From 35e743c9550458a847cf3e7910c1cb1a6612192a Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 2 Oct 2023 15:37:09 +0900 Subject: [PATCH] wip --- CHANGELOG.md | 1 + locales/index.d.ts | 2 + locales/ja-JP.yml | 2 + .../migration/1696222183852-withReplies.js | 20 +++++ packages/backend/src/core/CacheService.ts | 18 +++-- .../backend/src/core/NoteCreateService.ts | 77 +++++++++++-------- .../backend/src/core/NotificationService.ts | 8 +- .../src/core/entities/UserEntityService.ts | 1 + packages/backend/src/models/Following.ts | 7 ++ .../backend/src/models/UserListJoining.ts | 6 ++ .../backend/src/models/json-schema/user.ts | 4 + .../server/api/endpoints/following/update.ts | 6 +- .../src/server/api/endpoints/users/notes.ts | 2 +- .../src/server/api/stream/Connection.ts | 4 +- .../api/stream/channels/global-timeline.ts | 4 +- .../api/stream/channels/home-timeline.ts | 6 +- .../api/stream/channels/hybrid-timeline.ts | 6 +- .../api/stream/channels/local-timeline.ts | 4 +- packages/backend/test/e2e/users.ts | 1 + .../frontend/src/components/MkTimeline.vue | 12 --- packages/frontend/src/pages/timeline.vue | 11 +-- .../frontend/src/scripts/get-user-menu.ts | 13 ++++ packages/frontend/src/ui/deck/deck-store.ts | 1 - packages/frontend/src/ui/deck/tl-column.vue | 16 +--- 24 files changed, 135 insertions(+), 97 deletions(-) create mode 100644 packages/backend/migration/1696222183852-withReplies.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a0083922..ceb7234866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - API: notes/global-timeline は現在常に `[]` を返します ### General +- ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました - ソフトワードミュートとハードワードミュートは統合されました ### Server diff --git a/locales/index.d.ts b/locales/index.d.ts index 62db216953..418e1c67ff 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1129,6 +1129,8 @@ export interface Locale { "notificationRecieveConfig": string; "mutualFollow": string; "fileAttachedOnly": string; + "showRepliesToOthersInTimeline": string; + "hideRepliesToOthersInTimeline": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a6e9d9458e..80e4466a74 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1126,6 +1126,8 @@ edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" fileAttachedOnly: "ファイル付きのみ" +showRepliesToOthersInTimeline: "TLに他の人への返信を含める" +hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/backend/migration/1696222183852-withReplies.js b/packages/backend/migration/1696222183852-withReplies.js new file mode 100644 index 0000000000..9f65d5f6a1 --- /dev/null +++ b/packages/backend/migration/1696222183852-withReplies.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class WithReplies1696222183852 { + name = 'WithReplies1696222183852' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`); + await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`); + await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`); + } +} diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 561979c4bf..8191f611b0 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: RedisKVCache>; public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache>; - public userFollowingsCache: RedisKVCache>; + public userFollowingsCache: RedisKVCache | undefined>>; public userFollowingChannelsCache: RedisKVCache>; constructor( @@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.userFollowingsCache = new RedisKVCache>(this.redisClient, 'userFollowings', { + this.userFollowingsCache = new RedisKVCache | undefined>>(this.redisClient, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { + const obj: Record | undefined> = {}; + for (const x of xs) { + obj[x.followeeId] = { withReplies: x.withReplies }; + } + return obj; + }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), }); this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index f8342c0986..6fa9c9cf57 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -805,15 +805,7 @@ export class NoteCreateService implements OnApplicationShutdown { private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { const redisPipeline = this.redisClient.pipeline(); - if (note.replyId && note.replyUserId !== note.userId) { - if (note.visibility === 'public' || note.visibility === 'home') { - redisPipeline.xadd( - `userTimelineWithReplies:${user.id}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - } - } else if (note.channelId) { + if (note.channelId) { const channelFollowings = await this.channelFollowingsRepository.find({ where: { followeeId: note.channelId, @@ -845,7 +837,7 @@ export class NoteCreateService implements OnApplicationShutdown { followeeId: user.id, followerHost: IsNull(), }, - select: ['followerId'], + select: ['followerId', 'withReplies'], }); let userLists = await this.userListJoiningsRepository.find({ @@ -857,6 +849,11 @@ export class NoteCreateService implements OnApplicationShutdown { // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする for (const following of followings) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!following.withReplies) continue; + } + redisPipeline.xadd( `homeTimeline:${following.followerId}`, 'MAXLEN', '~', '200', @@ -878,6 +875,11 @@ export class NoteCreateService implements OnApplicationShutdown { } for (const userList of userLists) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!userList.withReplies) continue; + } + redisPipeline.xadd( `userListTimeline:${userList.userListId}`, 'MAXLEN', '~', '200', @@ -893,49 +895,60 @@ export class NoteCreateService implements OnApplicationShutdown { } } - redisPipeline.xadd( - `homeTimeline:${user.id}`, - 'MAXLEN', '~', '200', - '*', - 'note', note.id); - - if (note.fileIds.length > 0) { + { // 自分自身のHTL redisPipeline.xadd( - `homeTimelineWithFiles:${user.id}`, - 'MAXLEN', '~', '100', - '*', - 'note', note.id); - } - - if (note.visibility === 'public' || note.visibility === 'home') { - redisPipeline.xadd( - `userTimeline:${user.id}`, + `homeTimeline:${user.id}`, 'MAXLEN', '~', '200', '*', 'note', note.id); if (note.fileIds.length > 0) { redisPipeline.xadd( - `userTimelineWithFiles:${user.id}`, + `homeTimelineWithFiles:${user.id}`, 'MAXLEN', '~', '100', '*', 'note', note.id); } + } - if (note.userHost == null) { + if (note.visibility === 'public' || note.visibility === 'home') { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { redisPipeline.xadd( - 'localTimeline', - 'MAXLEN', '~', '1000', + `userTimelineWithReplies:${user.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + } else { + redisPipeline.xadd( + `userTimeline:${user.id}`, + 'MAXLEN', '~', '200', '*', 'note', note.id); if (note.fileIds.length > 0) { redisPipeline.xadd( - 'localTimelineWithFiles', - 'MAXLEN', '~', '500', + `userTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '100', '*', 'note', note.id); } + + if (note.userHost == null) { + redisPipeline.xadd( + 'localTimeline', + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + 'localTimelineWithFiles', + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + } } } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index ca05989a4a..32d54d2576 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -99,19 +99,19 @@ export class NotificationService implements OnApplicationShutdown { } if (recieveConfig?.type === 'following') { - const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); if (!isFollowing) { return null; } } else if (recieveConfig?.type === 'follower') { - const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)); + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); if (!isFollower) { return null; } } else if (recieveConfig?.type === 'mutualFollow') { const [isFollowing, isFollower] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), - this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), ]); if (!isFollowing && !isFollower) { return null; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index a47b3d51ac..171dda8fdc 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -487,6 +487,7 @@ export class UserEntityService implements OnModuleInit { isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, notify: relation.following?.notify ?? 'none', + withReplies: relation.following?.withReplies ?? false, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 8c9f965fad..1fbcd695b8 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -9,6 +9,7 @@ import { MiUser } from './User.js'; @Entity('following') @Index(['followerId', 'followeeId'], { unique: true }) +@Index(['followeeId', 'followerHost']) export class MiFollowing { @PrimaryColumn(id()) public id: string; @@ -45,6 +46,12 @@ export class MiFollowing { @JoinColumn() public follower: MiUser | null; + // タイムラインにその人のリプライまで含めるかどうか + @Column('boolean', { + default: false, + }) + public withReplies: boolean; + @Index() @Column('varchar', { length: 32, diff --git a/packages/backend/src/models/UserListJoining.ts b/packages/backend/src/models/UserListJoining.ts index 4918f2f700..bb5eb311e3 100644 --- a/packages/backend/src/models/UserListJoining.ts +++ b/packages/backend/src/models/UserListJoining.ts @@ -44,4 +44,10 @@ export class MiUserListJoining { }) @JoinColumn() public userList: MiUserList | null; + + // タイムラインにその人のリプライまで含めるかどうか + @Column('boolean', { + default: false, + }) + public withReplies: boolean; } diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 0181ea50e8..57d2d976ff 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -277,6 +277,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: false, optional: true, }, + withReplies: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts index 25f393e517..db17d151df 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -57,8 +57,9 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, notify: { type: 'string', enum: ['normal', 'none'] }, + withReplies: { type: 'boolean' }, }, - required: ['userId', 'notify'], + required: ['userId'], } as const; @Injectable() @@ -98,7 +99,8 @@ export default class extends Endpoint { // eslint- await this.followingsRepository.update({ id: exist.id, }, { - notify: ps.notify === 'none' ? null : ps.notify, + notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, + withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); return await this.userEntityService.pack(follower.id, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 6d8a2386d7..615e3e8c73 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -91,7 +91,7 @@ export default class extends Endpoint { // eslint- return []; } - const isFollowing = me ? (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId) : false; + const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index a73071ea99..f981e63871 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -11,7 +11,7 @@ import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; -import { MiUserProfile } from '@/models/_.js'; +import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; @@ -30,7 +30,7 @@ export default class Connection { private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; public userProfile: MiUserProfile | null = null; - public following: Set = new Set(); + public following: Record | undefined> = {}; public followingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); 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 4bdf607336..f0ac50349c 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -18,7 +18,6 @@ class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = true; public static requireCredential = false; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -38,7 +37,6 @@ class GlobalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.gtlAvailable) return; - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -64,7 +62,7 @@ class GlobalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; 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 cea0c781e2..1c1b1c2ae4 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -16,7 +16,6 @@ class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = true; public static requireCredential = true; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -31,7 +30,6 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; this.subscriber.on('notesStream', this.onNote); @@ -43,7 +41,7 @@ class HomeTimelineChannel extends Channel { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; + if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return; } // Ignore notes from instances the user has muted @@ -73,7 +71,7 @@ class HomeTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; 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 0ad26ecee3..e2f4817bfa 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -18,7 +18,6 @@ class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = true; public static requireCredential = true; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -38,7 +37,6 @@ class HybridTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -53,7 +51,7 @@ class HybridTimelineChannel extends Channel { // フォローしているチャンネルの投稿 の場合だけ if (!( (note.channelId == null && this.user!.id === note.userId) || - (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && Object.hasOwn(this.following, note.userId)) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; @@ -85,7 +83,7 @@ class HybridTimelineChannel extends Channel { if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; 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 641cd0932b..ca563b5d19 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -17,7 +17,6 @@ class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; public static shouldShare = true; public static requireCredential = false; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -37,7 +36,6 @@ class LocalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -64,7 +62,7 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && this.user && !this.withReplies) { + if (note.reply && this.user && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 0f5d5f7344..53db1ac28a 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -133,6 +133,7 @@ describe('ユーザー', () => { isMuted: user.isMuted ?? false, isRenoteMuted: user.isRenoteMuted ?? false, notify: user.notify ?? 'none', + withReplies: user.withReplies ?? false, }); }; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 1dcafd6be1..c4a34667ef 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -23,11 +23,9 @@ const props = withDefaults(defineProps<{ role?: string; sound?: boolean; withRenotes?: boolean; - withReplies?: boolean; onlyFiles?: boolean; }>(), { withRenotes: true, - withReplies: false, onlyFiles: false, }); @@ -70,12 +68,10 @@ if (props.src === 'antenna') { endpoint = 'notes/timeline'; query = { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('homeTimeline', { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -85,12 +81,10 @@ if (props.src === 'antenna') { endpoint = 'notes/local-timeline'; query = { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('localTimeline', { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -98,12 +92,10 @@ if (props.src === 'antenna') { endpoint = 'notes/hybrid-timeline'; query = { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('hybridTimeline', { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -111,12 +103,10 @@ if (props.src === 'antenna') { endpoint = 'notes/global-timeline'; query = { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }; connection = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); connection.on('note', prepend); @@ -140,13 +130,11 @@ if (props.src === 'antenna') { endpoint = 'notes/user-list-timeline'; query = { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }; connection = stream.useChannel('userList', { withRenotes: props.withRenotes, - withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 9cafdc3dd7..b8deb77952 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -15,11 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); const withRenotes = $ref(true); -const withReplies = $ref(false); const onlyFiles = $ref(false); watch($$(src), () => queue = 0); @@ -144,12 +142,7 @@ const headerActions = $computed(() => [{ text: i18n.ts.showRenotes, icon: 'ti ti-repeat', ref: $$(withRenotes), - }, /*{ - type: 'switch', - text: i18n.ts.withReplies, - icon: 'ti ti-arrow-back-up', - ref: $$(withReplies), - },*/ { + }, { type: 'switch', text: i18n.ts.fileAttachedOnly, icon: 'ti ti-photo', diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 128cbafb15..be514be5b1 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -80,6 +80,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }); } + async function toggleWithReplies() { + os.apiWithDialog('following/update', { + userId: user.id, + withReplies: !user.withReplies, + }).then(() => { + user.withReplies = !user.withReplies; + }); + } + async function toggleNotify() { os.apiWithDialog('following/update', { userId: user.id, @@ -282,6 +291,10 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため //if (user.isFollowing) { menu = menu.concat([{ + icon: user.withReplies ? 'ti ti-messages-off' : 'ti ti-messages', + text: user.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, + action: toggleWithReplies, + }, { icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off', text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, action: toggleNotify, diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 49fdf4d314..b2a44ac96b 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -31,7 +31,6 @@ export type Column = { excludeTypes?: typeof notificationTypes[number][]; tl?: 'home' | 'local' | 'social' | 'global'; withRenotes?: boolean; - withReplies?: boolean; onlyFiles?: boolean; }; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index d80d8f3d90..847752247e 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -23,10 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -52,7 +51,6 @@ let disabled = $ref(false); const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); const withRenotes = $ref(props.column.withRenotes ?? true); -const withReplies = $ref(props.column.withReplies ?? false); const onlyFiles = $ref(props.column.onlyFiles ?? false); watch($$(withRenotes), v => { @@ -61,12 +59,6 @@ watch($$(withRenotes), v => { }); }); -watch($$(withReplies), v => { - updateColumn(props.column.id, { - withReplies: v, - }); -}); - watch($$(onlyFiles), v => { updateColumn(props.column.id, { onlyFiles: v, @@ -115,11 +107,7 @@ const menu = [{ type: 'switch', text: i18n.ts.showRenotes, ref: $$(withRenotes), -}, /*{ - type: 'switch', - text: i18n.ts.withReplies, - ref: $$(withReplies), -},*/ { +}, { type: 'switch', text: i18n.ts.fileAttachedOnly, ref: $$(onlyFiles),