diff --git a/locales/en-US.yml b/locales/en-US.yml index cb38e8a35..6ca0c04e2 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -121,6 +121,8 @@ unmarkAsSensitive: "Unmark as NSFW" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" +renoteMute: "Hide renotes" +renoteUnmute: "Show renotes" block: "Block" unblock: "Unblock" suspend: "Suspend" diff --git a/packages/backend/migration/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js new file mode 100644 index 000000000..f87114851 --- /dev/null +++ b/packages/backend/migration/1665091090561-add-renote-muting.js @@ -0,0 +1,20 @@ + +export class addRenoteMuting1665091090561 { + constructor() { + this.name = 'addRenoteMuting1665091090561'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_renote_muting_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_renote_muting_muteeId"`); + await queryRunner.query(`DROP INDEX "IDX_renote_muting_muterId"`); + await queryRunner.query(`DROP TABLE "renote_muting"`); + } +} diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index ec56fb54f..0735ed31a 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -22,6 +22,7 @@ import { Meta } from '@/models/entities/meta.js'; import { Following } from '@/models/entities/following.js'; import { Instance } from '@/models/entities/instance.js'; import { Muting } from '@/models/entities/muting.js'; +import { RenoteMuting } from '@/models/entities/renote-muting.js'; import { SwSubscription } from '@/models/entities/sw-subscription.js'; import { Blocking } from '@/models/entities/blocking.js'; import { UserList } from '@/models/entities/user-list.js'; @@ -130,6 +131,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index fdecc278d..ad5dcb067 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -16,6 +16,7 @@ import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; import { packedFollowingSchema } from '@/models/schema/following.js'; import { packedMutingSchema } from '@/models/schema/muting.js'; +import { packedRenoteMutingSchema } from '@/models/schema/renote-muting.js'; import { packedBlockingSchema } from '@/models/schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/schema/hashtag.js'; @@ -51,6 +52,7 @@ export const refs = { DriveFolder: packedDriveFolderSchema, Following: packedFollowingSchema, Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, diff --git a/packages/backend/src/models/entities/renote-muting.ts b/packages/backend/src/models/entities/renote-muting.ts new file mode 100644 index 000000000..8e4adbfcd --- /dev/null +++ b/packages/backend/src/models/entities/renote-muting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './user.js'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.', + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.', + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index dce7af4d9..993f3e0f2 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -25,6 +25,7 @@ import { UserGroupJoining } from './entities/user-group-joining.js'; import { UserGroupInvitationRepository } from './repositories/user-group-invitation.js'; import { FollowRequestRepository } from './repositories/follow-request.js'; import { MutingRepository } from './repositories/muting.js'; +import { RenoteMutingRepository } from './repositories/renote-muting.js'; import { BlockingRepository } from './repositories/blocking.js'; import { NoteReactionRepository } from './repositories/note-reaction.js'; import { NotificationRepository } from './repositories/notification.js'; @@ -95,6 +96,7 @@ export const DriveFolders = (DriveFolderRepository); export const Notifications = (NotificationRepository); export const Metas = db.getRepository(Meta); export const Mutings = (MutingRepository); +export const RenoteMutings = (RenoteMutingRepository); export const Blockings = (BlockingRepository); export const SwSubscriptions = db.getRepository(SwSubscription); export const Hashtags = (HashtagRepository); diff --git a/packages/backend/src/models/repositories/renote-muting.ts b/packages/backend/src/models/repositories/renote-muting.ts new file mode 100644 index 000000000..bcc62482a --- /dev/null +++ b/packages/backend/src/models/repositories/renote-muting.ts @@ -0,0 +1,31 @@ +import { db } from '@/db/postgre.js'; +import { Packed } from '@/misc/schema.js'; +import { RenoteMuting } from '@/models/entities/renote-muting.js'; +import { User } from '@/models/entities/user.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Users } from '../index.js'; + +export const RenoteMutingRepository = db.getRepository(RenoteMuting).extend({ + async pack( + src: RenoteMuting['id'] | RenoteMuting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: Users.pack(muting.muteeId, me, { + detail: true, + }), + }); + }, + + packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 9bc921168..b192b6972 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -10,7 +10,7 @@ import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import { Cache } from '@/misc/cache.js'; import { db } from '@/db/postgre.js'; import { Instance } from '../entities/instance.js'; -import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; +import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; const userInstanceCache = new Cache(1000 * 60 * 60 * 3); @@ -112,6 +112,13 @@ export const UserRepository = db.getRepository(User).extend({ }, take: 1, }).then(n => n > 0), + isRenoteMuted: RenoteMutings.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), }); }, @@ -412,6 +419,7 @@ export const UserRepository = db.getRepository(User).extend({ isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/models/schema/renote-muting.ts b/packages/backend/src/models/schema/renote-muting.ts new file mode 100644 index 000000000..69ed8510d --- /dev/null +++ b/packages/backend/src/models/schema/renote-muting.ts @@ -0,0 +1,26 @@ +export const packedRenoteMutingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + muteeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + mutee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index f2cb995a8..dae8f692c 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -263,6 +263,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + isRenoteMuted: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/server/api/common/generated-muted-renote-query.ts b/packages/backend/src/server/api/common/generated-muted-renote-query.ts new file mode 100644 index 000000000..70159862a --- /dev/null +++ b/packages/backend/src/server/api/common/generated-muted-renote-query.ts @@ -0,0 +1,22 @@ +import { Brackets, SelectQueryBuilder } from 'typeorm'; +import { User } from '@/models/entities/user.js'; +import { RenoteMutings } from '@/models/index.js'; + +export function generateMutedRenotesQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = RenoteMutings.createQueryBuilder('renote_muting') + .select('renote_muting.muteeId') + .where('renote_muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb.where('note.renoteId IS NOT NULL'); + qb.andWhere('note.text IS NULL'); + qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL'); + })); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 5a70d1a4f..e7563555b 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -214,6 +214,9 @@ import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renote_mute_create from './endpoints/renote-mute/create.js'; +import * as ep___renote_mute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renote_mute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -521,6 +524,9 @@ const eps = [ ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], ['mute/list', ep___mute_list], + ['renote-mute/create', ep___renote_mute_create], + ['renote-mute/delete', ep___renote_mute_delete], + ['renote-mute/list', ep___renote_mute_list], ['my/apps', ep___my_apps], ['notes', ep___notes], ['notes/children', ep___notes_children], diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts index 95e3fca23..14d6e121d 100644 --- a/packages/backend/src/server/api/endpoints/blocking/create.ts +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -87,4 +87,6 @@ export default define(meta, paramDef, async (ps, user) => { return await Users.pack(blockee.id, blocker, { detail: true, }); + + publishUserEvent(user.id, 'block', blockee); }); diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts index 4c3234f97..53efc5cca 100644 --- a/packages/backend/src/server/api/endpoints/blocking/delete.ts +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -83,4 +83,6 @@ export default define(meta, paramDef, async (ps, user) => { return await Users.pack(blockee.id, blocker, { detail: true, }); + + publishUserEvent(user.id, 'unblock', blockee); }); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 925318f54..3d32e7c7a 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -8,6 +8,7 @@ import { generateMutedUserQuery } from '../../common/generate-muted-user-query.j import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js'; export const meta = { tags: ['notes'], @@ -79,6 +80,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedUserQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); + generateMutedRenotesQuery(query, user); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2dc98c4c9..f2e86915f 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js'; export const meta = { tags: ['notes'], @@ -93,6 +94,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedUserQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); + generateMutedRenotesQuery(query, user); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 5a495005c..6a65c028a 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js'; export const meta = { tags: ['notes'], @@ -86,6 +87,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user) generateMutedUserQuery(query, user); if (user) generateMutedNoteQuery(query, user); if (user) generateBlockedUserQuery(query, user); + if (user) generateMutedRenotesQuery(query, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 22f492517..d042e555f 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -9,6 +9,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { generateMutedRenotesQuery } from '../../common/generated-muted-renote-query.js'; export const meta = { tags: ['notes'], @@ -85,6 +86,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedUserQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); + generateMutedRenotesQuery(query, user); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 000000000..e1db204b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,79 @@ +import { genId } from '@/misc/gen-id.js'; +import { RenoteMutings } from '@/models/index.js'; +import { RenoteMuting } from '@/models/entities/renote-muting.js'; +import { publishUserEvent } from '@/services/stream.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'a4619cb2-5f23-484b-9301-94c903074e10', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: '7e7359cb-160c-4956-b08f-4d1c653cd007', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + + // Check if the mutee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await RenoteMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await RenoteMutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + + publishUserEvent(user.id, 'muteRenote', mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 000000000..bdfaae263 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,74 @@ +import { RenoteMutings } from '@/models/index.js'; +import { publishUserEvent } from '@/services/stream.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'b851d00b-8ab1-4a56-8b1b-e24187cb48ef', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '5467d020-daa9-4553-81e1-135c0c35a96d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + + // Check if the mutee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const exist = await RenoteMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await RenoteMutings.delete({ + id: exist.id, + }); + + publishUserEvent(user.id, 'unmuteRenote', mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 000000000..8d282243f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,43 @@ +import { RenoteMutings } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:mutes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'RenoteMuting', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(RenoteMutings.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await RenoteMutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index 233a6a90b..7984a8050 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -47,6 +47,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, { @@ -88,6 +92,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 05d170aa8..a68592466 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -30,6 +30,10 @@ export default abstract class Channel { return this.connection.muting; } + protected get renoteMuting() { + return this.connection.renoteMuting; + } + protected get blocking() { return this.connection.blocking; } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 2b5deeec0..6d3871b20 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -31,6 +31,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 6bcaeb1be..727bfbd1e 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -34,6 +34,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 1e163cf52..1087d4b91 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -43,6 +43,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 8eab28298..9ec7e1962 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -32,6 +32,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index e6885dabf..a7965491a 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -41,6 +41,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 3fd47173c..d17a24c70 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -49,6 +49,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 5c205318b..987ed2a32 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -40,6 +40,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index be061f68d..16690a368 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -55,6 +55,7 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && this.renoteMuting.has(note.userId)) return; this.send('note', note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 549c52a36..be67aa226 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -3,7 +3,7 @@ import * as websocket from 'websocket'; import readNote from '@/services/note/read.js'; import { User } from '@/models/entities/user.js'; import { Channel as ChannelModel } from '@/models/entities/channel.js'; -import { Followings, Mutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js'; +import { Followings, Mutings, RenoteMutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js'; import { AccessToken } from '@/models/entities/access-token.js'; import { UserProfile } from '@/models/entities/user-profile.js'; import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js'; @@ -22,6 +22,7 @@ export default class Connection { public userProfile?: UserProfile | null; public following: Set = new Set(); public muting: Set = new Set(); + public renoteMuting: Set = new Set(); public blocking: Set = new Set(); // "被"blocking public followingChannels: Set = new Set(); public token?: AccessToken; @@ -56,6 +57,7 @@ export default class Connection { if (this.user) { this.updateFollowing(); this.updateMuting(); + this.updateRenoteMuting(); this.updateBlocking(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -82,7 +84,21 @@ export default class Connection { this.muting.delete(data.body.id); break; - // TODO: block events + case 'block': + this.blocking.add(data.body.id); + break; + + case 'unblock': + this.blocking.delete(data.body.id); + break; + + case 'muteRenote': + this.renoteMuting.add(data.body.id); + break; + + case 'unmuteRenote': + this.renoteMuting.delete(data.body.id); + break; case 'followChannel': this.followingChannels.add(data.body.id); @@ -333,6 +349,17 @@ export default class Connection { this.muting = new Set(mutings.map(x => x.muteeId)); } + private async updateRenoteMuting() { + const renoteMutings = await RenoteMutings.find({ + where: { + muterId: this.user!.id, + }, + select: ['muteeId'], + }); + + this.renoteMuting = new Set(renoteMutings.map(x => x.muteeId)); + } + private async updateBlocking() { // ここでいうBlockingは被Blockingの意 const blockings = await Blockings.find({ where: { diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index b969535ca..fa84a97ac 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -44,6 +44,10 @@ export interface UserStreamTypes { updateUserProfile: UserProfile; mute: User; unmute: User; + muteRenote: User; + unmuteRenote: User; + block: User; + unblock: User; follow: Packed<'UserDetailedNotMe'>; unfollow: Packed<'User'>; userAdded: Packed<'User'>; diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts index 3721c5711..b7072ed25 100644 --- a/packages/client/src/scripts/get-user-menu.ts +++ b/packages/client/src/scripts/get-user-menu.ts @@ -98,6 +98,14 @@ export function getUserMenu(user) { } } + async function toggleRenoteMute(): Promise { + os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', { + userId: user.id, + }).then(() => { + user.isRenoteMuted = !user.isRenoteMuted; + }); + } + async function toggleBlock(): Promise { if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; @@ -187,6 +195,10 @@ export function getUserMenu(user) { if ($i && meId !== user.id) { menu = menu.concat([null, { + icon: user.isRenoteMuted ? 'fas fa-eye' : 'fas fa-eye-slash', + text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute, + action: toggleRenoteMute, + }, { icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, diff --git a/packages/foundkey-js/src/api.types.ts b/packages/foundkey-js/src/api.types.ts index df1c98879..9d26c993f 100644 --- a/packages/foundkey-js/src/api.types.ts +++ b/packages/foundkey-js/src/api.types.ts @@ -411,6 +411,9 @@ export type Endpoints = { 'mute/create': { req: TODO; res: TODO; }; 'mute/delete': { req: { userId: User['id'] }; res: null; }; 'mute/list': { req: TODO; res: TODO; }; + 'renote-mute/create': { req: TODO; res: TODO; }; + 'renote-mute/delete': { req: { userId: User['id'] }; res: null; }; + 'renote-mute/list': { req: TODO; res: TODO; }; 'my/apps': { req: TODO; res: TODO; }; 'notes': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; 'notes/children': { req: { noteId: Note['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; diff --git a/packages/foundkey-js/src/entities.ts b/packages/foundkey-js/src/entities.ts index b641103b6..e75974107 100644 --- a/packages/foundkey-js/src/entities.ts +++ b/packages/foundkey-js/src/entities.ts @@ -53,6 +53,7 @@ export type UserDetailed = UserLite & { isLocked: boolean; isModerator: boolean; isMuted: boolean; + isRenoteMuted: boolean; isSilenced: boolean; isSuspended: boolean; lang: string | null;