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) | ## 12.x.x (unreleased) | ||||||
| 
 | 
 | ||||||
| ### Improvements | ### Improvements | ||||||
|  | - スレッドミュート機能 | ||||||
| 
 | 
 | ||||||
| ### Bugfixes | ### Bugfixes | ||||||
| - リレー向けのActivityが一部実装で除外されてしまうことがあるのを修正 | - リレー向けのActivityが一部実装で除外されてしまうことがあるのを修正 | ||||||
|  |  | ||||||
|  | @ -800,6 +800,8 @@ manageAccounts: "アカウントを管理" | ||||||
| makeReactionsPublic: "リアクション一覧を公開する" | makeReactionsPublic: "リアクション一覧を公開する" | ||||||
| makeReactionsPublicDescription: "あなたがしたリアクション一覧を誰でも見れるようにします。" | makeReactionsPublicDescription: "あなたがしたリアクション一覧を誰でも見れるようにします。" | ||||||
| classic: "クラシック" | classic: "クラシック" | ||||||
|  | muteThread: "スレッドをミュート" | ||||||
|  | unmuteThread: "スレッドのミュートを解除" | ||||||
| 
 | 
 | ||||||
| _signup: | _signup: | ||||||
|   almostThere: "ほとんど完了です" |   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() { | 		getMenu() { | ||||||
| 			let menu; | 			let menu; | ||||||
| 			if (this.$i) { | 			if (this.$i) { | ||||||
|  | @ -657,6 +663,15 @@ export default defineComponent({ | ||||||
| 					text: this.$ts.watch, | 					text: this.$ts.watch, | ||||||
| 					action: () => this.toggleWatch(true) | 					action: () => this.toggleWatch(true) | ||||||
| 				}) : undefined, | 				}) : 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) ? { | 				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { | ||||||
| 					icon: 'fas fa-thumbtack', | 					icon: 'fas fa-thumbtack', | ||||||
| 					text: this.$ts.unpin, | 					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() { | 		getMenu() { | ||||||
| 			let menu; | 			let menu; | ||||||
| 			if (this.$i) { | 			if (this.$i) { | ||||||
|  | @ -632,6 +638,15 @@ export default defineComponent({ | ||||||
| 					text: this.$ts.watch, | 					text: this.$ts.watch, | ||||||
| 					action: () => this.toggleWatch(true) | 					action: () => this.toggleWatch(true) | ||||||
| 				}) : undefined, | 				}) : 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) ? { | 				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { | ||||||
| 					icon: 'fas fa-thumbtack', | 					icon: 'fas fa-thumbtack', | ||||||
| 					text: this.$ts.unpin, | 					text: this.$ts.unpin, | ||||||
|  |  | ||||||
|  | @ -17,6 +17,7 @@ import { PollVote } from '@/models/entities/poll-vote'; | ||||||
| import { Note } from '@/models/entities/note'; | import { Note } from '@/models/entities/note'; | ||||||
| import { NoteReaction } from '@/models/entities/note-reaction'; | import { NoteReaction } from '@/models/entities/note-reaction'; | ||||||
| import { NoteWatching } from '@/models/entities/note-watching'; | import { NoteWatching } from '@/models/entities/note-watching'; | ||||||
|  | import { NoteThreadMuting } from '@/models/entities/note-thread-muting'; | ||||||
| import { NoteUnread } from '@/models/entities/note-unread'; | import { NoteUnread } from '@/models/entities/note-unread'; | ||||||
| import { Notification } from '@/models/entities/notification'; | import { Notification } from '@/models/entities/notification'; | ||||||
| import { Meta } from '@/models/entities/meta'; | import { Meta } from '@/models/entities/meta'; | ||||||
|  | @ -138,6 +139,7 @@ export const entities = [ | ||||||
| 	NoteFavorite, | 	NoteFavorite, | ||||||
| 	NoteReaction, | 	NoteReaction, | ||||||
| 	NoteWatching, | 	NoteWatching, | ||||||
|  | 	NoteThreadMuting, | ||||||
| 	NoteUnread, | 	NoteUnread, | ||||||
| 	Page, | 	Page, | ||||||
| 	PageLike, | 	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() | 	@JoinColumn() | ||||||
| 	public renote: Note | null; | 	public renote: Note | null; | ||||||
| 
 | 
 | ||||||
|  | 	@Index() | ||||||
|  | 	@Column('varchar', { | ||||||
|  | 		length: 256, nullable: true | ||||||
|  | 	}) | ||||||
|  | 	public threadId: string | null; | ||||||
|  | 
 | ||||||
| 	@Column('varchar', { | 	@Column('varchar', { | ||||||
| 		length: 8192, nullable: true | 		length: 8192, nullable: true | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ import { PollVote } from './entities/poll-vote'; | ||||||
| import { Meta } from './entities/meta'; | import { Meta } from './entities/meta'; | ||||||
| import { SwSubscription } from './entities/sw-subscription'; | import { SwSubscription } from './entities/sw-subscription'; | ||||||
| import { NoteWatching } from './entities/note-watching'; | import { NoteWatching } from './entities/note-watching'; | ||||||
|  | import { NoteThreadMuting } from './entities/note-thread-muting'; | ||||||
| import { NoteUnread } from './entities/note-unread'; | import { NoteUnread } from './entities/note-unread'; | ||||||
| import { RegistrationTicket } from './entities/registration-tickets'; | import { RegistrationTicket } from './entities/registration-tickets'; | ||||||
| import { UserRepository } from './repositories/user'; | import { UserRepository } from './repositories/user'; | ||||||
|  | @ -69,6 +70,7 @@ export const Apps = getCustomRepository(AppRepository); | ||||||
| export const Notes = getCustomRepository(NoteRepository); | export const Notes = getCustomRepository(NoteRepository); | ||||||
| export const NoteFavorites = getCustomRepository(NoteFavoriteRepository); | export const NoteFavorites = getCustomRepository(NoteFavoriteRepository); | ||||||
| export const NoteWatchings = getRepository(NoteWatching); | export const NoteWatchings = getRepository(NoteWatching); | ||||||
|  | export const NoteThreadMutings = getRepository(NoteThreadMuting); | ||||||
| export const NoteReactions = getCustomRepository(NoteReactionRepository); | export const NoteReactions = getCustomRepository(NoteReactionRepository); | ||||||
| export const NoteUnreads = getRepository(NoteUnread); | export const NoteUnreads = getRepository(NoteUnread); | ||||||
| export const Polls = getRepository(Poll); | 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 { makePaginationQuery } from '../../common/make-pagination-query'; | ||||||
| import { Brackets } from 'typeorm'; | import { Brackets } from 'typeorm'; | ||||||
| import { generateBlockedUserQuery } from '../../common/generate-block-query'; | import { generateBlockedUserQuery } from '../../common/generate-block-query'; | ||||||
|  | import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query'; | ||||||
| 
 | 
 | ||||||
| export const meta = { | export const meta = { | ||||||
| 	tags: ['notes'], | 	tags: ['notes'], | ||||||
|  | @ -67,6 +68,7 @@ export default define(meta, async (ps, user) => { | ||||||
| 
 | 
 | ||||||
| 	generateVisibilityQuery(query, user); | 	generateVisibilityQuery(query, user); | ||||||
| 	generateMutedUserQuery(query, user); | 	generateMutedUserQuery(query, user); | ||||||
|  | 	generateMutedNoteThreadQuery(query, user); | ||||||
| 	generateBlockedUserQuery(query, user); | 	generateBlockedUserQuery(query, user); | ||||||
| 
 | 
 | ||||||
| 	if (ps.visibility) { | 	if (ps.visibility) { | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import $ from 'cafy'; | import $ from 'cafy'; | ||||||
| import { ID } from '@/misc/cafy-id'; | import { ID } from '@/misc/cafy-id'; | ||||||
| import define from '../../define'; | import define from '../../define'; | ||||||
| import { NoteFavorites, NoteWatchings } from '@/models/index'; | import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index'; | ||||||
| 
 | 
 | ||||||
| export const meta = { | export const meta = { | ||||||
| 	tags: ['notes'], | 	tags: ['notes'], | ||||||
|  | @ -25,31 +25,45 @@ export const meta = { | ||||||
| 			isWatching: { | 			isWatching: { | ||||||
| 				type: 'boolean' as const, | 				type: 'boolean' as const, | ||||||
| 				optional: false as const, nullable: false 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) => { | 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({ | 		NoteFavorites.count({ | ||||||
| 			where: { | 			where: { | ||||||
| 				userId: user.id, | 				userId: user.id, | ||||||
| 				noteId: ps.noteId | 				noteId: note.id, | ||||||
| 			}, | 			}, | ||||||
| 			take: 1 | 			take: 1 | ||||||
| 		}), | 		}), | ||||||
| 		NoteWatchings.count({ | 		NoteWatchings.count({ | ||||||
| 			where: { | 			where: { | ||||||
| 				userId: user.id, | 				userId: user.id, | ||||||
| 				noteId: ps.noteId | 				noteId: note.id, | ||||||
| 			}, | 			}, | ||||||
| 			take: 1 | 			take: 1 | ||||||
| 		}) | 		}), | ||||||
|  | 		NoteThreadMutings.count({ | ||||||
|  | 			where: { | ||||||
|  | 				userId: user.id, | ||||||
|  | 				threadId: note.threadId || note.id, | ||||||
|  | 			}, | ||||||
|  | 			take: 1 | ||||||
|  | 		}), | ||||||
| 	]); | 	]); | ||||||
| 
 | 
 | ||||||
| 	return { | 	return { | ||||||
| 		isFavorited: favorite !== 0, | 		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 config from '@/config/index'; | ||||||
| import { updateHashtags } from '../update-hashtag'; | import { updateHashtags } from '../update-hashtag'; | ||||||
| import { concat } from '@/prelude/array'; | import { concat } from '@/prelude/array'; | ||||||
| import insertNoteUnread from './unread'; | import { insertNoteUnread } from '@/services/note/unread'; | ||||||
| import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; | import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; | ||||||
| import { extractMentions } from '@/misc/extract-mentions'; | import { extractMentions } from '@/misc/extract-mentions'; | ||||||
| import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; | import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; | ||||||
| import { extractHashtags } from '@/misc/extract-hashtags'; | import { extractHashtags } from '@/misc/extract-hashtags'; | ||||||
| import { Note, IMentionedRemoteUsers } from '@/models/entities/note'; | 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 { DriveFile } from '@/models/entities/drive-file'; | ||||||
| import { App } from '@/models/entities/app'; | import { App } from '@/models/entities/app'; | ||||||
| import { Not, getConnection, In } from 'typeorm'; | 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) { | 			if (data.reply.userHost === null) { | ||||||
| 				nm.push(data.reply.userId, 'reply'); | 				const threadMuted = await NoteThreadMutings.findOne({ | ||||||
| 				publishMainStream(data.reply.userId, 'reply', noteObj); | 					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, | 		replyId: data.reply ? data.reply.id : null, | ||||||
| 		renoteId: data.renote ? data.renote.id : null, | 		renoteId: data.renote ? data.renote.id : null, | ||||||
| 		channelId: data.channel ? data.channel.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, | 		name: data.name, | ||||||
| 		text: data.text, | 		text: data.text, | ||||||
| 		hasPoll: data.poll != null, | 		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) { | async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager) { | ||||||
| 	for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) { | 	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, { | 		const detailPackedNote = await Notes.pack(note, u, { | ||||||
| 			detail: true | 			detail: true | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
|  | @ -1,10 +1,10 @@ | ||||||
| import { Note } from '@/models/entities/note'; | import { Note } from '@/models/entities/note'; | ||||||
| import { publishMainStream } from '@/services/stream'; | import { publishMainStream } from '@/services/stream'; | ||||||
| import { User } from '@/models/entities/user'; | 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'; | 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
 | 	// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
 | ||||||
| 	isSpecified: boolean; | 	isSpecified: boolean; | ||||||
| 	isMentioned: 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; | 	if (mute.map(m => m.muteeId).includes(note.userId)) return; | ||||||
| 	//#endregion
 | 	//#endregion
 | ||||||
| 
 | 
 | ||||||
|  | 	// スレッドミュート
 | ||||||
|  | 	const threadMute = await NoteThreadMutings.findOne({ | ||||||
|  | 		userId: userId, | ||||||
|  | 		threadId: note.threadId || note.id, | ||||||
|  | 	}); | ||||||
|  | 	if (threadMute) return; | ||||||
|  | 
 | ||||||
| 	const unread = { | 	const unread = { | ||||||
| 		id: genId(), | 		id: genId(), | ||||||
| 		noteId: note.id, | 		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 fs from 'fs'; | ||||||
| import * as WebSocket from 'ws'; | import * as WebSocket from 'ws'; | ||||||
|  | import * as misskey from 'misskey-js'; | ||||||
| import fetch from 'node-fetch'; | import fetch from 'node-fetch'; | ||||||
| const FormData = require('form-data'); | const FormData = require('form-data'); | ||||||
| import * as childProcess from 'child_process'; | import * as childProcess from 'child_process'; | ||||||
|  | @ -52,7 +53,7 @@ export const signup = async (params?: any): Promise<any> => { | ||||||
| 	return res.body; | 	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({ | 	const q = Object.assign({ | ||||||
| 		text: 'test' | 		text: 'test' | ||||||
| 	}, params); | 	}, params); | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue