From 1d5a54ff6f74569fa89c4083301d9b01eb80ad29 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 17 Feb 2019 23:41:47 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=8F=E3=83=83=E3=82=B7=E3=83=A5=E3=82=BF?=
 =?UTF-8?q?=E3=82=B0=E3=81=A7=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E6=A4=9C?=
 =?UTF-8?q?=E7=B4=A2=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?=
 =?UTF-8?q?=20(#4298)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* ハッシュタグでユーザー検索できるように

* :art:

* Increase limit

* リモートユーザーも表示

* Fix bug

* Fix bug

* Improve performance
---
 CHANGELOG.md                                  |  1 +
 locales/ja-JP.yml                             |  2 +
 src/client/app/common/views/components/mfm.ts |  8 +-
 .../app/common/views/components/user-list.vue |  2 +-
 src/client/app/common/views/pages/explore.vue | 86 ++++++++++++++++---
 src/client/app/common/views/pages/follow.vue  |  2 +-
 src/client/app/desktop/script.ts              |  2 +
 .../desktop/views/components/user-card.vue    |  2 +-
 .../desktop/views/deck/deck.user-column.vue   |  2 +-
 .../desktop/views/home/user/user.header.vue   |  2 +-
 src/client/app/mobile/script.ts               |  1 +
 .../mobile/views/components/user-preview.vue  |  2 +-
 .../app/mobile/views/pages/user/index.vue     |  2 +-
 src/models/hashtag.ts                         | 34 +++++++-
 src/models/user.ts                            |  1 +
 src/remote/activitypub/models/person.ts       |  9 ++
 src/server/api/endpoints/hashtags/list.ts     | 55 ++++++++++++
 src/server/api/endpoints/hashtags/users.ts    | 83 ++++++++++++++++++
 src/server/api/endpoints/i/update.ts          |  5 ++
 src/services/note/create.ts                   |  4 +-
 src/services/register-hashtag.ts              | 31 -------
 src/services/update-hashtag.ts                | 86 +++++++++++++++++++
 22 files changed, 366 insertions(+), 56 deletions(-)
 create mode 100644 src/server/api/endpoints/hashtags/list.ts
 create mode 100644 src/server/api/endpoints/hashtags/users.ts
 delete mode 100644 src/services/register-hashtag.ts
 create mode 100644 src/services/update-hashtag.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 219d42bee..60751a61d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@ ChangeLog
 
 unreleased
 ----------
+* ハッシュタグでユーザー検索できるように
 * Exploreページに新規ユーザー一覧を追加
 
 10.86.2
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 774bc169e..d77a99ad4 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -225,6 +225,8 @@ common/views/pages/explore.vue:
   popular-users: "人気のユーザー"
   recently-updated-users: "最近投稿したユーザー"
   recently-registered-users: "新規ユーザー"
+  popular-tags: "人気のタグ"
+  federated: "連合"
 
 common/views/components/games/reversi/reversi.vue:
   matching:
diff --git a/src/client/app/common/views/components/mfm.ts b/src/client/app/common/views/components/mfm.ts
index e322c53a3..78734200a 100644
--- a/src/client/app/common/views/components/mfm.ts
+++ b/src/client/app/common/views/components/mfm.ts
@@ -40,7 +40,11 @@ export default Vue.component('misskey-flavored-markdown', {
 		},
 		customEmojis: {
 			required: false,
-		}
+		},
+		isNote: {
+			type: Boolean,
+			default: true
+		},
 	},
 
 	render(createElement) {
@@ -204,7 +208,7 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement('router-link', {
 						key: Math.random(),
 						attrs: {
-							to: `/tags/${encodeURIComponent(token.node.props.hashtag)}`,
+							to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`,
 							style: 'color:var(--mfmHashtag);'
 						}
 					}, `#${token.node.props.hashtag}`)];
diff --git a/src/client/app/common/views/components/user-list.vue b/src/client/app/common/views/components/user-list.vue
index 8541e8543..ee44eac86 100644
--- a/src/client/app/common/views/components/user-list.vue
+++ b/src/client/app/common/views/components/user-list.vue
@@ -13,7 +13,7 @@
 					<p class="username">@{{ user | acct }}</p>
 				</div>
 				<div class="description" v-if="user.description" :title="user.description">
-					<mfm :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :should-break="false"/>
+					<mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :should-break="false"/>
 				</div>
 			</div>
 		</div>
diff --git a/src/client/app/common/views/pages/explore.vue b/src/client/app/common/views/pages/explore.vue
index 79fa26b70..2d273d3fd 100644
--- a/src/client/app/common/views/pages/explore.vue
+++ b/src/client/app/common/views/pages/explore.vue
@@ -1,29 +1,53 @@
 <template>
 <div>
-	<mk-user-list :make-promise="verifiedUsers">
-		<span><fa :icon="faBookmark"/> {{ $t('verified-users') }}</span>
+	<mk-user-list v-if="tag != null" :make-promise="tagUsers" :key="tag">
+		<fa :icon="faHashtag" fixed-width/>{{ tag }}
 	</mk-user-list>
-	<mk-user-list :make-promise="popularUsers">
-		<span><fa :icon="faChartLine"/> {{ $t('popular-users') }}</span>
-	</mk-user-list>
-	<mk-user-list :make-promise="recentlyUpdatedUsers">
-		<span><fa :icon="faCommentAlt"/> {{ $t('recently-updated-users') }}</span>
-	</mk-user-list>
-	<mk-user-list :make-promise="recentlyRegisteredUsers">
-		<span><fa :icon="faPlus"/> {{ $t('recently-registered-users') }}</span>
+	<mk-user-list v-if="tag != null" :make-promise="tagRemoteUsers" :key="tag">
+		<fa :icon="faHashtag" fixed-width/>{{ tag }} ({{ $t('federated') }})
 	</mk-user-list>
+
+	<ui-container :body-togglable="true">
+		<template slot="header"><fa :icon="faHashtag" fixed-width/>{{ $t('popular-tags') }}</template>
+
+		<div class="vxjfqztj">
+			<router-link v-for="tag in tags" :to="`/explore/tags/${tag.tag}`" :key="tag.tag">{{ tag.tag }}</router-link>
+		</div>
+	</ui-container>
+
+	<template v-if="tag == null">
+		<mk-user-list :make-promise="verifiedUsers">
+			<fa :icon="faBookmark" fixed-width/>{{ $t('verified-users') }}
+		</mk-user-list>
+		<mk-user-list :make-promise="popularUsers">
+			<fa :icon="faChartLine" fixed-width/>{{ $t('popular-users') }}
+		</mk-user-list>
+		<mk-user-list :make-promise="recentlyUpdatedUsers">
+			<fa :icon="faCommentAlt" fixed-width/>{{ $t('recently-updated-users') }}
+		</mk-user-list>
+		<mk-user-list :make-promise="recentlyRegisteredUsers">
+			<fa :icon="faPlus" fixed-width/>{{ $t('recently-registered-users') }}
+		</mk-user-list>
+	</template>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import { faChartLine, faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faChartLine, faPlus, faHashtag } from '@fortawesome/free-solid-svg-icons';
 import { faBookmark, faCommentAlt } from '@fortawesome/free-regular-svg-icons';
 
 export default Vue.extend({
 	i18n: i18n('common/views/pages/explore.vue'),
 
+	props: {
+		tag: {
+			type: String,
+			required: false
+		}
+	},
+
 	data() {
 		return {
 			verifiedUsers: () => this.$root.api('users', {
@@ -49,11 +73,49 @@ export default Vue.extend({
 				sort: '+createdAt',
 				limit: 10
 			}),
-			faBookmark, faChartLine, faCommentAlt, faPlus
+			tags: [],
+			faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag
 		};
 	},
+
+	computed: {
+		tagUsers(): () => Promise<any> {
+			return () => this.$root.api('hashtags/users', {
+				tag: this.tag,
+				state: 'alive',
+				origin: 'local',
+				sort: '+follower',
+				limit: 30
+			});
+		},
+
+		tagRemoteUsers(): () => Promise<any> {
+			return () => this.$root.api('hashtags/users', {
+				tag: this.tag,
+				state: 'alive',
+				origin: 'remote',
+				sort: '+follower',
+				limit: 30
+			});
+		},
+	},
+
+	created() {
+		this.$root.api('hashtags/list', {
+			sort: '+attachedLocalUsers',
+			limit: 30
+		}).then(tags => {
+			this.tags = tags;
+		});
+	}
 });
 </script>
 
 <style lang="stylus" scoped>
+.vxjfqztj
+	padding 16px
+
+	> *
+		margin-right 16px
+
 </style>
diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue
index 4d1febaec..f8d12a2dc 100644
--- a/src/client/app/common/views/pages/follow.vue
+++ b/src/client/app/common/views/pages/follow.vue
@@ -12,7 +12,7 @@
 			</router-link>
 			<span class="username">@{{ user | acct }}</span>
 			<div class="description">
-				<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+				<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
 			</div>
 		</div>
 	</main>
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index c66171e3a..8c8e3c3fb 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -146,6 +146,7 @@ init(async (launch, os) => {
 					{ path: '/tags/:tag', name: 'tag', component: () => import('./views/deck/deck.hashtag-column.vue').then(m => m.default) },
 					{ path: '/featured', component: () => import('./views/deck/deck.featured-column.vue').then(m => m.default) },
 					{ path: '/explore', component: () => import('./views/deck/deck.explore-column.vue').then(m => m.default) },
+					{ path: '/explore/tags/:tag', props: true, component: () => import('./views/deck/deck.explore-column.vue').then(m => m.default) },
 					{ path: '/i/favorites', component: () => import('./views/deck/deck.favorites-column.vue').then(m => m.default) }
 				]}
 				: { path: '/', component: MkHome, children: [
@@ -160,6 +161,7 @@ init(async (launch, os) => {
 					{ path: '/tags/:tag', name: 'tag', component: () => import('./views/home/tag.vue').then(m => m.default) },
 					{ path: '/featured', name: 'featured', component: () => import('./views/home/featured.vue').then(m => m.default) },
 					{ path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
+					{ path: '/explore/tags/:tag', name: 'explore', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
 					{ path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) },
 				]},
 			{ path: '/i/messaging/:user', component: MkMessagingRoom },
diff --git a/src/client/app/desktop/views/components/user-card.vue b/src/client/app/desktop/views/components/user-card.vue
index 21a4ab9f6..61b3be930 100644
--- a/src/client/app/desktop/views/components/user-card.vue
+++ b/src/client/app/desktop/views/components/user-card.vue
@@ -10,7 +10,7 @@
 		<span class="username">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span>
 
 		<div class="description">
-			<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+			<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
 		</div>
 	</div>
 </div>
diff --git a/src/client/app/desktop/views/deck/deck.user-column.vue b/src/client/app/desktop/views/deck/deck.user-column.vue
index d6618c571..813667f6a 100644
--- a/src/client/app/desktop/views/deck/deck.user-column.vue
+++ b/src/client/app/desktop/views/deck/deck.user-column.vue
@@ -25,7 +25,7 @@
 		</header>
 		<div class="info">
 			<div class="description">
-				<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+				<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
 			</div>
 			<div class="fields" v-if="user.fields">
 				<dl class="field" v-for="(field, i) in user.fields" :key="i">
diff --git a/src/client/app/desktop/views/home/user/user.header.vue b/src/client/app/desktop/views/home/user/user.header.vue
index debfb2439..1219a0791 100644
--- a/src/client/app/desktop/views/home/user/user.header.vue
+++ b/src/client/app/desktop/views/home/user/user.header.vue
@@ -23,7 +23,7 @@
 			<ui-button @click="menu" ref="menu" :inline="true"><fa icon="ellipsis-h"/></ui-button>
 		</div>
 		<div class="description">
-			<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+			<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
 		</div>
 		<div class="fields" v-if="user.fields">
 			<dl class="field" v-for="(field, i) in user.fields" :key="i">
diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts
index dbdc0f630..9bec577d7 100644
--- a/src/client/app/mobile/script.ts
+++ b/src/client/app/mobile/script.ts
@@ -133,6 +133,7 @@ init((launch) => {
 			{ path: '/tags/:tag', component: MkTag },
 			{ path: '/featured', name: 'featured', component: () => import('./views/pages/featured.vue').then(m => m.default) },
 			{ path: '/explore', name: 'explore', component: () => import('./views/pages/explore.vue').then(m => m.default) },
+			{ path: '/explore/tags/:tag', name: 'explore', props: true, component: () => import('./views/pages/explore.vue').then(m => m.default) },
 			{ path: '/share', component: MkShare },
 			{ path: '/games/reversi/:game?', name: 'reversi', component: MkReversi },
 			{ path: '/@:user', name: 'user', component: () => import('./views/pages/user/index.vue').then(m => m.default), children: [
diff --git a/src/client/app/mobile/views/components/user-preview.vue b/src/client/app/mobile/views/components/user-preview.vue
index b40e6f761..ea8bbe242 100644
--- a/src/client/app/mobile/views/components/user-preview.vue
+++ b/src/client/app/mobile/views/components/user-preview.vue
@@ -10,7 +10,7 @@
 		</header>
 		<div class="body">
 			<div class="description">
-				<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+				<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
 			</div>
 		</div>
 	</div>
diff --git a/src/client/app/mobile/views/pages/user/index.vue b/src/client/app/mobile/views/pages/user/index.vue
index 48b65624e..d7fb3d4d5 100644
--- a/src/client/app/mobile/views/pages/user/index.vue
+++ b/src/client/app/mobile/views/pages/user/index.vue
@@ -22,7 +22,7 @@
 					<span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span>
 				</div>
 				<div class="description">
-					<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+					<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
 				</div>
 				<div class="fields" v-if="user.fields">
 					<dl class="field" v-for="(field, i) in user.fields" :key="i">
diff --git a/src/models/hashtag.ts b/src/models/hashtag.ts
index f5b615605..742e4a254 100644
--- a/src/models/hashtag.ts
+++ b/src/models/hashtag.ts
@@ -3,11 +3,41 @@ import db from '../db/mongodb';
 
 const Hashtag = db.get<IHashtags>('hashtags');
 Hashtag.createIndex('tag', { unique: true });
-Hashtag.createIndex('mentionedUserIdsCount');
+Hashtag.createIndex('mentionedUsersCount');
+Hashtag.createIndex('mentionedLocalUsersCount');
+Hashtag.createIndex('attachedUsersCount');
+Hashtag.createIndex('attachedLocalUsersCount');
 export default Hashtag;
 
+// 後方互換性のため
+Hashtag.findOne({ attachedUserIds: { $exists: false }}).then(h => {
+	if (h != null) {
+		Hashtag.update({}, {
+			$rename: {
+				mentionedUserIdsCount: 'mentionedUsersCount'
+			},
+			$set: {
+				mentionedLocalUserIds: [],
+				mentionedLocalUsersCount: 0,
+				attachedUserIds: [],
+				attachedUsersCount: 0,
+				attachedLocalUserIds: [],
+				attachedLocalUsersCount: 0,
+			}
+		}, {
+			multi: true
+		});
+	}
+});
+
 export interface IHashtags {
 	tag: string;
 	mentionedUserIds: mongo.ObjectID[];
-	mentionedUserIdsCount: number;
+	mentionedUsersCount: number;
+	mentionedLocalUserIds: mongo.ObjectID[];
+	mentionedLocalUsersCount: number;
+	attachedUserIds: mongo.ObjectID[];
+	attachedUsersCount: number;
+	attachedLocalUserIds: mongo.ObjectID[];
+	attachedLocalUsersCount: number;
 }
diff --git a/src/models/user.ts b/src/models/user.ts
index 6cc44f371..2549b2568 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -18,6 +18,7 @@ const User = db.get<IUser>('users');
 User.createIndex('createdAt');
 User.createIndex('updatedAt');
 User.createIndex('followersCount');
+User.createIndex('tags');
 User.createIndex('username');
 User.createIndex('usernameLower');
 User.createIndex('host');
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index c90df1690..9a38bbf14 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -23,6 +23,7 @@ import Following from '../../../models/following';
 import { IIdentifier } from './identifier';
 import { apLogger } from '../logger';
 import { INote } from '../../../models/note';
+import { updateHashtag } from '../../../services/update-hashtag';
 const logger = apLogger;
 
 /**
@@ -210,6 +211,10 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU
 	usersChart.update(user, true);
 	//#endregion
 
+	// ハッシュタグ登録
+	for (const tag of tags) updateHashtag(user, tag, true, true);
+	for (const tag of (user.tags || []).filter(x => !tags.includes(x))) updateHashtag(user, tag, true, false);
+
 	//#region アイコンとヘッダー画像をフェッチ
 	const [avatar, banner] = (await Promise.all<IDriveFile>([
 		person.icon,
@@ -383,6 +388,10 @@ export async function updatePerson(uri: string, resolver?: Resolver, hint?: obje
 		$set: updates
 	});
 
+	// ハッシュタグ更新
+	for (const tag of tags) updateHashtag(exist, tag, true, true);
+	for (const tag of (exist.tags || []).filter(x => !tags.includes(x))) updateHashtag(exist, tag, true, false);
+
 	// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
 	await Following.update({
 		followerId: exist._id
diff --git a/src/server/api/endpoints/hashtags/list.ts b/src/server/api/endpoints/hashtags/list.ts
new file mode 100644
index 000000000..5c37dbd6b
--- /dev/null
+++ b/src/server/api/endpoints/hashtags/list.ts
@@ -0,0 +1,55 @@
+import $ from 'cafy';
+import define from '../../define';
+import Hashtag from '../../../../models/hashtag';
+
+export const meta = {
+	requireCredential: false,
+
+	params: {
+		limit: {
+			validator: $.optional.num.range(1, 100),
+			default: 10
+		},
+
+		sort: {
+			validator: $.str.or([
+				'+mentionedUsers',
+				'-mentionedUsers',
+				'+mentionedLocalUsers',
+				'-mentionedLocalUsers',
+				'+attachedUsers',
+				'-attachedUsers',
+				'+attachedLocalUsers',
+				'-attachedLocalUsers',
+			]),
+		},
+	}
+};
+
+const sort: any = {
+	'+mentionedUsers': { mentionedUsersCount: -1 },
+	'-mentionedUsers': { mentionedUsersCount: 1 },
+	'+mentionedLocalUsers': { mentionedLocalUsersCount: -1 },
+	'-mentionedLocalUsers': { mentionedLocalUsersCount: 1 },
+	'+attachedUsers': { attachedUsersCount: -1 },
+	'-attachedUsers': { attachedUsersCount: 1 },
+	'+attachedLocalUsers': { attachedLocalUsersCount: -1 },
+	'-attachedLocalUsers': { attachedLocalUsersCount: 1 },
+};
+
+export default define(meta, (ps, me) => new Promise(async (res, rej) => {
+	const tags = await Hashtag
+		.find({}, {
+			limit: ps.limit,
+			sort: sort[ps.sort],
+			fields: {
+				tag: true,
+				mentionedUsersCount: true,
+				mentionedLocalUsersCount: true,
+				attachedUsersCount: true,
+				attachedLocalUsersCount: true
+			}
+		});
+
+	res(tags);
+}));
diff --git a/src/server/api/endpoints/hashtags/users.ts b/src/server/api/endpoints/hashtags/users.ts
new file mode 100644
index 000000000..be6b53b88
--- /dev/null
+++ b/src/server/api/endpoints/hashtags/users.ts
@@ -0,0 +1,83 @@
+import $ from 'cafy';
+import User, { pack } from '../../../../models/user';
+import define from '../../define';
+
+export const meta = {
+	requireCredential: false,
+
+	params: {
+		tag: {
+			validator: $.str,
+		},
+
+		limit: {
+			validator: $.optional.num.range(1, 100),
+			default: 10
+		},
+
+		sort: {
+			validator: $.str.or([
+				'+follower',
+				'-follower',
+				'+createdAt',
+				'-createdAt',
+				'+updatedAt',
+				'-updatedAt',
+			]),
+		},
+
+		state: {
+			validator: $.optional.str.or([
+				'all',
+				'alive'
+			]),
+			default: 'all'
+		},
+
+		origin: {
+			validator: $.optional.str.or([
+				'combined',
+				'local',
+				'remote',
+			]),
+			default: 'local'
+		}
+	}
+};
+
+const sort: any = {
+	'+follower': { followersCount: -1 },
+	'-follower': { followersCount: 1 },
+	'+createdAt': { createdAt: -1 },
+	'-createdAt': { createdAt: 1 },
+	'+updatedAt': { updatedAt: -1 },
+	'-updatedAt': { updatedAt: 1 },
+};
+
+export default define(meta, (ps, me) => new Promise(async (res, rej) => {
+	const q = {
+		tags: ps.tag,
+		$and: []
+	} as any;
+
+	// state
+	q.$and.push(
+		ps.state == 'alive' ? { updatedAt: { $gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)) } } :
+		{}
+	);
+
+	// origin
+	q.$and.push(
+		ps.origin == 'local' ? { host: null } :
+		ps.origin == 'remote' ? { host: { $ne: null } } :
+		{}
+	);
+
+	const users = await User
+		.find(q, {
+			limit: ps.limit,
+			sort: sort[ps.sort],
+		});
+
+	res(await Promise.all(users.map(user => pack(user, me, { detail: true }))));
+}));
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index 6ae63c52d..b3ec53223 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -11,6 +11,7 @@ import { parse, parsePlain } from '../../../../mfm/parse';
 import extractEmojis from '../../../../misc/extract-emojis';
 import extractHashtags from '../../../../misc/extract-hashtags';
 import * as langmap from 'langmap';
+import { updateHashtag } from '../../../../services/update-hashtag';
 
 export const meta = {
 	desc: {
@@ -221,6 +222,10 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => {
 
 		updates.emojis = emojis;
 		updates.tags = tags;
+
+		// ハッシュタグ更新
+		for (const tag of tags) updateHashtag(user, tag, true, true);
+		for (const tag of (user.tags || []).filter(x => !tags.includes(x))) updateHashtag(user, tag, true, false);
 	}
 	//#endregion
 
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 126d698b0..c94686dcc 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -19,7 +19,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';
+import { updateHashtag } from '../update-hashtag';
 import isQuote from '../../misc/is-quote';
 import notesChart from '../../services/chart/notes';
 import perUserNotesChart from '../../services/chart/per-user-notes';
@@ -235,7 +235,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 	}
 
 	// ハッシュタグ登録
-	for (const tag of tags) registerHashtag(user, tag);
+	for (const tag of tags) updateHashtag(user, tag);
 
 	// ファイルが添付されていた場合ドライブのファイルの「このファイルが添付された投稿一覧」プロパティにこの投稿を追加
 	if (data.files) {
diff --git a/src/services/register-hashtag.ts b/src/services/register-hashtag.ts
deleted file mode 100644
index 01b7bc871..000000000
--- a/src/services/register-hashtag.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { IUser } from '../models/user';
-import Hashtag from '../models/hashtag';
-import hashtagChart from '../services/chart/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
-		});
-	}
-
-	hashtagChart.update(tag, user);
-}
diff --git a/src/services/update-hashtag.ts b/src/services/update-hashtag.ts
new file mode 100644
index 000000000..e5de7c10c
--- /dev/null
+++ b/src/services/update-hashtag.ts
@@ -0,0 +1,86 @@
+import { IUser, isLocalUser } from '../models/user';
+import Hashtag from '../models/hashtag';
+import hashtagChart from './chart/hashtag';
+
+export async function updateHashtag(user: IUser, tag: string, isUserAttached = false, inc = true) {
+	tag = tag.toLowerCase();
+
+	const index = await Hashtag.findOne({ tag });
+
+	if (index == null && !inc) return;
+
+	if (index != null) {
+		const $push = {} as any;
+		const $pull = {} as any;
+		const $inc = {} as any;
+
+		if (isUserAttached) {
+			if (inc) {
+				// 自分が初めてこのタグを使ったなら
+				if (!index.attachedUserIds.some(id => id.equals(user._id))) {
+					$push.attachedUserIds = user._id;
+					$inc.attachedUsersCount = 1;
+				}
+				// 自分が(ローカル内で)初めてこのタグを使ったなら
+				if (isLocalUser(user) && !index.attachedLocalUserIds.some(id => id.equals(user._id))) {
+					$push.attachedLocalUserIds = user._id;
+					$inc.attachedLocalUsersCount = 1;
+				}
+			} else {
+				$pull.attachedUserIds = user._id;
+				$inc.attachedUsersCount = -1;
+				if (isLocalUser(user)) {
+					$pull.attachedLocalUserIds = user._id;
+					$inc.attachedLocalUsersCount = -1;
+				}
+			}
+		} else {
+			// 自分が初めてこのタグを使ったなら
+			if (!index.mentionedUserIds.some(id => id.equals(user._id))) {
+				$push.mentionedUserIds = user._id;
+				$inc.mentionedUsersCount = 1;
+			}
+			// 自分が(ローカル内で)初めてこのタグを使ったなら
+			if (isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id.equals(user._id))) {
+				$push.mentionedLocalUserIds = user._id;
+				$inc.mentionedLocalUsersCount = 1;
+			}
+		}
+
+		const q = {} as any;
+		if (Object.keys($push).length > 0) q.$push = $push;
+		if (Object.keys($pull).length > 0) q.$pull = $pull;
+		if (Object.keys($inc).length > 0) q.$inc = $inc;
+		if (Object.keys(q).length > 0) Hashtag.update({ tag }, q);
+	} else {
+		if (isUserAttached) {
+			Hashtag.insert({
+				tag,
+				mentionedUserIds: [],
+				mentionedUsersCount: 0,
+				mentionedLocalUserIds: [],
+				mentionedLocalUsersCount: 0,
+				attachedUserIds: [user._id],
+				attachedUsersCount: 1,
+				attachedLocalUserIds: isLocalUser(user) ? [user._id] : [],
+				attachedLocalUsersCount: isLocalUser(user) ? 1 : 0
+			});
+		} else {
+			Hashtag.insert({
+				tag,
+				mentionedUserIds: [user._id],
+				mentionedUsersCount: 1,
+				mentionedLocalUserIds: isLocalUser(user) ? [user._id] : [],
+				mentionedLocalUsersCount: isLocalUser(user) ? 1 : 0,
+				attachedUserIds: [],
+				attachedUsersCount: 0,
+				attachedLocalUserIds: [],
+				attachedLocalUsersCount: 0
+			});
+		}
+	}
+
+	if (!isUserAttached) {
+		hashtagChart.update(tag, user);
+	}
+}