From 87f61e714ad3b17856a6a5ac66051707badb3bd0 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Mon, 19 Oct 2020 19:29:04 +0900
Subject: [PATCH] Resolve #6087

---
 locales/ja-JP.yml                             |   7 +
 .../1603094348345-refine-abuse-user-report.ts |  38 ++++
 ...1603095701770-refine-abuse-user-report2.ts |  20 +++
 src/client/components/abuse-report-window.vue |  85 +++++++++
 src/client/components/note.vue                |  17 +-
 src/client/components/page-window.vue         |   8 +-
 src/client/components/sidebar.vue             |   7 +-
 src/client/components/ui/window.vue           |   9 +-
 src/client/os.ts                              |   6 +-
 src/client/pages/instance/abuses.vue          | 163 ++++++++++++++++++
 src/client/router.ts                          |   1 +
 src/client/scripts/get-user-menu.ts           |  14 +-
 src/models/entities/abuse-user-report.ts      |  41 ++++-
 src/models/repositories/abuse-user-report.ts  |   9 +-
 src/remote/activitypub/kernel/flag/index.ts   |   4 +-
 .../api/endpoints/admin/abuse-user-reports.ts |  38 ++++
 ...report.ts => resolve-abuse-user-report.ts} |   7 +-
 .../api/endpoints/users/report-abuse.ts       |  10 +-
 18 files changed, 461 insertions(+), 23 deletions(-)
 create mode 100644 migration/1603094348345-refine-abuse-user-report.ts
 create mode 100644 migration/1603095701770-refine-abuse-user-report2.ts
 create mode 100644 src/client/components/abuse-report-window.vue
 create mode 100644 src/client/pages/instance/abuses.vue
 rename src/server/api/endpoints/admin/{remove-abuse-user-report.ts => resolve-abuse-user-report.ts} (77%)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 83c7add3b2..bccb82e51b 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -586,6 +586,13 @@ setMultipleBySeparatingWithSpace: "スペースで区切って複数設定でき
 fileIdOrUrl: "ファイルIDまたはURL"
 chatOpenBehavior: "チャットを開くときの動作"
 sample: "サンプル"
+abuseReports: "通報"
+reportAbuse: "通報"
+reportAbuseOf: "{name}を通報する"
+fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。"
+abuseReported: "内容が送信されました。ご報告ありがとうございました。"
+send: "送信"
+abuseMarkAsResolved: "対応済みにする"
 
 _serverDisconnectedBehavior:
   reload: "自動でリロード"
