This commit is contained in:
		
							parent
							
								
									d2a5f4c5c1
								
							
						
					
					
						commit
						df20f5063d
					
				|  | @ -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"> | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  | @ -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({}, { | ||||
|  |  | |||
|  | @ -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 New Issue