Merge pull request 'mute notifications in muted threads' (#119) from mute-notifications into main

Reviewed-on: https://akkoma.dev/FoundKeyGang/FoundKey/pulls/119
This commit is contained in:
Norm 2022-09-22 19:52:32 +00:00 committed by ThatOneCalculator
parent 964bc9708a
commit 1b21899ffd
No known key found for this signature in database
GPG Key ID: 8703CACD01000000
13 changed files with 229 additions and 216 deletions

View File

@ -817,6 +817,7 @@ makeReactionsPublicDescription: "Jeder wird die Liste deiner gesendeten Reaktion
classic: "Classic" classic: "Classic"
muteThread: "Thread stummschalten" muteThread: "Thread stummschalten"
unmuteThread: "Threadstummschaltung aufheben" 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" ffVisibility: "Sichtbarkeit von Gefolgten/Followern"
ffVisibilityDescription: "Konfiguriere wer sehen kann, wem du folgst sowie wer dir folgt." ffVisibilityDescription: "Konfiguriere wer sehen kann, wem du folgst sowie wer dir folgt."
continueThread: "Weiteren Threadverlauf anzeigen" continueThread: "Weiteren Threadverlauf anzeigen"

View File

@ -833,6 +833,7 @@ makeReactionsPublicDescription: "This will make the list of all your past reacti
classic: "Classic" classic: "Classic"
muteThread: "Mute thread" muteThread: "Mute thread"
unmuteThread: "Unmute 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" ffVisibility: "Follows/Followers Visibility"
ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you." ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you."
continueThread: "View thread continuation" continueThread: "View thread continuation"

View File

@ -833,6 +833,7 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を
classic: "クラシック" classic: "クラシック"
muteThread: "スレッドをミュート" muteThread: "スレッドをミュート"
unmuteThread: "スレッドのミュートを解除" unmuteThread: "スレッドのミュートを解除"
threadMuteNotificationsDesc: "このスレッドから表示する通知を選択します。グローバル通知設定も適用され、禁止が優先されます。"
ffVisibility: "つながりの公開範囲" ffVisibility: "つながりの公開範囲"
ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。" ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。"
continueThread: "さらにスレッドを見る" continueThread: "さらにスレッドを見る"

View File

@ -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"`);
}
}

View File

@ -1,14 +1,7 @@
import { import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
PrimaryColumn, import { id } from '../id.js';
Entity, import { User } from './user.js';
Index, import { Note } from './note.js';
JoinColumn,
Column,
ManyToOne,
} from "typeorm";
import { User } from "./user.js";
import { Note } from "./note.js";
import { id } from "../id.js";
@Entity() @Entity()
@Index(['userId', 'threadId'], { unique: true }) @Index(['userId', 'threadId'], { unique: true })
@ -37,4 +30,10 @@ export class NoteThreadMuting {
length: 256, length: 256,
}) })
public threadId: string; public threadId: string;
@Column('enum', {
array: true,
default: [],
})
public mutingNotificationTypes: any;
} }

View File

@ -1,20 +1,14 @@
import { In, Repository } from "typeorm"; import { In } from 'typeorm';
import { Notification } from "@/models/entities/notification.js"; import { noteNotificationTypes } from 'foundkey-js';
import { awaitAll } from "@/prelude/await-all.js"; import { db } from '@/db/postgre.js';
import type { Packed } from "@/misc/schema.js"; import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis.js';
import type { Note } from "@/models/entities/note.js"; import { Packed } from '@/misc/schema.js';
import type { NoteReaction } from "@/models/entities/note-reaction.js"; import { Note } from '@/models/entities/note.js';
import type { User } from "@/models/entities/user.js"; import { NoteReaction } from '@/models/entities/note-reaction.js';
import { aggregateNoteEmojis, prefetchEmojis } from "@/misc/populate-emojis.js"; import { Notification } from '@/models/entities/notification.js';
import { notificationTypes } from "@/types.js"; import { User } from '@/models/entities/user.js';
import { db } from "@/db/postgre.js"; import { awaitAll } from '@/prelude/await-all.js';
import { import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index.js';
Users,
Notes,
UserGroupInvitations,
AccessTokens,
NoteReactions,
} from "../index.js";
export const NotificationRepository = db.getRepository(Notification).extend({ export const NotificationRepository = db.getRepository(Notification).extend({
async pack( async pack(
@ -39,109 +33,27 @@ export const NotificationRepository = db.getRepository(Notification).extend({
type: notification.type, type: notification.type,
isRead: notification.isRead, isRead: notification.isRead,
userId: notification.notifierId, userId: notification.notifierId,
user: notification.notifierId user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null,
? Users.pack(notification.notifier || notification.notifierId) ...(noteNotificationTypes.includes(notification.type) ? {
: null, note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, {
...(notification.type === "mention" detail: true,
? { _hint_: options._hintForEachNotes_,
note: Notes.pack( }),
notification.note || notification.noteId!, } : {}),
{ id: notification.notifieeId }, ...(notification.type === 'reaction' ? {
{ reaction: notification.reaction,
detail: true, } : {}),
_hint_: options._hintForEachNotes_, ...(notification.type === 'pollVote' ? {
}, choice: notification.choice,
), } : {}),
} ...(notification.type === 'groupInvited' ? {
: {}), invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!),
...(notification.type === "reply" } : {}),
? { ...(notification.type === 'app' ? {
note: Notes.pack( body: notification.customBody,
notification.note || notification.noteId!, header: notification.customHeader || token?.name,
{ id: notification.notifieeId }, icon: notification.customIcon || token?.iconUrl,
{ } : {}),
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,
}
: {}),
}); });
}, },

View File

@ -1,22 +1,16 @@
import { Not } from "typeorm"; import { ArrayOverlap, Not } from 'typeorm';
import { publishNoteStream } from "@/services/stream.js"; import { publishNoteStream } from '@/services/stream.js';
import { createNotification } from "@/services/create-notification.js"; import { createNotification } from '@/services/create-notification.js';
import { deliver } from "@/queue/index.js"; import { deliver } from '@/queue/index.js';
import { renderActivity } from "@/remote/activitypub/renderer/index.js"; import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderVote from "@/remote/activitypub/renderer/vote.js"; import renderVote from '@/remote/activitypub/renderer/vote.js';
import { deliverQuestionUpdate } from "@/services/note/polls/update.js"; import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
import { import { PollVotes, NoteWatchings, Users, Polls, Blockings, NoteThreadMutings } from '@/models/index.js';
PollVotes, import { IRemoteUser } from '@/models/entities/user.js';
NoteWatchings, import { genId } from '@/misc/gen-id.js';
Users, import { getNote } from '../../../common/getters.js';
Polls, import { ApiError } from '../../../error.js';
Blockings, import define from '../../../define.js';
} 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";
export const meta = { export const meta = {
tags: ["notes"], tags: ["notes"],
@ -145,14 +139,24 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
// Notify // check if this thread and notification type is muted
createNotification(note.userId, "pollVote", { const threadMuted = await NoteThreadMutings.findOne({
notifierId: user.id, userId: note.userId,
noteId: note.id, threadId: note.threadId || note.id,
choice: ps.choice, mutingNotificationTypes: ArrayOverlap(['pollVote']),
}); });
// Notify
if (!threadMuted) {
createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id,
choice: ps.choice,
});
}
// Fetch watchers // Fetch watchers
// checking for mutes is not necessary here, as note watchings will be
// deleted when a thread is muted
NoteWatchings.findBy({ NoteWatchings.findBy({
noteId: note.id, noteId: note.id,
userId: Not(user.id), userId: Not(user.id),

View File

@ -1,9 +1,9 @@
import { Notes, NoteThreadMutings } from "@/models/index.js"; import { Notes, NoteThreadMutings, NoteWatchings } from '@/models/index.js';
import { genId } from "@/misc/gen-id.js"; import { genId } from '@/misc/gen-id.js';
import readNote from "@/services/note/read.js"; import readNote from '@/services/note/read.js';
import define from "../../../define.js"; import define from '../../../define.js';
import { getNote } from "../../../common/getters.js"; import { getNote } from '../../../common/getters.js';
import { ApiError } from "../../../error.js"; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ["notes"], tags: ["notes"],
@ -24,7 +24,15 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: "object", type: "object",
properties: { 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"], required: ["noteId"],
} as const; } as const;
@ -54,5 +62,19 @@ export default define(meta, paramDef, async (ps, user) => {
createdAt: new Date(), createdAt: new Date(),
threadId: note.threadId || note.id, threadId: note.threadId || note.id,
userId: user.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();
}); });

View File

@ -1,3 +1,4 @@
import { ArrayOverlap, Not, In } from 'typeorm';
import * as mfm from "mfm-js"; import * as mfm from "mfm-js";
import es from "../../db/elasticsearch.js"; import es from "../../db/elasticsearch.js";
import { import {
@ -90,7 +91,7 @@ class NotificationManager {
// 自分自身へは通知しない // 自分自身へは通知しない
if (this.notifier.id === notifiee) return; 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) { if (exist) {
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
@ -107,15 +108,19 @@ class NotificationManager {
public async deliver() { public async deliver() {
for (const x of this.queue) { for (const x of this.queue) {
// ミュート情報を取得 // check if the sender or thread are muted
const mentioneeMutes = await Mutings.findBy({ const userMuted = await Mutings.findOneBy({
muterId: x.target, 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 (!(userMuted || threadMuted)) {
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
createNotification(x.target, x.reason, { createNotification(x.target, x.reason, {
notifierId: this.notifier.id, notifierId: this.notifier.id,
noteId: this.note.id, noteId: this.note.id,

View File

@ -1,3 +1,4 @@
import { ArrayOverlap, Not } from 'typeorm';
import { publishNoteStream } from "@/services/stream.js"; import { publishNoteStream } from "@/services/stream.js";
import type { CacheableUser } from "@/models/entities/user.js"; import type { CacheableUser } from "@/models/entities/user.js";
import { User } from "@/models/entities/user.js"; import { User } from "@/models/entities/user.js";
@ -64,12 +65,20 @@ export default async function (
userId: user.id, userId: user.id,
}); });
// Notify // check if this thread and notification type is muted
createNotification(note.userId, "pollVote", { const muted = await NoteThreadMutings.findOne({
notifierId: user.id, userId: note.userId,
noteId: note.id, threadId: note.threadId || note.id,
choice: choice, mutingNotificationTypes: ArrayOverlap(['pollVote']),
}); });
// Notify
if (!muted) {
createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id,
choice: choice,
});
}
// Fetch watchers // Fetch watchers
NoteWatchings.findBy({ NoteWatchings.findBy({

View File

@ -1,3 +1,4 @@
import { ArrayOverlap, IsNull, Not } from 'typeorm';
import { publishNoteStream } from "@/services/stream.js"; import { publishNoteStream } from "@/services/stream.js";
import { renderLike } from "@/remote/activitypub/renderer/like.js"; import { renderLike } from "@/remote/activitypub/renderer/like.js";
import DeliverManager from "@/remote/activitypub/deliver-manager.js"; import DeliverManager from "@/remote/activitypub/deliver-manager.js";
@ -118,9 +119,15 @@ export default async (
userId: user.id, 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) { if (note.userHost === null && !threadMuted) {
createNotification(note.userId, "reaction", { createNotification(note.userId, 'reaction', {
notifierId: user.id, notifierId: user.id,
note: note, note: note,
noteId: note.id, noteId: note.id,

View File

@ -18,7 +18,7 @@
</MkSwitch> </MkSwitch>
</div> </div>
<div v-if="!useGlobalSetting" class="_section"> <div v-if="!useGlobalSetting" class="_section">
<MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> <MkInfo>{{ message }}</MkInfo>
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
@ -42,21 +42,25 @@ const emit = defineEmits<{
}>(); }>();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
includingTypes?: typeof notificationTypes[number][] | null; includingTypes?: typeof foundkey.notificationTypes[number][] | null;
notificationTypes?: typeof foundkey.notificationTypes[number][] | null;
showGlobalToggle?: boolean; showGlobalToggle?: boolean;
message?: string,
}>(), { }>(), {
includingTypes: () => [], includingTypes: () => [],
notificationTypes: () => [],
showGlobalToggle: true, showGlobalToggle: true,
message: i18n.ts.notificationSettingDesc,
}); });
let includingTypes = $computed(() => props.includingTypes || []); let includingTypes = $computed(() => props.includingTypes || []);
const dialog = $ref<InstanceType<typeof XModalWindow>>(); const dialog = $ref<InstanceType<typeof XModalWindow>>();
let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({}); let typesMap = $ref<Record<typeof foundkey.notificationTypes[number], boolean>>({});
let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); 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); typesMap[ntype] = includingTypes.includes(ntype);
} }
@ -65,7 +69,7 @@ function ok() {
emit('done', { includingTypes: null }); emit('done', { includingTypes: null });
} else { } else {
emit('done', { emit('done', {
includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) includingTypes: (Object.keys(typesMap) as typeof foundkey.notificationTypes[number][])
.filter(type => typesMap[type]), .filter(type => typesMap[type]),
}); });
} }
@ -75,13 +79,13 @@ function ok() {
function disableAll() { function disableAll() {
for (const type in typesMap) { for (const type in typesMap) {
typesMap[type as typeof notificationTypes[number]] = false; typesMap[type as typeof foundkey.notificationTypes[number]] = false;
} }
} }
function enableAll() { function enableAll() {
for (const type in typesMap) { for (const type in typesMap) {
typesMap[type as typeof notificationTypes[number]] = true; typesMap[type as typeof foundkey.notificationTypes[number]] = true;
} }
} }
</script> </script>

View File

@ -79,13 +79,34 @@ export function getNoteMenu(props: {
); );
} }
function toggleThreadMute(mute: boolean): void { function muteThread(): void {
os.apiWithDialog( // show global settings by default
mute ? "notes/thread-muting/create" : "notes/thread-muting/delete", const includingTypes = foundkey.notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x));
{ os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), {
noteId: appearNote.id, 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 { function copyContent(): void {
@ -322,33 +343,47 @@ export function getNoteMenu(props: {
text: i18n.ts.clip, text: i18n.ts.clip,
action: () => clip(), action: () => clip(),
}, },
appearNote.userId !== $i.id (appearNote.userId !== $i.id) ? statePromise.then(state => state.isWatching ? {
? statePromise.then((state) => icon: 'ph-eye-slash ph-bold ph-lg',
state.isWatching text: i18n.ts.unwatch,
? { action: () => toggleWatch(false),
icon: "ph-eye-slash ph-bold ph-lg", } : {
text: i18n.ts.unwatch, icon: 'ph-eye ph-bold ph-lg',
action: () => toggleWatch(false), text: i18n.ts.watch,
} action: () => toggleWatch(true),
: { }) : undefined,
icon: "ph-eye ph-bold ph-lg", statePromise.then(state => state.isMutedThread ? {
text: i18n.ts.watch, icon: 'ph-speaker-high ph-bold ph-lg',
action: () => toggleWatch(true), text: i18n.ts.unmuteThread,
}, action: () => unmuteThread(),
) } : {
: undefined, icon: 'ph-speaker-x ph-bold ph-lg',
statePromise.then((state) => text: i18n.ts.muteThread,
state.isMutedThread action: () => muteThread(),
? { }),
icon: "ph-speaker-x ph-bold ph-lg", appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
text: i18n.ts.unmuteThread, icon: 'ph-push-pin ph-bold ph-lg',
action: () => toggleThreadMute(false), text: i18n.ts.unpin,
} action: () => togglePin(false),
: { } : {
icon: "ph-speaker-x ph-bold ph-lg", icon: 'ph-push-pin ph-bold ph-lg',
text: i18n.ts.muteThread, text: i18n.ts.pin,
action: () => toggleThreadMute(true), 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 appearNote.userId === $i.id
? ($i.pinnedNoteIds || []).includes(appearNote.id) ? ($i.pinnedNoteIds || []).includes(appearNote.id)