From fc65190ef7b687650018cccfee2219bf00827f70 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 31 Oct 2021 15:30:22 +0900 Subject: [PATCH] feat: thread mute (#7930) * feat: thread mute * chore: fix comment * fix test * fix * refactor --- CHANGELOG.md | 1 + locales/ja-JP.yml | 2 + migration/1635500777168-note-thread-mute.ts | 26 +++++ src/client/components/note-detailed.vue | 15 +++ src/client/components/note.vue | 15 +++ src/db/postgre.ts | 2 + src/models/entities/note-thread-muting.ts | 33 ++++++ src/models/entities/note.ts | 6 + src/models/index.ts | 2 + .../generate-muted-note-thread-query.ts | 17 +++ src/server/api/endpoints/notes/mentions.ts | 2 + src/server/api/endpoints/notes/state.ts | 28 +++-- .../endpoints/notes/thread-muting/create.ts | 54 +++++++++ .../endpoints/notes/thread-muting/delete.ts | 40 +++++++ src/services/note/create.ts | 29 ++++- src/services/note/unread.ts | 11 +- test/thread-mute.ts | 103 ++++++++++++++++++ test/utils.ts | 3 +- 18 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 migration/1635500777168-note-thread-mute.ts create mode 100644 src/models/entities/note-thread-muting.ts create mode 100644 src/server/api/common/generate-muted-note-thread-query.ts create mode 100644 src/server/api/endpoints/notes/thread-muting/create.ts create mode 100644 src/server/api/endpoints/notes/thread-muting/delete.ts create mode 100644 test/thread-mute.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf62565b8c..ae652c310a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ## 12.x.x (unreleased) ### Improvements +- スレッドミュート機能 ### Bugfixes - リレー向けのActivityが一部実装で除外されてしまうことがあるのを修正 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index dbb0bf1664..1326369f83 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -800,6 +800,8 @@ manageAccounts: "アカウントを管理" makeReactionsPublic: "リアクション一覧を公開する" makeReactionsPublicDescription: "あなたがしたリアクション一覧を誰でも見れるようにします。" classic: "クラシック" +muteThread: "スレッドをミュート" +unmuteThread: "スレッドのミュートを解除" _signup: almostThere: "ほとんど完了です" diff --git a/migration/1635500777168-note-thread-mute.ts b/migration/1635500777168-note-thread-mute.ts new file mode 100644 index 0000000000..aed10d18d7 --- /dev/null +++ b/migration/1635500777168-note-thread-mute.ts @@ -0,0 +1,26 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class noteThreadMute1635500777168 implements MigrationInterface { + name = 'noteThreadMute1635500777168' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "note_thread_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "threadId" character varying(256) NOT NULL, CONSTRAINT "PK_ec5936d94d1a0369646d12a3a47" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_29c11c7deb06615076f8c95b80" ON "note_thread_muting" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_c426394644267453e76f036926" ON "note_thread_muting" ("threadId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ae7aab18a2641d3e5f25e0c4ea" ON "note_thread_muting" ("userId", "threadId") `); + await queryRunner.query(`ALTER TABLE "note" ADD "threadId" character varying(256)`); + await queryRunner.query(`CREATE INDEX "IDX_d4ebdef929896d6dc4a3c5bb48" ON "note" ("threadId") `); + await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD CONSTRAINT "FK_29c11c7deb06615076f8c95b80a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP CONSTRAINT "FK_29c11c7deb06615076f8c95b80a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d4ebdef929896d6dc4a3c5bb48"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "threadId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ae7aab18a2641d3e5f25e0c4ea"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c426394644267453e76f036926"`); + await queryRunner.query(`DROP INDEX "public"."IDX_29c11c7deb06615076f8c95b80"`); + await queryRunner.query(`DROP TABLE "note_thread_muting"`); + } + +} diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue index 40b0a68c58..568a2360d1 100644 --- a/src/client/components/note-detailed.vue +++ b/src/client/components/note-detailed.vue @@ -601,6 +601,12 @@ export default defineComponent({ }); }, + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + getMenu() { let menu; if (this.$i) { @@ -657,6 +663,15 @@ export default defineComponent({ text: this.$ts.watch, action: () => this.toggleWatch(true) }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { icon: 'fas fa-thumbtack', text: this.$ts.unpin, diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 91a3e3b87d..681e819a22 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -576,6 +576,12 @@ export default defineComponent({ }); }, + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + getMenu() { let menu; if (this.$i) { @@ -632,6 +638,15 @@ export default defineComponent({ text: this.$ts.watch, action: () => this.toggleWatch(true) }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { icon: 'fas fa-thumbtack', text: this.$ts.unpin, diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 4f4047b613..f52c2ab722 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -17,6 +17,7 @@ import { PollVote } from '@/models/entities/poll-vote'; import { Note } from '@/models/entities/note'; import { NoteReaction } from '@/models/entities/note-reaction'; import { NoteWatching } from '@/models/entities/note-watching'; +import { NoteThreadMuting } from '@/models/entities/note-thread-muting'; import { NoteUnread } from '@/models/entities/note-unread'; import { Notification } from '@/models/entities/notification'; import { Meta } from '@/models/entities/meta'; @@ -138,6 +139,7 @@ export const entities = [ NoteFavorite, NoteReaction, NoteWatching, + NoteThreadMuting, NoteUnread, Page, PageLike, diff --git a/src/models/entities/note-thread-muting.ts b/src/models/entities/note-thread-muting.ts new file mode 100644 index 0000000000..b438522a4c --- /dev/null +++ b/src/models/entities/note-thread-muting.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { Note } from './note'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'threadId'], { unique: true }) +export class NoteThreadMuting { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column('varchar', { + length: 256, + }) + public threadId: string; +} diff --git a/src/models/entities/note.ts b/src/models/entities/note.ts index 9a85532637..4a5411f93d 100644 --- a/src/models/entities/note.ts +++ b/src/models/entities/note.ts @@ -47,6 +47,12 @@ export class Note { @JoinColumn() public renote: Note | null; + @Index() + @Column('varchar', { + length: 256, nullable: true + }) + public threadId: string | null; + @Column('varchar', { length: 8192, nullable: true }) diff --git a/src/models/index.ts b/src/models/index.ts index 4c6f19eaff..7154cca550 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -7,6 +7,7 @@ import { PollVote } from './entities/poll-vote'; import { Meta } from './entities/meta'; import { SwSubscription } from './entities/sw-subscription'; import { NoteWatching } from './entities/note-watching'; +import { NoteThreadMuting } from './entities/note-thread-muting'; import { NoteUnread } from './entities/note-unread'; import { RegistrationTicket } from './entities/registration-tickets'; import { UserRepository } from './repositories/user'; @@ -69,6 +70,7 @@ export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); export const NoteFavorites = getCustomRepository(NoteFavoriteRepository); export const NoteWatchings = getRepository(NoteWatching); +export const NoteThreadMutings = getRepository(NoteThreadMuting); export const NoteReactions = getCustomRepository(NoteReactionRepository); export const NoteUnreads = getRepository(NoteUnread); export const Polls = getRepository(Poll); diff --git a/src/server/api/common/generate-muted-note-thread-query.ts b/src/server/api/common/generate-muted-note-thread-query.ts new file mode 100644 index 0000000000..7e2cbd498b --- /dev/null +++ b/src/server/api/common/generate-muted-note-thread-query.ts @@ -0,0 +1,17 @@ +import { User } from '@/models/entities/user'; +import { NoteThreadMutings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { + const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted') + .select('threadMuted.threadId') + .where('threadMuted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + q.andWhere(new Brackets(qb => { qb + .where(`note.threadId IS NULL`) + .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); + })); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index 74f7911bfe..ffaebd6c95 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -8,6 +8,7 @@ import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Brackets } from 'typeorm'; import { generateBlockedUserQuery } from '../../common/generate-block-query'; +import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query'; export const meta = { tags: ['notes'], @@ -67,6 +68,7 @@ export default define(meta, async (ps, user) => { generateVisibilityQuery(query, user); generateMutedUserQuery(query, user); + generateMutedNoteThreadQuery(query, user); generateBlockedUserQuery(query, user); if (ps.visibility) { diff --git a/src/server/api/endpoints/notes/state.ts b/src/server/api/endpoints/notes/state.ts index 489902435d..b3913a5e79 100644 --- a/src/server/api/endpoints/notes/state.ts +++ b/src/server/api/endpoints/notes/state.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import { ID } from '@/misc/cafy-id'; import define from '../../define'; -import { NoteFavorites, NoteWatchings } from '@/models/index'; +import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index'; export const meta = { tags: ['notes'], @@ -25,31 +25,45 @@ export const meta = { isWatching: { type: 'boolean' as const, optional: false as const, nullable: false as const - } + }, + isMutedThread: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, } } }; export default define(meta, async (ps, user) => { - const [favorite, watching] = await Promise.all([ + const note = await Notes.findOneOrFail(ps.noteId); + + const [favorite, watching, threadMuting] = await Promise.all([ NoteFavorites.count({ where: { userId: user.id, - noteId: ps.noteId + noteId: note.id, }, take: 1 }), NoteWatchings.count({ where: { userId: user.id, - noteId: ps.noteId + noteId: note.id, }, take: 1 - }) + }), + NoteThreadMutings.count({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + }, + take: 1 + }), ]); return { isFavorited: favorite !== 0, - isWatching: watching !== 0 + isWatching: watching !== 0, + isMutedThread: threadMuting !== 0, }; }); diff --git a/src/server/api/endpoints/notes/thread-muting/create.ts b/src/server/api/endpoints/notes/thread-muting/create.ts new file mode 100644 index 0000000000..2010d54331 --- /dev/null +++ b/src/server/api/endpoints/notes/thread-muting/create.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; +import { Notes, NoteThreadMutings } from '@/models'; +import { genId } from '@/misc/gen-id'; +import readNote from '@/services/note/read'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const mutedNotes = await Notes.find({ + where: [{ + id: note.threadId || note.id, + }, { + threadId: note.threadId || note.id, + }], + }); + + await readNote(user.id, mutedNotes); + + await NoteThreadMutings.insert({ + id: genId(), + createdAt: new Date(), + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/src/server/api/endpoints/notes/thread-muting/delete.ts b/src/server/api/endpoints/notes/thread-muting/delete.ts new file mode 100644 index 0000000000..05d5691870 --- /dev/null +++ b/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; +import { NoteThreadMutings } from '@/models'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'bddd57ac-ceb3-b29d-4334-86ea5fae481a' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await NoteThreadMutings.delete({ + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 8c996bdba6..69d854ab1a 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -10,13 +10,13 @@ import { resolveUser } from '@/remote/resolve-user'; import config from '@/config/index'; import { updateHashtags } from '../update-hashtag'; import { concat } from '@/prelude/array'; -import insertNoteUnread from './unread'; +import { insertNoteUnread } from '@/services/note/unread'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; import { extractMentions } from '@/misc/extract-mentions'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; import { extractHashtags } from '@/misc/extract-hashtags'; import { Note, IMentionedRemoteUsers } from '@/models/entities/note'; -import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings } from '@/models/index'; +import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings, NoteThreadMutings } from '@/models/index'; import { DriveFile } from '@/models/entities/drive-file'; import { App } from '@/models/entities/app'; import { Not, getConnection, In } from 'typeorm'; @@ -344,8 +344,15 @@ export default async (user: { id: User['id']; username: User['username']; host: // 通知 if (data.reply.userHost === null) { - nm.push(data.reply.userId, 'reply'); - publishMainStream(data.reply.userId, 'reply', noteObj); + const threadMuted = await NoteThreadMutings.findOne({ + userId: data.reply.userId, + threadId: data.reply.threadId || data.reply.id, + }); + + if (!threadMuted) { + nm.push(data.reply.userId, 'reply'); + publishMainStream(data.reply.userId, 'reply', noteObj); + } } } @@ -459,6 +466,11 @@ async function insertNote(user: { id: User['id']; host: User['host']; }, data: O replyId: data.reply ? data.reply.id : null, renoteId: data.renote ? data.renote.id : null, channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, name: data.name, text: data.text, hasPoll: data.poll != null, @@ -581,6 +593,15 @@ async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager) { for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) { + const threadMuted = await NoteThreadMutings.findOne({ + userId: u.id, + threadId: note.threadId || note.id, + }); + + if (threadMuted) { + continue; + } + const detailPackedNote = await Notes.pack(note, u, { detail: true }); diff --git a/src/services/note/unread.ts b/src/services/note/unread.ts index 4a9df6083c..29d2b54af8 100644 --- a/src/services/note/unread.ts +++ b/src/services/note/unread.ts @@ -1,10 +1,10 @@ import { Note } from '@/models/entities/note'; import { publishMainStream } from '@/services/stream'; import { User } from '@/models/entities/user'; -import { Mutings, NoteUnreads } from '@/models/index'; +import { Mutings, NoteThreadMutings, NoteUnreads } from '@/models/index'; import { genId } from '@/misc/gen-id'; -export default async function(userId: User['id'], note: Note, params: { +export async function insertNoteUnread(userId: User['id'], note: Note, params: { // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse isSpecified: boolean; isMentioned: boolean; @@ -17,6 +17,13 @@ export default async function(userId: User['id'], note: Note, params: { if (mute.map(m => m.muteeId).includes(note.userId)) return; //#endregion + // スレッドミュート + const threadMute = await NoteThreadMutings.findOne({ + userId: userId, + threadId: note.threadId || note.id, + }); + if (threadMute) return; + const unread = { id: genId(), noteId: note.id, diff --git a/test/thread-mute.ts b/test/thread-mute.ts new file mode 100644 index 0000000000..95601cd903 --- /dev/null +++ b/test/thread-mute.ts @@ -0,0 +1,103 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils'; + +describe('Note thread mute', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('notes/mentions にミュートしているスレッドの投稿が含まれない', async(async () => { + const bobNote = await post(bob, { text: '@alice @carol root note' }); + const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); + + const res = await request('/notes/mentions', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); + })); + + it('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async(async () => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + + const bobNote = await post(bob, { text: '@alice @carol root note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + + const res = await request('/i', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.hasUnreadMentions, false); + })); + + it('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + + const bobNote = await post(bob, { text: '@alice @carol root note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + let fired = false; + + const ws = await connectStream(alice, 'main', async ({ type, body }) => { + if (type === 'unreadMention') { + if (body === bobNote.id) return; + fired = true; + } + }); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + + setTimeout(() => { + assert.strictEqual(fired, false); + ws.close(); + done(); + }, 5000); + })); + + it('i/notifications にミュートしているスレッドの通知が含まれない', async(async () => { + const bobNote = await post(bob, { text: '@alice @carol root note' }); + const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); + + const res = await request('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false); + assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); + + // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい + })); +}); diff --git a/test/utils.ts b/test/utils.ts index 1a0c54463d..54bcf65ab1 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as WebSocket from 'ws'; +import * as misskey from 'misskey-js'; import fetch from 'node-fetch'; const FormData = require('form-data'); import * as childProcess from 'child_process'; @@ -52,7 +53,7 @@ export const signup = async (params?: any): Promise => { return res.body; }; -export const post = async (user: any, params?: any): Promise => { +export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { const q = Object.assign({ text: 'test' }, params);