feat: thread mute (#7930)
* feat: thread mute * chore: fix comment * fix test * fix * refactor
This commit is contained in:
		
							parent
							
								
									f47a564819
								
							
						
					
					
						commit
						fc65190ef7
					
				
					 18 changed files with 375 additions and 14 deletions
				
			
		|  | @ -10,6 +10,7 @@ | |||
| ## 12.x.x (unreleased) | ||||
| 
 | ||||
| ### Improvements | ||||
| - スレッドミュート機能 | ||||
| 
 | ||||
| ### Bugfixes | ||||
| - リレー向けのActivityが一部実装で除外されてしまうことがあるのを修正 | ||||
|  |  | |||
|  | @ -800,6 +800,8 @@ manageAccounts: "アカウントを管理" | |||
| makeReactionsPublic: "リアクション一覧を公開する" | ||||
| makeReactionsPublicDescription: "あなたがしたリアクション一覧を誰でも見れるようにします。" | ||||
| classic: "クラシック" | ||||
| muteThread: "スレッドをミュート" | ||||
| unmuteThread: "スレッドのミュートを解除" | ||||
| 
 | ||||
| _signup: | ||||
|   almostThere: "ほとんど完了です" | ||||
|  |  | |||
							
								
								
									
										26
									
								
								migration/1635500777168-note-thread-mute.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								migration/1635500777168-note-thread-mute.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import {MigrationInterface, QueryRunner} from "typeorm"; | ||||
| 
 | ||||
| export class noteThreadMute1635500777168 implements MigrationInterface { | ||||
|     name = 'noteThreadMute1635500777168' | ||||
| 
 | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         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<void> { | ||||
|         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"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										33
									
								
								src/models/entities/note-thread-muting.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/models/entities/note-thread-muting.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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; | ||||
| } | ||||
|  | @ -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 | ||||
| 	}) | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
							
								
								
									
										17
									
								
								src/server/api/common/generate-muted-note-thread-query.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/server/api/common/generate-muted-note-thread-query.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<any>, 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()); | ||||
| } | ||||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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, | ||||
| 	}; | ||||
| }); | ||||
|  |  | |||
							
								
								
									
										54
									
								
								src/server/api/endpoints/notes/thread-muting/create.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/server/api/endpoints/notes/thread-muting/create.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										40
									
								
								src/server/api/endpoints/notes/thread-muting/delete.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/server/api/endpoints/notes/thread-muting/delete.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||
| 	}); | ||||
| }); | ||||
|  | @ -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 | ||||
| 		}); | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										103
									
								
								test/thread-mute.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								test/thread-mute.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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の投稿はスレッドミュート前に行われたため通知に含まれていてもよい
 | ||||
| 	})); | ||||
| }); | ||||
|  | @ -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<any> => { | |||
| 	return res.body; | ||||
| }; | ||||
| 
 | ||||
| export const post = async (user: any, params?: any): Promise<any> => { | ||||
| export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise<misskey.entities.Note> => { | ||||
| 	const q = Object.assign({ | ||||
| 		text: 'test' | ||||
| 	}, params); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue