parent
d2a5f4c5c1
commit
df20f5063d
7 changed files with 187 additions and 18 deletions
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
13
src/models/hashtag.ts
Normal file
13
src/models/hashtag.ts
Normal 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;
|
||||
}
|
51
src/server/api/endpoints/hashtags/search.ts
Normal file
51
src/server/api/endpoints/hashtags/search.ts
Normal 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));
|
||||
});
|
|
@ -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({}, {
|
||||
|
|
28
src/services/register-hashtag.ts
Normal file
28
src/services/register-hashtag.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue