From 1b21899ffd9fa4899790860519175f9fe02adda3 Mon Sep 17 00:00:00 2001 From: Norm Date: Thu, 22 Sep 2022 19:52:32 +0000 Subject: [PATCH] Merge pull request 'mute notifications in muted threads' (#119) from mute-notifications into main Reviewed-on: https://akkoma.dev/FoundKeyGang/FoundKey/pulls/119 --- locales/de-DE.yml | 1 + locales/en-US.yml | 1 + locales/ja-JP.yml | 1 + ...1655793461890-thread-mute-notifications.js | 13 ++ .../src/models/entities/note-thread-muting.ts | 21 ++- .../src/models/repositories/notification.ts | 152 ++++-------------- .../server/api/endpoints/notes/polls/vote.ts | 52 +++--- .../endpoints/notes/thread-muting/create.ts | 36 ++++- packages/backend/src/services/note/create.ts | 17 +- .../backend/src/services/note/polls/vote.ts | 19 ++- .../src/services/note/reaction/create.ts | 11 +- .../MkNotificationSettingWindow.vue | 18 ++- packages/client/src/scripts/get-note-menu.ts | 103 ++++++++---- 13 files changed, 229 insertions(+), 216 deletions(-) create mode 100644 packages/backend/migration/1655793461890-thread-mute-notifications.js diff --git a/locales/de-DE.yml b/locales/de-DE.yml index a51bc0c486..844058a206 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -817,6 +817,7 @@ makeReactionsPublicDescription: "Jeder wird die Liste deiner gesendeten Reaktion classic: "Classic" muteThread: "Thread stummschalten" unmuteThread: "Threadstummschaltung aufheben" +threadMuteNotificationsDesc: "Wähle die Benachrichtigungen, die du aus diesem Thread erhalten möchtest. Globale Benachrichtigungs-Einstellungen werden zusätzlich angewandt. Das Deaktivieren einer Benachrichtigung hat Vorrang." ffVisibility: "Sichtbarkeit von Gefolgten/Followern" ffVisibilityDescription: "Konfiguriere wer sehen kann, wem du folgst sowie wer dir folgt." continueThread: "Weiteren Threadverlauf anzeigen" diff --git a/locales/en-US.yml b/locales/en-US.yml index 47cbb783c1..2a4466278f 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -833,6 +833,7 @@ makeReactionsPublicDescription: "This will make the list of all your past reacti classic: "Classic" muteThread: "Mute thread" unmuteThread: "Unmute thread" +threadMuteNotificationsDesc: "Select the notifications you wish to view from this thread. Global notification settings also apply. Disabling takes precedence." ffVisibility: "Follows/Followers Visibility" ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you." continueThread: "View thread continuation" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fe672c16ef..c20708f991 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -833,6 +833,7 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を classic: "クラシック" muteThread: "スレッドをミュート" unmuteThread: "スレッドのミュートを解除" +threadMuteNotificationsDesc: "このスレッドから表示する通知を選択します。グローバル通知設定も適用され、禁止が優先されます。" ffVisibility: "つながりの公開範囲" ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。" continueThread: "さらにスレッドを見る" diff --git a/packages/backend/migration/1655793461890-thread-mute-notifications.js b/packages/backend/migration/1655793461890-thread-mute-notifications.js new file mode 100644 index 0000000000..89f340d5fa --- /dev/null +++ b/packages/backend/migration/1655793461890-thread-mute-notifications.js @@ -0,0 +1,13 @@ +export class threadMuteNotifications1655793461890 { + name = 'threadMuteNotifications1655793461890' + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."note_thread_muting_mutingnotificationtypes_enum" AS ENUM('mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded')`); + await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD "mutingNotificationTypes" "public"."note_thread_muting_mutingnotificationtypes_enum" array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP COLUMN "mutingNotificationTypes"`); + await queryRunner.query(`DROP TYPE "public"."note_thread_muting_mutingnotificationtypes_enum"`); + } +} diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/note-thread-muting.ts index 2985b195f0..9a1d4d6da6 100644 --- a/packages/backend/src/models/entities/note-thread-muting.ts +++ b/packages/backend/src/models/entities/note-thread-muting.ts @@ -1,14 +1,7 @@ -import { - PrimaryColumn, - Entity, - Index, - JoinColumn, - Column, - ManyToOne, -} from "typeorm"; -import { User } from "./user.js"; -import { Note } from "./note.js"; -import { id } from "../id.js"; +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './user.js'; +import { Note } from './note.js'; @Entity() @Index(['userId', 'threadId'], { unique: true }) @@ -37,4 +30,10 @@ export class NoteThreadMuting { length: 256, }) public threadId: string; + + @Column('enum', { + array: true, + default: [], + }) + public mutingNotificationTypes: any; } diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index 1538e67d86..357e060e66 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -1,20 +1,14 @@ -import { In, Repository } from "typeorm"; -import { Notification } from "@/models/entities/notification.js"; -import { awaitAll } from "@/prelude/await-all.js"; -import type { Packed } from "@/misc/schema.js"; -import type { Note } from "@/models/entities/note.js"; -import type { NoteReaction } from "@/models/entities/note-reaction.js"; -import type { User } from "@/models/entities/user.js"; -import { aggregateNoteEmojis, prefetchEmojis } from "@/misc/populate-emojis.js"; -import { notificationTypes } from "@/types.js"; -import { db } from "@/db/postgre.js"; -import { - Users, - Notes, - UserGroupInvitations, - AccessTokens, - NoteReactions, -} from "../index.js"; +import { In } from 'typeorm'; +import { noteNotificationTypes } from 'foundkey-js'; +import { db } from '@/db/postgre.js'; +import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis.js'; +import { Packed } from '@/misc/schema.js'; +import { Note } from '@/models/entities/note.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { Notification } from '@/models/entities/notification.js'; +import { User } from '@/models/entities/user.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index.js'; export const NotificationRepository = db.getRepository(Notification).extend({ async pack( @@ -39,109 +33,27 @@ export const NotificationRepository = db.getRepository(Notification).extend({ type: notification.type, isRead: notification.isRead, userId: notification.notifierId, - user: notification.notifierId - ? Users.pack(notification.notifier || notification.notifierId) - : null, - ...(notification.type === "mention" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - } - : {}), - ...(notification.type === "reply" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - } - : {}), - ...(notification.type === "renote" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - } - : {}), - ...(notification.type === "quote" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - } - : {}), - ...(notification.type === "reaction" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - reaction: notification.reaction, - } - : {}), - ...(notification.type === "pollVote" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - choice: notification.choice, - } - : {}), - ...(notification.type === "pollEnded" - ? { - note: Notes.pack( - notification.note || notification.noteId!, - { id: notification.notifieeId }, - { - detail: true, - _hint_: options._hintForEachNotes_, - }, - ), - } - : {}), - ...(notification.type === "groupInvited" - ? { - invitation: UserGroupInvitations.pack( - notification.userGroupInvitationId!, - ), - } - : {}), - ...(notification.type === "app" - ? { - body: notification.customBody, - header: notification.customHeader || token?.name, - icon: notification.customIcon || token?.iconUrl, - } - : {}), + user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null, + ...(noteNotificationTypes.includes(notification.type) ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reaction' ? { + reaction: notification.reaction, + } : {}), + ...(notification.type === 'pollVote' ? { + choice: notification.choice, + } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader || token?.name, + icon: notification.customIcon || token?.iconUrl, + } : {}), }); }, diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 0558aa1b8f..7f3fd231de 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -1,22 +1,16 @@ -import { Not } from "typeorm"; -import { publishNoteStream } from "@/services/stream.js"; -import { createNotification } from "@/services/create-notification.js"; -import { deliver } from "@/queue/index.js"; -import { renderActivity } from "@/remote/activitypub/renderer/index.js"; -import renderVote from "@/remote/activitypub/renderer/vote.js"; -import { deliverQuestionUpdate } from "@/services/note/polls/update.js"; -import { - PollVotes, - NoteWatchings, - Users, - Polls, - Blockings, -} from "@/models/index.js"; -import type { IRemoteUser } from "@/models/entities/user.js"; -import { genId } from "@/misc/gen-id.js"; -import { getNote } from "../../../common/getters.js"; -import { ApiError } from "../../../error.js"; -import define from "../../../define.js"; +import { ArrayOverlap, Not } from 'typeorm'; +import { publishNoteStream } from '@/services/stream.js'; +import { createNotification } from '@/services/create-notification.js'; +import { deliver } from '@/queue/index.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderVote from '@/remote/activitypub/renderer/vote.js'; +import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; +import { PollVotes, NoteWatchings, Users, Polls, Blockings, NoteThreadMutings } from '@/models/index.js'; +import { IRemoteUser } from '@/models/entities/user.js'; +import { genId } from '@/misc/gen-id.js'; +import { getNote } from '../../../common/getters.js'; +import { ApiError } from '../../../error.js'; +import define from '../../../define.js'; export const meta = { tags: ["notes"], @@ -145,14 +139,24 @@ export default define(meta, paramDef, async (ps, user) => { userId: user.id, }); - // Notify - createNotification(note.userId, "pollVote", { - notifierId: user.id, - noteId: note.id, - choice: ps.choice, + // check if this thread and notification type is muted + const threadMuted = await NoteThreadMutings.findOne({ + userId: note.userId, + threadId: note.threadId || note.id, + mutingNotificationTypes: ArrayOverlap(['pollVote']), }); + // Notify + if (!threadMuted) { + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice, + }); + } // Fetch watchers + // checking for mutes is not necessary here, as note watchings will be + // deleted when a thread is muted NoteWatchings.findBy({ noteId: note.id, userId: Not(user.id), diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index e4803cc291..32ff2fcc6b 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -1,9 +1,9 @@ -import { Notes, NoteThreadMutings } from "@/models/index.js"; -import { genId } from "@/misc/gen-id.js"; -import readNote from "@/services/note/read.js"; -import define from "../../../define.js"; -import { getNote } from "../../../common/getters.js"; -import { ApiError } from "../../../error.js"; +import { Notes, NoteThreadMutings, NoteWatchings } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import readNote from '@/services/note/read.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; +import { ApiError } from '../../../error.js'; export const meta = { tags: ["notes"], @@ -24,7 +24,15 @@ export const meta = { export const paramDef = { type: "object", properties: { - noteId: { type: "string", format: "misskey:id" }, + noteId: { type: 'string', format: 'misskey:id' }, + mutingNotificationTypes: { + description: 'Defines which notification types from the thread should be muted. Replies are always muted. Applies in addition to the global settings, muting takes precedence.', + type: 'array', + items: { + type: 'string', + }, + uniqueItems: true, + }, }, required: ["noteId"], } as const; @@ -54,5 +62,19 @@ export default define(meta, paramDef, async (ps, user) => { createdAt: new Date(), threadId: note.threadId || note.id, userId: user.id, + mutingNotificationTypes: ps.mutingNotificationTypes, }); + + // remove all note watchings in the muted thread + const notesThread = Notes.createQueryBuilder("notes") + .select("note.id") + .where({ + threadId: note.threadId ?? note.id, + }); + + await NoteWatchings.createQueryBuilder() + .delete() + .where(`"note_watching"."noteId" IN (${ notesThread.getQuery() })`) + .setParameters(notesThread.getParameters()) + .execute(); }); diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 968aed880f..37caabf6ae 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -1,3 +1,4 @@ +import { ArrayOverlap, Not, In } from 'typeorm'; import * as mfm from "mfm-js"; import es from "../../db/elasticsearch.js"; import { @@ -90,7 +91,7 @@ class NotificationManager { // 自分自身へは通知しない if (this.notifier.id === notifiee) return; - const exist = this.queue.find((x) => x.target === notifiee); + const exist = this.queue.find(async (x) => x.target === notifiee); if (exist) { // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする @@ -107,15 +108,19 @@ class NotificationManager { public async deliver() { for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await Mutings.findBy({ + // check if the sender or thread are muted + const userMuted = await Mutings.findOneBy({ muterId: x.target, + muteeId: this.notifier.id, }); - const mentioneesMutedUserIds = mentioneeMutes.map((m) => m.muteeId); + const threadMuted = await NoteThreadMutings.findOneBy({ + userId: x.target, + threadId: this.note.threadId || this.note.id, + mutingNotificationTypes: ArrayOverlap([x.reason]), + }); - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + if (!(userMuted || threadMuted)) { createNotification(x.target, x.reason, { notifierId: this.notifier.id, noteId: this.note.id, diff --git a/packages/backend/src/services/note/polls/vote.ts b/packages/backend/src/services/note/polls/vote.ts index 582af0b17b..02b3317363 100644 --- a/packages/backend/src/services/note/polls/vote.ts +++ b/packages/backend/src/services/note/polls/vote.ts @@ -1,3 +1,4 @@ +import { ArrayOverlap, Not } from 'typeorm'; import { publishNoteStream } from "@/services/stream.js"; import type { CacheableUser } from "@/models/entities/user.js"; import { User } from "@/models/entities/user.js"; @@ -64,12 +65,20 @@ export default async function ( userId: user.id, }); - // Notify - createNotification(note.userId, "pollVote", { - notifierId: user.id, - noteId: note.id, - choice: choice, + // check if this thread and notification type is muted + const muted = await NoteThreadMutings.findOne({ + userId: note.userId, + threadId: note.threadId || note.id, + mutingNotificationTypes: ArrayOverlap(['pollVote']), }); + // Notify + if (!muted) { + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } // Fetch watchers NoteWatchings.findBy({ diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts index 1a3c52eb51..249ee2bd6d 100644 --- a/packages/backend/src/services/note/reaction/create.ts +++ b/packages/backend/src/services/note/reaction/create.ts @@ -1,3 +1,4 @@ +import { ArrayOverlap, IsNull, Not } from 'typeorm'; import { publishNoteStream } from "@/services/stream.js"; import { renderLike } from "@/remote/activitypub/renderer/like.js"; import DeliverManager from "@/remote/activitypub/deliver-manager.js"; @@ -118,9 +119,15 @@ export default async ( userId: user.id, }); + // check if this thread is muted + const threadMuted = await NoteThreadMutings.findOne({ + userId: note.userId, + threadId: note.threadId || note.id, + mutingNotificationTypes: ArrayOverlap(['reaction']), + }); // リアクションされたユーザーがローカルユーザーなら通知を作成 - if (note.userHost === null) { - createNotification(note.userId, "reaction", { + if (note.userHost === null && !threadMuted) { + createNotification(note.userId, 'reaction', { notifierId: user.id, note: note, noteId: note.id, diff --git a/packages/client/src/components/MkNotificationSettingWindow.vue b/packages/client/src/components/MkNotificationSettingWindow.vue index f2a9aed990..5a4e93260b 100644 --- a/packages/client/src/components/MkNotificationSettingWindow.vue +++ b/packages/client/src/components/MkNotificationSettingWindow.vue @@ -18,7 +18,7 @@
- {{ i18n.ts.notificationSettingDesc }} + {{ message }} {{ i18n.ts.disableAll }} {{ i18n.ts.enableAll }} {{ i18n.t(`_notification._types.${ntype}`) }} @@ -42,21 +42,25 @@ const emit = defineEmits<{ }>(); const props = withDefaults(defineProps<{ - includingTypes?: typeof notificationTypes[number][] | null; + includingTypes?: typeof foundkey.notificationTypes[number][] | null; + notificationTypes?: typeof foundkey.notificationTypes[number][] | null; showGlobalToggle?: boolean; + message?: string, }>(), { includingTypes: () => [], + notificationTypes: () => [], showGlobalToggle: true, + message: i18n.ts.notificationSettingDesc, }); let includingTypes = $computed(() => props.includingTypes || []); const dialog = $ref>(); -let typesMap = $ref>({}); +let typesMap = $ref>({}); let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); -for (const ntype of notificationTypes) { +for (const ntype of props.notificationTypes) { typesMap[ntype] = includingTypes.includes(ntype); } @@ -65,7 +69,7 @@ function ok() { emit('done', { includingTypes: null }); } else { emit('done', { - includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) + includingTypes: (Object.keys(typesMap) as typeof foundkey.notificationTypes[number][]) .filter(type => typesMap[type]), }); } @@ -75,13 +79,13 @@ function ok() { function disableAll() { for (const type in typesMap) { - typesMap[type as typeof notificationTypes[number]] = false; + typesMap[type as typeof foundkey.notificationTypes[number]] = false; } } function enableAll() { for (const type in typesMap) { - typesMap[type as typeof notificationTypes[number]] = true; + typesMap[type as typeof foundkey.notificationTypes[number]] = true; } } diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index 0c8d517e52..63a23e0b74 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -79,13 +79,34 @@ export function getNoteMenu(props: { ); } - function toggleThreadMute(mute: boolean): void { - os.apiWithDialog( - mute ? "notes/thread-muting/create" : "notes/thread-muting/delete", - { - noteId: appearNote.id, - }, - ); + function muteThread(): void { + // show global settings by default + const includingTypes = foundkey.notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)); + os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), { + includingTypes, + showGlobalToggle: false, + message: i18n.ts.threadMuteNotificationsDesc, + notificationTypes: foundkey.noteNotificationTypes, + }, { + done: async (res) => { + const { includingTypes: value } = res; + let mutingNotificationTypes: string[] | undefined; + if (value != null) { + mutingNotificationTypes = foundkey.noteNotificationTypes.filter(x => !value.includes(x)) + } + + await os.apiWithDialog('notes/thread-muting/create', { + noteId: appearNote.id, + mutingNotificationTypes, + }); + } + }, 'closed'); + } + + function unmuteThread(): void { + os.apiWithDialog('notes/thread-muting/delete', { + noteId: appearNote.id + }); } function copyContent(): void { @@ -322,33 +343,47 @@ export function getNoteMenu(props: { text: i18n.ts.clip, action: () => clip(), }, - appearNote.userId !== $i.id - ? statePromise.then((state) => - state.isWatching - ? { - icon: "ph-eye-slash ph-bold ph-lg", - text: i18n.ts.unwatch, - action: () => toggleWatch(false), - } - : { - icon: "ph-eye ph-bold ph-lg", - text: i18n.ts.watch, - action: () => toggleWatch(true), - }, - ) - : undefined, - statePromise.then((state) => - state.isMutedThread - ? { - icon: "ph-speaker-x ph-bold ph-lg", - text: i18n.ts.unmuteThread, - action: () => toggleThreadMute(false), - } - : { - icon: "ph-speaker-x ph-bold ph-lg", - text: i18n.ts.muteThread, - action: () => toggleThreadMute(true), - }, + (appearNote.userId !== $i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'ph-eye-slash ph-bold ph-lg', + text: i18n.ts.unwatch, + action: () => toggleWatch(false), + } : { + icon: 'ph-eye ph-bold ph-lg', + text: i18n.ts.watch, + action: () => toggleWatch(true), + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'ph-speaker-high ph-bold ph-lg', + text: i18n.ts.unmuteThread, + action: () => unmuteThread(), + } : { + icon: 'ph-speaker-x ph-bold ph-lg', + text: i18n.ts.muteThread, + action: () => muteThread(), + }), + appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + icon: 'ph-push-pin ph-bold ph-lg', + text: i18n.ts.unpin, + action: () => togglePin(false), + } : { + icon: 'ph-push-pin ph-bold ph-lg', + text: i18n.ts.pin, + action: () => togglePin(true), + } : undefined, + ...(appearNote.userId !== $i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: i18n.ts.reportAbuse, + action: () => { + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), { + user: appearNote.user, + urls: [u], + }, {}, 'closed'); + }, + }] + : [] ), appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id)