This commit is contained in:
syuilo 2018-07-18 07:19:24 +09:00
parent d2a5f4c5c1
commit df20f5063d
7 changed files with 187 additions and 18 deletions

View File

@ -7,6 +7,11 @@
<span class="username">@{{ user | acct }}</span> <span class="username">@{{ user | acct }}</span>
</li> </li>
</ol> </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"> <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"> <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1">
<span class="emoji">{{ emoji.emoji }}</span> <span class="emoji">{{ emoji.emoji }}</span>
@ -48,33 +53,33 @@ emjdb.sort((a, b) => a.name.length - b.name.length);
export default Vue.extend({ export default Vue.extend({
props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'], props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'],
data() { data() {
return { return {
fetching: true, fetching: true,
users: [], users: [],
hashtags: [],
emojis: [], emojis: [],
select: -1, select: -1,
emojilib emojilib
} }
}, },
computed: { computed: {
items(): HTMLCollection { items(): HTMLCollection {
return (this.$refs.suggests as Element).children; return (this.$refs.suggests as Element).children;
} }
}, },
updated() { updated() {
//#region 調 //#region 調
const margin = 32; if (this.x + this.$el.offsetWidth > window.innerWidth) {
this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
if (this.x + this.$el.offsetWidth > window.innerWidth - margin) {
this.$el.style.left = (this.x - this.$el.offsetWidth) + 'px';
this.$el.style.marginLeft = '-16px';
} else { } else {
this.$el.style.left = this.x + 'px'; 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.top = (this.y - this.$el.offsetHeight) + 'px';
this.$el.style.marginTop = '0'; this.$el.style.marginTop = '0';
} else { } else {
@ -83,6 +88,7 @@ export default Vue.extend({
} }
//#endregion //#endregion
}, },
mounted() { mounted() {
this.textarea.addEventListener('keydown', this.onKeydown); this.textarea.addEventListener('keydown', this.onKeydown);
@ -100,6 +106,7 @@ export default Vue.extend({
}); });
}); });
}, },
beforeDestroy() { beforeDestroy() {
this.textarea.removeEventListener('keydown', this.onKeydown); this.textarea.removeEventListener('keydown', this.onKeydown);
@ -107,6 +114,7 @@ export default Vue.extend({
el.removeEventListener('mousedown', this.onMousedown); el.removeEventListener('mousedown', this.onMousedown);
}); });
}, },
methods: { methods: {
exec() { exec() {
this.select = -1; this.select = -1;
@ -117,7 +125,8 @@ export default Vue.extend({
} }
if (this.type == 'user') { if (this.type == 'user') {
const cache = sessionStorage.getItem(this.q); const cacheKey = 'autocomplete:user:' + this.q;
const cache = sessionStorage.getItem(cacheKey);
if (cache) { if (cache) {
const users = JSON.parse(cache); const users = JSON.parse(cache);
this.users = users; this.users = users;
@ -131,7 +140,26 @@ export default Vue.extend({
this.fetching = false; 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') { } else if (this.type == 'emoji') {
@ -260,6 +288,8 @@ root(isDark)
user-select none user-select none
&:hover &:hover
background isDark ? rgba(#fff, 0.1) : rgba(#000, 0.1)
&[data-selected='true'] &[data-selected='true']
background $theme-color background $theme-color
@ -292,6 +322,14 @@ root(isDark)
vertical-align middle vertical-align middle
color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3) 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 > .emojis > li
.emoji .emoji
@ -300,11 +338,11 @@ root(isDark)
width 24px width 24px
.name .name
color rgba(#000, 0.8) color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8)
.alias .alias
margin 0 0 0 8px margin 0 0 0 8px
color rgba(#000, 0.3) color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3)
.mk-autocomplete[data-darkmode] .mk-autocomplete[data-darkmode]
root(true) root(true)

View File

@ -67,15 +67,27 @@ class Autocomplete {
* *
*/ */
private onInput() { private onInput() {
const caret = this.textarea.selectionStart; const caretPos = this.textarea.selectionStart;
const text = this.text.substr(0, caret); const text = this.text.substr(0, caretPos);
const mentionIndex = text.lastIndexOf('@'); const mentionIndex = text.lastIndexOf('@');
const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = 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; let opened = false;
if (mentionIndex != -1 && mentionIndex > emojiIndex) { if (isMention) {
const username = text.substr(mentionIndex + 1); const username = text.substr(mentionIndex + 1);
if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) { if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) {
this.open('user', username); 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); const emoji = text.substr(emojiIndex + 1);
if (emoji != '' && emoji.match(/^[\+\-a-z0-9_]+$/)) { if (emoji != '' && emoji.match(/^[\+\-a-z0-9_]+$/)) {
this.open('emoji', emoji); this.open('emoji', emoji);
@ -173,6 +193,22 @@ class Autocomplete {
const pos = trimmedBefore.length + (value.username.length + 2); const pos = trimmedBefore.length + (value.username.length + 2);
this.textarea.setSelectionRange(pos, pos); 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') { } else if (type == 'emoji') {
const source = this.text; const source = this.text;

View File

@ -16,7 +16,7 @@
<a @click="addVisibleUser">+%i18n:@add-visible-user%</a> <a @click="addVisibleUser">+%i18n:@add-visible-user%</a>
</div> </div>
<input v-show="useCw" v-model="cw" placeholder="%i18n:@cw-placeholder%"> <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"> <div class="attaches" v-show="files.length != 0">
<x-draggable class="files" :list="files" :options="{ animation: 150 }"> <x-draggable class="files" :list="files" :options="{ animation: 150 }">
<div class="file" v-for="file in files" :key="file.id"> <div class="file" v-for="file in files" :key="file.id">

13
src/models/hashtag.ts Normal file
View File

@ -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;
}

View File

@ -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));
});

View File

@ -20,6 +20,7 @@ import UserList from '../../models/user-list';
import resolveUser from '../../remote/resolve-user'; import resolveUser from '../../remote/resolve-user';
import Meta from '../../models/meta'; import Meta from '../../models/meta';
import config from '../../config'; import config from '../../config';
import registerHashtag from '../register-hashtag';
type Type = 'reply' | 'renote' | 'quote' | 'mention'; type Type = 'reply' | 'renote' | 'quote' | 'mention';
@ -64,7 +65,6 @@ export default async (user: IUser, data: {
geo?: any; geo?: any;
poll?: any; poll?: any;
viaMobile?: boolean; viaMobile?: boolean;
tags?: string[];
cw?: string; cw?: string;
visibility?: string; visibility?: string;
visibleUsers?: IUser[]; visibleUsers?: IUser[];
@ -75,7 +75,7 @@ export default async (user: IUser, data: {
if (data.visibility == null) data.visibility = 'public'; if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false; if (data.viaMobile == null) data.viaMobile = false;
let tags = data.tags || []; let tags: string[] = [];
let tokens: any[] = null; let tokens: any[] = null;
@ -149,6 +149,9 @@ export default async (user: IUser, data: {
res(note); res(note);
// ハッシュタグ登録
tags.map(tag => registerHashtag(user, tag));
//#region Increment notes count //#region Increment notes count
if (isLocalUser(user)) { if (isLocalUser(user)) {
Meta.update({}, { Meta.update({}, {

View File

@ -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
});
}
}