diff --git a/migration/1603094348345-refine-abuse-user-report.ts b/migration/1603094348345-refine-abuse-user-report.ts
new file mode 100644
index 0000000000..2c5c4b39c5
--- /dev/null
+++ b/migration/1603094348345-refine-abuse-user-report.ts
@@ -0,0 +1,38 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class refineAbuseUserReport1603094348345 implements MigrationInterface {
+    name = 'refineAbuseUserReport1603094348345'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_d049123c413e68ca52abe734203"`);
+        await queryRunner.query(`DROP INDEX "IDX_d049123c413e68ca52abe73420"`);
+        await queryRunner.query(`DROP INDEX "IDX_5cd442c3b2e74fdd99dae20243"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "userId"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "targetUserId" character varying(32) NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "assigneeId" character varying(32)`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "resolved" boolean NOT NULL DEFAULT false`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "comment"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "comment" character varying(2048) NOT NULL`);
+        await queryRunner.query(`CREATE INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_2b15aaf4a0dc5be3499af7ab6a" ON "abuse_user_report" ("resolved") `);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_08b883dd5fdd6f9c4c1572b36de" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_08b883dd5fdd6f9c4c1572b36de"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`);
+        await queryRunner.query(`DROP INDEX "IDX_2b15aaf4a0dc5be3499af7ab6a"`);
+        await queryRunner.query(`DROP INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "comment"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "comment" character varying(512) NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "resolved"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "assigneeId"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "targetUserId"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "userId" character varying(32) NOT NULL`);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5cd442c3b2e74fdd99dae20243" ON "abuse_user_report" ("userId", "reporterId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_d049123c413e68ca52abe73420" ON "abuse_user_report" ("userId") `);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_d049123c413e68ca52abe734203" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+}
diff --git a/migration/1603095701770-refine-abuse-user-report2.ts b/migration/1603095701770-refine-abuse-user-report2.ts
new file mode 100644
index 0000000000..18e0c05ac2
--- /dev/null
+++ b/migration/1603095701770-refine-abuse-user-report2.ts
@@ -0,0 +1,20 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class refineAbuseUserReport21603095701770 implements MigrationInterface {
+    name = 'refineAbuseUserReport21603095701770'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "targetUserHost" character varying(128)`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "reporterHost" character varying(128)`);
+        await queryRunner.query(`CREATE INDEX "IDX_4ebbf7f93cdc10e8d1ef2fc6cd" ON "abuse_user_report" ("targetUserHost") `);
+        await queryRunner.query(`CREATE INDEX "IDX_f8d8b93740ad12c4ce8213a199" ON "abuse_user_report" ("reporterHost") `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`DROP INDEX "IDX_f8d8b93740ad12c4ce8213a199"`);
+        await queryRunner.query(`DROP INDEX "IDX_4ebbf7f93cdc10e8d1ef2fc6cd"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "reporterHost"`);
+        await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "targetUserHost"`);
+    }
+
+}
diff --git a/src/client/components/abuse-report-window.vue b/src/client/components/abuse-report-window.vue
new file mode 100644
index 0000000000..1d87cb1802
--- /dev/null
+++ b/src/client/components/abuse-report-window.vue
@@ -0,0 +1,85 @@
+<template>
+<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
+	<template #header>
+		<Fa :icon="faExclamationCircle" style="margin-right: 0.5em;"/>
+		<i18n-t keypath="reportAbuseOf" tag="span">
+			<template #name>
+				<b><MkAcct :user="user"/></b>
+			</template>
+		</i18n-t>
+	</template>
+	<div class="dpvffvvy">
+		<div class="_section">
+			<div class="_content">
+				<MkTextarea v-model:value="comment">
+					<span>{{ $t('details') }}</span>
+					<template #desc>{{ $t('fillAbuseReportDescription') }}</template>
+				</MkTextarea>
+			</div>
+		</div>
+		<div class="_section">
+			<div class="_content">
+				<MkButton @click="send" primary full :disabled="comment.length === 0">{{ $t('send') }}</MkButton>
+			</div>
+		</div>
+	</div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
+import XWindow from '@/components/ui/window.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		XWindow,
+		MkTextarea,
+		MkButton,
+	},
+
+	props: {
+		user: {
+			type: Object,
+			required: true,
+		},
+		initialComment: {
+			type: String,
+			required: false,
+		},
+	},
+
+	emits: ['closed'],
+
+	data() {
+		return {
+			comment: this.initialComment || '',
+			faExclamationCircle,
+		};
+	},
+
+	methods: {
+		send() {
+			os.apiWithDialog('users/report-abuse', {
+				userId: this.user.id,
+				comment: this.comment,
+			}, undefined, res => {
+				os.dialog({
+					type: 'success',
+					text: this.$t('abuseReported')
+				});
+				this.$refs.window.close();
+			});
+		}
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.dpvffvvy {
+	--section-padding: 16px;
+}
+</style>
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index cb364a04c9..85bdb9c6fb 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -101,7 +101,7 @@
 
 <script lang="ts">
 import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue';
-import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
+import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
 import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
 import { parse } from '../../mfm/parse';
 import { sum, unique } from '../../prelude/array';
@@ -637,6 +637,21 @@ export default defineComponent({
 					}]
 					: []
 				),
+				...(this.appearNote.userId != this.$store.state.i.id ? [
+					null,
+					{
+						icon: faExclamationCircle,
+						text: this.$t('reportAbuse'),
+						action: () => {
+							const u = `${url}/notes/${this.appearNote.id}`;
+							os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), {
+								user: this.appearNote.user,
+								initialComment: `Note: ${u}\n-----\n`
+							}, {}, 'closed');
+						}
+					}]
+					: []
+				),
 				...(this.appearNote.userId == this.$store.state.i.id || this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [
 					null,
 					this.appearNote.userId == this.$store.state.i.id ? {
diff --git a/src/client/components/page-window.vue b/src/client/components/page-window.vue
index 4a605bde69..2673b3f8ec 100644
--- a/src/client/components/page-window.vue
+++ b/src/client/components/page-window.vue
@@ -7,7 +7,7 @@
 		<button class="_button" @click="expand" v-tooltip="$t('showInPage')"><Fa :icon="faExpandAlt"/></button>
 		<button class="_button" @click="popout" v-tooltip="$t('popout')"><Fa :icon="faExternalLinkAlt"/></button>
 	</template>
-	<div style="min-height: 100%; background: var(--bg);">
+	<div class="yrolvcoq" style="min-height: 100%; background: var(--bg);">
 		<component :is="component" v-bind="props" :ref="changePage"/>
 	</div>
 </XWindow>
@@ -84,3 +84,9 @@ export default defineComponent({
 	},
 });
 </script>
+
+<style lang="scss" scoped>
+.yrolvcoq {
+	--section-padding: 16px;
+}
+</style>
diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue
index 7548b136ea..383378241b 100644
--- a/src/client/components/sidebar.vue
+++ b/src/client/components/sidebar.vue
@@ -46,7 +46,7 @@
 
 <script lang="ts">
 import { defineComponent } from 'vue';
-import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream } from '@fortawesome/free-solid-svg-icons';
+import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
 import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
 import { host, instanceName } from '@/config';
 import { search } from '@/scripts/search';
@@ -217,6 +217,11 @@ export default defineComponent({
 				text: this.$t('announcements'),
 				to: '/instance/announcements',
 				icon: faBroadcastTower,
+			}, {
+				type: 'link',
+				text: this.$t('abuseReports'),
+				to: '/instance/abuses',
+				icon: faExclamationCircle,
 			}, {
 				type: 'link',
 				text: this.$t('logs'),
diff --git a/src/client/components/ui/window.vue b/src/client/components/ui/window.vue
index 4a51a4d8e3..781c6ef151 100644
--- a/src/client/components/ui/window.vue
+++ b/src/client/components/ui/window.vue
@@ -7,7 +7,9 @@
 				<span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
 					<slot name="header"></slot>
 				</span>
-				<slot name="buttons"></slot>
+				<slot name="buttons">
+					<button class="_button" style="pointer-events: none;"></button>
+				</slot>
 			</div>
 			<div class="body" v-if="padding">
 				<div class="_section">
@@ -371,8 +373,6 @@ export default defineComponent({
 		width: 100%;
     height: 100%;
 
-		--section-padding: 16px;
-
 		> .header {
 			$height: 50px;
 			display: flex;
@@ -380,7 +380,6 @@ export default defineComponent({
 			z-index: 1;
 			flex-shrink: 0;
 			box-shadow: 0px 1px var(--divider);
-			cursor: move;
 			user-select: none;
 			height: $height;
 
@@ -400,6 +399,8 @@ export default defineComponent({
 				white-space: nowrap;
 				overflow: hidden;
 				text-overflow: ellipsis;
+				text-align: center;
+				cursor: move;
 			}
 		}
 
diff --git a/src/client/os.ts b/src/client/os.ts
index 6adfc28956..3241f82e5d 100644
--- a/src/client/os.ts
+++ b/src/client/os.ts
@@ -4,6 +4,7 @@ import Stream from '@/scripts/stream';
 import { store } from '@/store';
 import { apiUrl } from '@/config';
 import MkPostFormDialog from '@/components/post-form-dialog.vue';
+import MkWaitingDialog from '@/components/waiting-dialog.vue';
 
 const ua = navigator.userAgent.toLowerCase();
 export const isMobile = /mobile|iphone|ipad|android/.test(ua);
@@ -73,7 +74,7 @@ export function apiWithDialog(
 	promiseDialog(promise, onSuccess, onFailure ? onFailure : (e) => {
 		dialog({
 			type: 'error',
-			text: e.message + '\n' + (e as any).id,
+			text: e.message + '<br>' + (e as any).id,
 		});
 	});
 
@@ -111,7 +112,8 @@ export function promiseDialog<T extends Promise<any>>(
 		}
 	});
 
-	popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), {
+	// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
+	popup(MkWaitingDialog, {
 		success: success,
 		showing: showing,
 		text: text,
diff --git a/src/client/pages/instance/abuses.vue b/src/client/pages/instance/abuses.vue
new file mode 100644
index 0000000000..90a2656e10
--- /dev/null
+++ b/src/client/pages/instance/abuses.vue
@@ -0,0 +1,163 @@
+<template>
+<div class="">
+	<div class="_section reports">
+		<div class="_content">
+			<div class="inputs" style="display: flex;">
+				<MkSelect v-model:value="state" style="margin: 0; flex: 1;">
+					<template #label>{{ $t('state') }}</template>
+					<option value="all">{{ $t('all') }}</option>
+					<option value="unresolved">{{ $t('unresolved') }}</option>
+					<option value="resolved">{{ $t('resolved') }}</option>
+				</MkSelect>
+				<MkSelect v-model:value="targetUserOrigin" style="margin: 0; flex: 1;">
+					<template #label>{{ $t('targetUserOrigin') }}</template>
+					<option value="combined">{{ $t('all') }}</option>
+					<option value="local">{{ $t('local') }}</option>
+					<option value="remote">{{ $t('remote') }}</option>
+				</MkSelect>
+				<MkSelect v-model:value="reporterOrigin" style="margin: 0; flex: 1;">
+					<template #label>{{ $t('reporterOrigin') }}</template>
+					<option value="combined">{{ $t('all') }}</option>
+					<option value="local">{{ $t('local') }}</option>
+					<option value="remote">{{ $t('remote') }}</option>
+				</MkSelect>
+			</div>
+			<!-- TODO
+			<div class="inputs" style="display: flex; padding-top: 1.2em;">
+				<MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.reports.reload()">
+					<span>{{ $t('username') }}</span>
+				</MkInput>
+				<MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'">
+					<span>{{ $t('host') }}</span>
+				</MkInput>
+			</div>
+			-->
+
+			<MkPagination :pagination="pagination" #default="{items}" ref="reports" :auto-margin="false" style="margin-top: var(--margin);">
+				<div class="bcekxzvu _card _vMargin" v-for="report in items" :key="report.id">
+					<div class="_content target">
+						<MkAvatar class="avatar" :user="report.targetUser"/>
+						<div class="info">
+							<MkUserName class="name" :user="report.targetUser"/>
+							<div class="acct">@{{ acct(report.targetUser) }}</div>
+						</div>
+					</div>
+					<div class="_content">
+						<div>
+							<Mfm :text="report.comment"/>
+						</div>
+						<hr>
+						<div>Reporter: <MkAcct :user="report.reporter"/></div>
+						<div><MkTime :time="report.createdAt"/></div>
+					</div>
+					<div class="_footer">
+						<div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div>
+						<MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $t('abuseMarkAsResolved') }}</MkButton>
+					</div>
+				</div>
+			</MkPagination>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faPlus, faUsers, faSearch, faBookmark, faMicrophoneSlash, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
+import { faSnowflake, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons';
+import parseAcct from '../../../misc/acct/parse';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/ui/input.vue';
+import MkSelect from '@/components/ui/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '../../filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkInput,
+		MkSelect,
+		MkPagination,
+	},
+
+	data() {
+		return {
+			INFO: {
+				header: [{
+					title: this.$t('abuseReports'),
+					icon: faExclamationCircle
+				}],
+			},
+			searchUsername: '',
+			searchHost: '',
+			state: 'unresolved',
+			reporterOrigin: 'combined',
+			targetUserOrigin: 'combined',
+			pagination: {
+				endpoint: 'admin/abuse-user-reports',
+				limit: 10,
+				params: () => ({
+					state: this.state,
+					reporterOrigin: this.reporterOrigin,
+					targetUserOrigin: this.targetUserOrigin,
+				}),
+			},
+			faPlus, faUsers, faSearch, faBookmark, farBookmark, faMicrophoneSlash, faSnowflake
+		}
+	},
+
+	watch: {
+		state() {
+			this.$refs.reports.reload();
+		},
+
+		reporterOrigin() {
+			this.$refs.reports.reload();
+		},
+
+		targetUserOrigin() {
+			this.$refs.reports.reload();
+		},
+	},
+
+	methods: {
+		acct,
+
+		resolve(report) {
+			os.apiWithDialog('admin/resolve-abuse-user-report', {
+				reportId: report.id,
+			}).then(() => {
+				this.$refs.reports.removeItem(item => item.id === report.id);
+			});
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.bcekxzvu {
+	> .target {
+		display: flex;
+		width: 100%;
+		box-sizing: border-box;
+		text-align: left;
+		align-items: center;
+					
+		> .avatar {
+			width: 42px;
+			height: 42px;
+		}
+
+		> .info {
+			margin-left: 0.3em;
+			padding: 0 8px;
+			flex: 1;
+
+			> .name {
+				font-weight: bold;
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/router.ts b/src/client/router.ts
index fc67f6ecfd..c9c7a32835 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -86,6 +86,7 @@ export const router = createRouter({
 		{ path: '/instance/federation', component: page('instance/federation') },
 		{ path: '/instance/relays', component: page('instance/relays') },
 		{ path: '/instance/announcements', component: page('instance/announcements') },
+		{ path: '/instance/abuses', component: page('instance/abuses') },
 		{ path: '/notes/:note', name: 'note', component: page('note') },
 		{ path: '/tags/:tag', component: page('tag') },
 		{ path: '/auth/:token', component: page('auth') },
diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts
index 63c3ae43b6..cace2e1425 100644
--- a/src/client/scripts/get-user-menu.ts
+++ b/src/client/scripts/get-user-menu.ts
@@ -1,4 +1,4 @@
-import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons';
+import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
 import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons';
 import { i18n } from '@/i18n';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
@@ -102,6 +102,12 @@ export function getUserMenu(user) {
 		});
 	}
 
+	async function reportAbuse() {
+		os.popup(await import('@/components/abuse-report-window.vue'), {
+			user: user,
+		}, {}, 'closed');
+	}
+
 	async function getConfirmed(text: string): Promise<boolean> {
 		const confirm = await os.dialog({
 			type: 'warning',
@@ -157,6 +163,12 @@ export function getUserMenu(user) {
 			action: toggleBlock
 		}]);
 
+		menu = menu.concat([null, {
+			icon: faExclamationCircle,
+			text: i18n.global.t('reportAbuse'),
+			action: reportAbuse
+		}]);
+
 		if (store.getters.isSignedIn && (store.state.i.isAdmin || store.state.i.isModerator)) {
 			menu = menu.concat([null, {
 				icon: faMicrophoneSlash,
diff --git a/src/models/entities/abuse-user-report.ts b/src/models/entities/abuse-user-report.ts
index 43ab56023a..c0cff139f6 100644
--- a/src/models/entities/abuse-user-report.ts
+++ b/src/models/entities/abuse-user-report.ts
@@ -3,7 +3,6 @@ import { User } from './user';
 import { id } from '../id';
 
 @Entity()
-@Index(['userId', 'reporterId'], { unique: true })
 export class AbuseUserReport {
 	@PrimaryColumn(id())
 	public id: string;
@@ -16,13 +15,13 @@ export class AbuseUserReport {
 
 	@Index()
 	@Column(id())
-	public userId: User['id'];
+	public targetUserId: User['id'];
 
 	@ManyToOne(type => User, {
 		onDelete: 'CASCADE'
 	})
 	@JoinColumn()
-	public user: User | null;
+	public targetUser: User | null;
 
 	@Index()
 	@Column(id())
@@ -34,8 +33,42 @@ export class AbuseUserReport {
 	@JoinColumn()
 	public reporter: User | null;
 
+	@Column({
+		...id(),
+		nullable: true
+	})
+	public assigneeId: User['id'] | null;
+
+	@ManyToOne(type => User, {
+		onDelete: 'SET NULL'
+	})
+	@JoinColumn()
+	public assignee: User | null;
+
+	@Index()
+	@Column('boolean', {
+		default: false
+	})
+	public resolved: boolean;
+
 	@Column('varchar', {
-		length: 512,
+		length: 2048,
 	})
 	public comment: string;
+
+	//#region Denormalized fields
+	@Index()
+	@Column('varchar', {
+		length: 128, nullable: true,
+		comment: '[Denormalized]'
+	})
+	public targetUserHost: string | null;
+
+	@Index()
+	@Column('varchar', {
+		length: 128, nullable: true,
+		comment: '[Denormalized]'
+	})
+	public reporterHost: string | null;
+	//#endregion
 }
diff --git a/src/models/repositories/abuse-user-report.ts b/src/models/repositories/abuse-user-report.ts
index bff64c770c..dbdaa5ee15 100644
--- a/src/models/repositories/abuse-user-report.ts
+++ b/src/models/repositories/abuse-user-report.ts
@@ -15,14 +15,19 @@ export class AbuseUserReportRepository extends Repository<AbuseUserReport> {
 			id: report.id,
 			createdAt: report.createdAt,
 			comment: report.comment,
+			resolved: report.resolved,
 			reporterId: report.reporterId,
-			userId: report.userId,
+			targetUserId: report.targetUserId,
+			assigneeId: report.assigneeId,
 			reporter: Users.pack(report.reporter || report.reporterId, null, {
 				detail: true
 			}),
-			user: Users.pack(report.user || report.userId, null, {
+			targetUser: Users.pack(report.targetUser || report.targetUserId, null, {
 				detail: true
 			}),
+			assignee: report.assigneeId ? Users.pack(report.assignee || report.assigneeId, null, {
+				detail: true
+			}) : null,
 		});
 	}
 
diff --git a/src/remote/activitypub/kernel/flag/index.ts b/src/remote/activitypub/kernel/flag/index.ts
index 9b3065b112..46ea789b4b 100644
--- a/src/remote/activitypub/kernel/flag/index.ts
+++ b/src/remote/activitypub/kernel/flag/index.ts
@@ -19,8 +19,10 @@ export default async (actor: IRemoteUser, activity: IFlag): Promise<string> => {
 	await AbuseUserReports.insert({
 		id: genId(),
 		createdAt: new Date(),
-		userId: users[0].id,
+		targetUserId: users[0].id,
+		targetUserHost: users[0].host,
 		reporterId: actor.id,
+		reporterHost: actor.host,
 		comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`
 	});
 
