wip
This commit is contained in:
		
							parent
							
								
									6f17993cba
								
							
						
					
					
						commit
						35e743c955
					
				|  | @ -21,6 +21,7 @@ | |||
| - API: notes/global-timeline は現在常に `[]` を返します | ||||
| 
 | ||||
| ### General | ||||
| - ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました | ||||
| - ソフトワードミュートとハードワードミュートは統合されました | ||||
| 
 | ||||
| ### Server | ||||
|  |  | |||
|  | @ -1129,6 +1129,8 @@ export interface Locale { | |||
|     "notificationRecieveConfig": string; | ||||
|     "mutualFollow": string; | ||||
|     "fileAttachedOnly": string; | ||||
|     "showRepliesToOthersInTimeline": string; | ||||
|     "hideRepliesToOthersInTimeline": string; | ||||
|     "_announcement": { | ||||
|         "forExistingUsers": string; | ||||
|         "forExistingUsersDescription": string; | ||||
|  |  | |||
|  | @ -1126,6 +1126,8 @@ edited: "編集済み" | |||
| notificationRecieveConfig: "通知の受信設定" | ||||
| mutualFollow: "相互フォロー" | ||||
| fileAttachedOnly: "ファイル付きのみ" | ||||
| showRepliesToOthersInTimeline: "TLに他の人への返信を含める" | ||||
| hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" | ||||
| 
 | ||||
| _announcement: | ||||
|   forExistingUsers: "既存ユーザーのみ" | ||||
|  |  | |||
|  | @ -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"`); | ||||
|     } | ||||
| } | ||||
|  | @ -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<Set<string>>; | ||||
| 	public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
 | ||||
| 	public renoteMutingsCache: RedisKVCache<Set<string>>; | ||||
| 	public userFollowingsCache: RedisKVCache<Set<string>>; | ||||
| 	public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>; | ||||
| 	public userFollowingChannelsCache: RedisKVCache<Set<string>>; | ||||
| 
 | ||||
| 	constructor( | ||||
|  | @ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown { | |||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
| 
 | ||||
| 		this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', { | ||||
| 		this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | 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<string, Pick<MiFollowing, 'withReplies'> | 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<Set<string>>(this.redisClient, 'userFollowingChannels', { | ||||
|  |  | |||
|  | @ -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); | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -44,4 +44,10 @@ export class MiUserListJoining { | |||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public userList: MiUserList | null; | ||||
| 
 | ||||
| 	// タイムラインにその人のリプライまで含めるかどうか
 | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public withReplies: boolean; | ||||
| } | ||||
|  |  | |||
|  | @ -277,6 +277,10 @@ export const packedUserDetailedNotMeOnlySchema = { | |||
| 			type: 'string', | ||||
| 			nullable: false, optional: true, | ||||
| 		}, | ||||
| 		withReplies: { | ||||
| 			type: 'boolean', | ||||
| 			nullable: false, optional: true, | ||||
| 		}, | ||||
| 		//#endregion
 | ||||
| 	}, | ||||
| } as const; | ||||
|  |  | |||
|  | @ -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<typeof meta, typeof paramDef> { // 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); | ||||
|  |  | |||
|  | @ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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 }) | ||||
|  |  | |||
|  | @ -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<string> = new Set(); | ||||
| 	public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {}; | ||||
| 	public followingChannels: Set<string> = new Set(); | ||||
| 	public userIdsWhoMeMuting: Set<string> = new Set(); | ||||
| 	public userIdsWhoBlockingMe: Set<string> = new Set(); | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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<string>(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; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -133,6 +133,7 @@ describe('ユーザー', () => { | |||
| 			isMuted: user.isMuted ?? false, | ||||
| 			isRenoteMuted: user.isRenoteMuted ?? false, | ||||
| 			notify: user.notify ?? 'none', | ||||
| 			withReplies: user.withReplies ?? false, | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
| 	}); | ||||
|  |  | |||
|  | @ -15,11 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 			<div :class="$style.tl"> | ||||
| 				<MkTimeline | ||||
| 					ref="tlComponent" | ||||
| 					:key="src + withRenotes + withReplies + onlyFiles" | ||||
| 					:key="src + withRenotes + onlyFiles" | ||||
| 					:src="src.split(':')[0]" | ||||
| 					:list="src.split(':')[1]" | ||||
| 					:withRenotes="withRenotes" | ||||
| 					:withReplies="withReplies" | ||||
| 					:onlyFiles="onlyFiles" | ||||
| 					:sound="true" | ||||
| 					@queue="queueUpdated" | ||||
|  | @ -62,7 +61,6 @@ let queue = $ref(0); | |||
| let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); | ||||
| const src = $computed({ get: () => ($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', | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -31,7 +31,6 @@ export type Column = { | |||
| 	excludeTypes?: typeof notificationTypes[number][]; | ||||
| 	tl?: 'home' | 'local' | 'social' | 'global'; | ||||
| 	withRenotes?: boolean; | ||||
| 	withReplies?: boolean; | ||||
| 	onlyFiles?: boolean; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -23,10 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 	<MkTimeline | ||||
| 		v-else-if="column.tl" | ||||
| 		ref="timeline" | ||||
| 		:key="column.tl + withRenotes + withReplies + onlyFiles" | ||||
| 		:key="column.tl + withRenotes + onlyFiles" | ||||
| 		:src="column.tl" | ||||
| 		:withRenotes="withRenotes" | ||||
| 		:withReplies="withReplies" | ||||
| 		:onlyFiles="onlyFiles" | ||||
| 	/> | ||||
| </XColumn> | ||||
|  | @ -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), | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue