diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue index 712ce8a31..0d2ffbe88 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/app/common/views/components/autocomplete.vue @@ -7,6 +7,11 @@ @{{ user | acct }} +
    +
  1. + {{ hashtag }} +
  2. +
  1. {{ emoji.emoji }} @@ -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 94635d301..7ec377111 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 1015a4411..52ba95e87 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 @@ +%i18n:@add-visible-user% - +
    diff --git a/src/models/hashtag.ts b/src/models/hashtag.ts new file mode 100644 index 000000000..f5b615605 --- /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('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 000000000..988a786a0 --- /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 aec0e7896..6629e691b 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 000000000..ca6b74783 --- /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 + }); + } +}