diff --git a/src/server/api/endpoints/admin/abuse-user-reports.ts b/src/server/api/endpoints/admin/abuse-user-reports.ts
index d5a52184d1..6a7f380e16 100644
--- a/src/server/api/endpoints/admin/abuse-user-reports.ts
+++ b/src/server/api/endpoints/admin/abuse-user-reports.ts
@@ -23,12 +23,50 @@ export const meta = {
 		untilId: {
 			validator: $.optional.type(ID),
 		},
+
+		state: {
+			validator: $.optional.nullable.str,
+			default: null,
+		},
+
+		reporterOrigin: {
+			validator: $.optional.str.or([
+				'combined',
+				'local',
+				'remote',
+			]),
+			default: 'combined'
+		},
+
+		targetUserOrigin: {
+			validator: $.optional.str.or([
+				'combined',
+				'local',
+				'remote',
+			]),
+			default: 'combined'
+		},
 	}
 };
 
 export default define(meta, async (ps) => {
 	const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId);
 
+	switch (ps.state) {
+		case 'resolved': query.andWhere('report.resolved = TRUE'); break;
+		case 'unresolved': query.andWhere('report.resolved = FALSE'); break;
+	}
+
+	switch (ps.reporterOrigin) {
+		case 'local': query.andWhere('report.reporterHost IS NULL'); break;
+		case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break;
+	}
+
+	switch (ps.targetUserOrigin) {
+		case 'local': query.andWhere('report.targetUserHost IS NULL'); break;
+		case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break;
+	}
+
 	const reports = await query.take(ps.limit!).getMany();
 
 	return await AbuseUserReports.packMany(reports);
diff --git a/src/server/api/endpoints/admin/remove-abuse-user-report.ts b/src/server/api/endpoints/admin/resolve-abuse-user-report.ts
similarity index 77%
rename from src/server/api/endpoints/admin/remove-abuse-user-report.ts
rename to src/server/api/endpoints/admin/resolve-abuse-user-report.ts
index 150de5f5d4..0a62b5f365 100644
--- a/src/server/api/endpoints/admin/remove-abuse-user-report.ts
+++ b/src/server/api/endpoints/admin/resolve-abuse-user-report.ts
@@ -16,12 +16,15 @@ export const meta = {
 	}
 };
 
