parent
d2a5f4c5c1
commit
df20f5063d
7 changed files with 187 additions and 18 deletions
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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
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 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({}, {
|
||||||
|
|
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