summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2018-07-18 07:19:24 +0900
committersyuilo <syuilotan@yahoo.co.jp>2018-07-18 07:19:24 +0900
commitdf20f5063dd5f93235307e5fca6fc7b17625bea9 (patch)
tree4adfc17a5ea5794e4d70fe1fe6fbc40812277e3d /src
parent:v: (diff)
downloadsharkey-df20f5063dd5f93235307e5fca6fc7b17625bea9.tar.gz
sharkey-df20f5063dd5f93235307e5fca6fc7b17625bea9.tar.bz2
sharkey-df20f5063dd5f93235307e5fca6fc7b17625bea9.zip
#1720 #59
Diffstat (limited to 'src')
-rw-r--r--src/client/app/common/views/components/autocomplete.vue60
-rw-r--r--src/client/app/common/views/directives/autocomplete.ts44
-rw-r--r--src/client/app/mobile/views/components/post-form.vue2
-rw-r--r--src/models/hashtag.ts13
-rw-r--r--src/server/api/endpoints/hashtags/search.ts51
-rw-r--r--src/services/note/create.ts7
-rw-r--r--src/services/register-hashtag.ts28
7 files changed, 187 insertions, 18 deletions
diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue
index 712ce8a31f..0d2ffbe88d 100644
--- a/src/client/app/common/views/components/autocomplete.vue
+++ b/src/client/app/common/views/components/autocomplete.vue
@@ -7,6 +7,11 @@
<span class="username">@{{ user | acct }}</span>
</li>
</ol>
+ <ol class="hashtags" ref="suggests" v-if="hashtags.length > 0">
+ <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1">
+ <span class="name">{{ hashtag }}</span>
+ </li>
+ </ol>
<ol class="emojis" ref="suggests" v-if="emojis.length > 0">
<li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1">
<span class="emoji">{{ emoji.emoji }}</span>
@@ -48,33 +53,33 @@ emjdb.sort((a, b) => a.name.length - b.name.length);
export default Vue.extend({
props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'],
+
data() {
return {
fetching: true,
users: [],
+ hashtags: [],
emojis: [],
select: -1,
emojilib
}
},
+
computed: {
items(): HTMLCollection {
return (this.$refs.suggests as Element).children;
}
},
+
updated() {
//#region 位置調整
- const margin = 32;
-
- if (this.x + this.$el.offsetWidth > window.innerWidth - margin) {
- this.$el.style.left = (this.x - this.$el.offsetWidth) + 'px';
- this.$el.style.marginLeft = '-16px';
+ if (this.x + this.$el.offsetWidth > window.innerWidth) {
+ this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
} else {
this.$el.style.left = this.x + 'px';
- this.$el.style.marginLeft = '0';
}
- if (this.y + this.$el.offsetHeight > window.innerHeight - margin) {
+ if (this.y + this.$el.offsetHeight > window.innerHeight) {
this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
this.$el.style.marginTop = '0';
} else {
@@ -83,6 +88,7 @@ export default Vue.extend({
}
//#endregion
},
+
mounted() {
this.textarea.addEventListener('keydown', this.onKeydown);
@@ -100,6 +106,7 @@ export default Vue.extend({
});
});
},
+
beforeDestroy() {
this.textarea.removeEventListener('keydown', this.onKeydown);
@@ -107,6 +114,7 @@ export default Vue.extend({
el.removeEventListener('mousedown', this.onMousedown);
});
},
+
methods: {
exec() {
this.select = -1;
@@ -117,7 +125,8 @@ export default Vue.extend({
}
if (this.type == 'user') {
- const cache = sessionStorage.getItem(this.q);
+ const cacheKey = 'autocomplete:user:' + this.q;
+ const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const users = JSON.parse(cache);
this.users = users;
@@ -131,7 +140,26 @@ export default Vue.extend({
this.fetching = false;
// キャッシュ
- sessionStorage.setItem(this.q, JSON.stringify(users));
+ sessionStorage.setItem(cacheKey, JSON.stringify(users));
+ });
+ }
+ } else if (this.type == 'hashtag') {
+ const cacheKey = 'autocomplete:hashtag:' + this.q;
+ const cache = sessionStorage.getItem(cacheKey);
+ if (cache) {
+ const hashtags = JSON.parse(cache);
+ this.hashtags = hashtags;
+ this.fetching = false;
+ } else {
+ (this as any).api('hashtags/search', {
+ query: this.q,
+ limit: 30
+ }).then(hashtags => {
+ this.hashtags = hashtags;
+ this.fetching = false;
+
+ // キャッシュ
+ sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
});
}
} else if (this.type == 'emoji') {
@@ -260,6 +288,8 @@ root(isDark)
user-select none
&:hover
+ background isDark ? rgba(#fff, 0.1) : rgba(#000, 0.1)
+
&[data-selected='true']
background $theme-color
@@ -292,6 +322,14 @@ root(isDark)
vertical-align middle
color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3)
+
+ > .hashtags > li
+
+ .name
+ vertical-align middle
+ margin 0 8px 0 0
+ color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8)
+
> .emojis > li
.emoji
@@ -300,11 +338,11 @@ root(isDark)
width 24px
.name
- color rgba(#000, 0.8)
+ color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8)
.alias
margin 0 0 0 8px
- color rgba(#000, 0.3)
+ color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3)
.mk-autocomplete[data-darkmode]
root(true)
diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts
index 94635d301a..7ec377111b 100644
--- a/src/client/app/common/views/directives/autocomplete.ts
+++ b/src/client/app/common/views/directives/autocomplete.ts
@@ -67,15 +67,27 @@ class Autocomplete {
* テキスト入力時
*/
private onInput() {
- const caret = this.textarea.selectionStart;
- const text = this.text.substr(0, caret);
+ const caretPos = this.textarea.selectionStart;
+ const text = this.text.substr(0, caretPos);
const mentionIndex = text.lastIndexOf('@');
+ const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = text.lastIndexOf(':');
+ const start = Math.min(
+ mentionIndex == -1 ? Infinity : mentionIndex,
+ hashtagIndex == -1 ? Infinity : hashtagIndex,
+ emojiIndex == -1 ? Infinity : emojiIndex);
+
+ if (start == Infinity) return;
+
+ const isMention = mentionIndex == start;
+ const isHashtag = hashtagIndex == start;
+ const isEmoji = emojiIndex == start;
+
let opened = false;
- if (mentionIndex != -1 && mentionIndex > emojiIndex) {
+ if (isMention) {
const username = text.substr(mentionIndex + 1);
if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) {
this.open('user', username);
@@ -83,7 +95,15 @@ class Autocomplete {
}
}
- if (emojiIndex != -1 && emojiIndex > mentionIndex) {
+ if (isHashtag || opened == false) {
+ const hashtag = text.substr(hashtagIndex + 1);
+ if (hashtag != '' && !hashtag.includes(' ') && !hashtag.includes('\n')) {
+ this.open('hashtag', hashtag);
+ opened = true;
+ }
+ }
+
+ if (isEmoji || opened == false) {
const emoji = text.substr(emojiIndex + 1);
if (emoji != '' && emoji.match(/^[\+\-a-z0-9_]+$/)) {
this.open('emoji', emoji);
@@ -173,6 +193,22 @@ class Autocomplete {
const pos = trimmedBefore.length + (value.username.length + 2);
this.textarea.setSelectionRange(pos, pos);
});
+ } else if (type == 'hashtag') {
+ const source = this.text;
+
+ const before = source.substr(0, caret);
+ const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
+ const after = source.substr(caret);
+
+ // 挿入
+ this.text = trimmedBefore + '#' + value + ' ' + after;
+
+ // キャレットを戻す
+ this.vm.$nextTick(() => {
+ this.textarea.focus();
+ const pos = trimmedBefore.length + (value.length + 2);
+ this.textarea.setSelectionRange(pos, pos);
+ });
} else if (type == 'emoji') {
const source = this.text;
diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue
index 1015a44115..52ba95e87a 100644
--- a/src/client/app/mobile/views/components/post-form.vue
+++ b/src/client/app/mobile/views/components/post-form.vue
@@ -16,7 +16,7 @@
<a @click="addVisibleUser">+%i18n:@add-visible-user%</a>
</div>
<input v-show="useCw" v-model="cw" placeholder="%i18n:@cw-placeholder%">
- <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder"></textarea>
+ <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="'text'"></textarea>
<div class="attaches" v-show="files.length != 0">
<x-draggable class="files" :list="files" :options="{ animation: 150 }">
<div class="file" v-for="file in files" :key="file.id">
diff --git a/src/models/hashtag.ts b/src/models/hashtag.ts
new file mode 100644
index 0000000000..f5b6156055
--- /dev/null
+++ b/src/models/hashtag.ts
@@ -0,0 +1,13 @@
+import * as mongo from 'mongodb';
+import db from '../db/mongodb';
+
+const Hashtag = db.get<IHashtags>('hashtags');
+Hashtag.createIndex('tag', { unique: true });
+Hashtag.createIndex('mentionedUserIdsCount');
+export default Hashtag;
+
+export interface IHashtags {
+ tag: string;
+ mentionedUserIds: mongo.ObjectID[];
+ mentionedUserIdsCount: number;
+}
diff --git a/src/server/api/endpoints/hashtags/search.ts b/src/server/api/endpoints/hashtags/search.ts
new file mode 100644
index 0000000000..988a786a08
--- /dev/null
+++ b/src/server/api/endpoints/hashtags/search.ts
@@ -0,0 +1,51 @@
+import $ from 'cafy';
+import Hashtag from '../../../../models/hashtag';
+import getParams from '../../get-params';
+
+export const meta = {
+ desc: {
+ ja: 'ハッシュタグを検索します。'
+ },
+
+ requireCredential: false,
+
+ params: {
+ limit: $.num.optional.range(1, 100).note({
+ default: 10,
+ desc: {
+ ja: '最大数'
+ }
+ }),
+
+ query: $.str.note({
+ desc: {
+ ja: 'クエリ'
+ }
+ }),
+
+ offset: $.num.optional.min(0).note({
+ default: 0,
+ desc: {
+ ja: 'オフセット'
+ }
+ })
+ }
+};
+
+export default (params: any) => new Promise(async (res, rej) => {
+ const [ps, psErr] = getParams(meta, params);
+ if (psErr) throw psErr;
+
+ const hashtags = await Hashtag
+ .find({
+ tag: new RegExp(ps.query.toLowerCase())
+ }, {
+ sort: {
+ count: -1
+ },
+ limit: ps.limit,
+ skip: ps.offset
+ });
+
+ res(hashtags.map(tag => tag.tag));
+});
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index aec0e78964..6629e691b7 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -20,6 +20,7 @@ import UserList from '../../models/user-list';
import resolveUser from '../../remote/resolve-user';
import Meta from '../../models/meta';
import config from '../../config';
+import registerHashtag from '../register-hashtag';
type Type = 'reply' | 'renote' | 'quote' | 'mention';
@@ -64,7 +65,6 @@ export default async (user: IUser, data: {
geo?: any;
poll?: any;
viaMobile?: boolean;
- tags?: string[];
cw?: string;
visibility?: string;
visibleUsers?: IUser[];
@@ -75,7 +75,7 @@ export default async (user: IUser, data: {
if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false;
- let tags = data.tags || [];
+ let tags: string[] = [];
let tokens: any[] = null;
@@ -149,6 +149,9 @@ export default async (user: IUser, data: {
res(note);
+ // ハッシュタグ登録
+ tags.map(tag => registerHashtag(user, tag));
+
//#region Increment notes count
if (isLocalUser(user)) {
Meta.update({}, {
diff --git a/src/services/register-hashtag.ts b/src/services/register-hashtag.ts
new file mode 100644
index 0000000000..ca6b74783b
--- /dev/null
+++ b/src/services/register-hashtag.ts
@@ -0,0 +1,28 @@
+import { IUser } from '../models/user';
+import Hashtag from '../models/hashtag';
+
+export default async function(user: IUser, tag: string) {
+ tag = tag.toLowerCase();
+
+ const index = await Hashtag.findOne({ tag });
+
+ if (index != null) {
+ // 自分が初めてこのタグを使ったなら
+ if (!index.mentionedUserIds.some(id => id.equals(user._id))) {
+ Hashtag.update({ tag }, {
+ $push: {
+ mentionedUserIds: user._id
+ },
+ $inc: {
+ mentionedUserIdsCount: 1
+ }
+ });
+ }
+ } else {
+ Hashtag.insert({
+ tag,
+ mentionedUserIds: [user._id],
+ mentionedUserIdsCount: 1
+ });
+ }
+}