-export default define(meta, async (ps) => {
+export default define(meta, async (ps, me) => {
 	const report = await AbuseUserReports.findOne(ps.reportId);
 
 	if (report == null) {
 		throw new Error('report not found');
 	}
 
-	await AbuseUserReports.delete(report.id);
+	await AbuseUserReports.update(report.id, {
+		resolved: true,
+		assigneeId: me.id,
+	});
 });
diff --git a/src/server/api/endpoints/users/report-abuse.ts b/src/server/api/endpoints/users/report-abuse.ts
index a9b5543f3c..eaa4cd6258 100644
--- a/src/server/api/endpoints/users/report-abuse.ts
+++ b/src/server/api/endpoints/users/report-abuse.ts
@@ -26,7 +26,7 @@ export const meta = {
 		},
 
 		comment: {
-			validator: $.str.range(1, 3000),
+			validator: $.str.range(1, 2048),
 			desc: {
 				'ja-JP': '迷惑行為の詳細'
 			}
@@ -72,9 +72,11 @@ export default define(meta, async (ps, me) => {
 	const report = await AbuseUserReports.save({
 		id: genId(),
 		createdAt: new Date(),
-		userId: user.id,
+		targetUserId: user.id,
+		targetUserHost: user.host,
 		reporterId: me.id,
-		comment: ps.comment
+		reporterHost: null,
+		comment: ps.comment,
 	});
 
 	// Publish event to moderators
@@ -90,7 +92,7 @@ export default define(meta, async (ps, me) => {
 		for (const moderator of moderators) {
 			publishAdminStream(moderator.id, 'newAbuseUserReport', {
 				id: report.id,
-				userId: report.userId,
+				targetUserId: report.targetUserId,
 				reporterId: report.reporterId,
 				comment: report.comment
 			});