Merge remote-tracking branch 'misskey-original/develop' into develop
# Conflicts: # package.json # packages/backend/src/server/api/stream/channels/hybrid-timeline.ts # packages/frontend/package.json
This commit is contained in:
		
						commit
						cc5037cb85
					
				|  | @ -18,15 +18,17 @@ | |||
| - Feat: アイコンデコレーション機能 | ||||
| - Enhance: すでにフォローしたすべての人の返信をTLに追加できるように | ||||
| 
 | ||||
| ## Client | ||||
| ### Client | ||||
| - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました | ||||
| 	- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください | ||||
| 	  https://misskey-hub.net/docs/advanced/publish-on-your-website.html | ||||
| - Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 | ||||
| 
 | ||||
| ### Server | ||||
| - Enhance: RedisへのTLのキャッシュをオフにできるように | ||||
| - Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 | ||||
| - Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 | ||||
| - Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正 | ||||
| 
 | ||||
| ## 2023.10.2 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1224,6 +1224,7 @@ export interface Locale { | |||
|         "manifestJsonOverride": string; | ||||
|         "shortName": string; | ||||
|         "shortNameDescription": string; | ||||
|         "fanoutTimelineDescription": string; | ||||
|     }; | ||||
|     "_accountMigration": { | ||||
|         "moveFrom": string; | ||||
|  |  | |||
|  | @ -1222,6 +1222,7 @@ _serverSettings: | |||
|   manifestJsonOverride: "manifest.jsonのオーバーライド" | ||||
|   shortName: "略称" | ||||
|   shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。" | ||||
|   fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" | ||||
| 
 | ||||
| _accountMigration: | ||||
|   moveFrom: "別のアカウントからこのアカウントに移行" | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| { | ||||
| 	"name": "misskey", | ||||
| 	"version": "2023.11.0-beta.2-prismisskey.1", | ||||
| 	"version": "2023.11.0-beta.3", | ||||
| 	"codename": "nasubi", | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
|  | @ -18,11 +18,11 @@ | |||
| 		"build-assets": "node ./scripts/build-assets.mjs", | ||||
| 		"build": "pnpm build-pre && pnpm -r build && pnpm build-assets", | ||||
| 		"build-storybook": "pnpm --filter frontend build-storybook", | ||||
| 		"build-and-start": "cross-env NODE_ENV=development pnpm build && cross-env NODE_ENV=development pnpm start", | ||||
| 		"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", | ||||
| 		"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", | ||||
| 		"init": "pnpm migrate", | ||||
| 		"migrate": "cd packages/backend && pnpm migrate", | ||||
| 		"revert": "cd packages/backend && pnpm revert", | ||||
| 		"check:connect": "cd packages/backend && pnpm check:connect", | ||||
| 		"migrateandstart": "pnpm migrate && pnpm start", | ||||
| 		"watch": "pnpm dev", | ||||
|  | @ -56,7 +56,7 @@ | |||
| 		"@typescript-eslint/parser": "6.8.0", | ||||
| 		"cross-env": "7.0.3", | ||||
| 		"cypress": "13.3.2", | ||||
| 		"eslint": "8.51.0", | ||||
| 		"eslint": "8.52.0", | ||||
| 		"start-server-and-test": "2.0.1" | ||||
| 	}, | ||||
| 	"optionalDependencies": { | ||||
|  |  | |||
|  | @ -0,0 +1,16 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| export class EnableFtt1698041201306 { | ||||
|     name = 'EnableFtt1698041201306' | ||||
| 
 | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "enableFanoutTimeline" boolean NOT NULL DEFAULT true`); | ||||
|     } | ||||
| 
 | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFanoutTimeline"`); | ||||
|     } | ||||
| } | ||||
|  | @ -10,6 +10,7 @@ | |||
| 		"start": "node ./built/index.js", | ||||
| 		"start:test": "NODE_ENV=test node ./built/index.js", | ||||
| 		"migrate": "pnpm typeorm migration:run -d ormconfig.js", | ||||
| 		"revert": "pnpm typeorm migration:revert -d ormconfig.js", | ||||
| 		"check:connect": "node ./check_connect.js", | ||||
| 		"build": "swc src -d built -D", | ||||
| 		"watch:swc": "swc src -d built -D -w", | ||||
|  | @ -76,9 +77,9 @@ | |||
| 		"@nestjs/testing": "10.2.7", | ||||
| 		"@peertube/http-signature": "1.7.0", | ||||
| 		"@simplewebauthn/server": "8.3.2", | ||||
| 		"@sinonjs/fake-timers": "11.2.1", | ||||
| 		"@sinonjs/fake-timers": "11.2.2", | ||||
| 		"@swc/cli": "0.1.62", | ||||
| 		"@swc/core": "1.3.93", | ||||
| 		"@swc/core": "1.3.94", | ||||
| 		"accepts": "1.3.8", | ||||
| 		"ajv": "8.12.0", | ||||
| 		"archiver": "6.0.1", | ||||
|  | @ -124,7 +125,7 @@ | |||
| 		"nanoid": "5.0.2", | ||||
| 		"nested-property": "4.0.0", | ||||
| 		"node-fetch": "3.3.2", | ||||
| 		"nodemailer": "6.9.6", | ||||
| 		"nodemailer": "6.9.7", | ||||
| 		"nsfwjs": "2.4.2", | ||||
| 		"oauth": "0.10.0", | ||||
| 		"oauth2orize": "1.12.0", | ||||
|  | @ -142,7 +143,7 @@ | |||
| 		"qrcode": "1.5.3", | ||||
| 		"random-seed": "0.3.0", | ||||
| 		"ratelimiter": "3.4.1", | ||||
| 		"re2": "1.20.3", | ||||
| 		"re2": "1.20.4", | ||||
| 		"redis-lock": "0.1.4", | ||||
| 		"reflect-metadata": "0.1.13", | ||||
| 		"rename": "1.0.4", | ||||
|  | @ -155,7 +156,7 @@ | |||
| 		"strict-event-emitter-types": "2.0.0", | ||||
| 		"stringz": "2.1.0", | ||||
| 		"summaly": "github:misskey-dev/summaly", | ||||
| 		"systeminformation": "5.21.12", | ||||
| 		"systeminformation": "5.21.13", | ||||
| 		"tinycolor2": "1.6.0", | ||||
| 		"tmp": "0.2.1", | ||||
| 		"tsc-alias": "1.8.8", | ||||
|  | @ -216,7 +217,7 @@ | |||
| 		"@typescript-eslint/parser": "6.8.0", | ||||
| 		"aws-sdk-client-mock": "3.0.0", | ||||
| 		"cross-env": "7.0.3", | ||||
| 		"eslint": "8.51.0", | ||||
| 		"eslint": "8.52.0", | ||||
| 		"eslint-plugin-import": "2.28.1", | ||||
| 		"execa": "8.0.1", | ||||
| 		"jest": "29.7.0", | ||||
|  |  | |||
|  | @ -77,7 +77,13 @@ export interface MainEventTypes { | |||
| 	unreadAntenna: MiAntenna; | ||||
| 	readAllAnnouncements: undefined; | ||||
| 	myTokenRegenerated: undefined; | ||||
| 	signin: MiSignin; | ||||
| 	signin: { | ||||
| 		id: MiSignin['id']; | ||||
| 		createdAt: string; | ||||
| 		ip: string; | ||||
| 		headers: Record<string, any>; | ||||
| 		success: boolean; | ||||
| 	}; | ||||
| 	registryUpdated: { | ||||
| 		scope?: string[]; | ||||
| 		key: string; | ||||
|  |  | |||
|  | @ -56,6 +56,7 @@ import { SearchService } from '@/core/SearchService.js'; | |||
| import { FeaturedService } from '@/core/FeaturedService.js'; | ||||
| import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { UserBlockingService } from '@/core/UserBlockingService.js'; | ||||
| 
 | ||||
| type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; | ||||
| 
 | ||||
|  | @ -216,6 +217,7 @@ export class NoteCreateService implements OnApplicationShutdown { | |||
| 		private activeUsersChart: ActiveUsersChart, | ||||
| 		private instanceChart: InstanceChart, | ||||
| 		private utilityService: UtilityService, | ||||
| 		private userBlockingService: UserBlockingService, | ||||
| 	) { } | ||||
| 
 | ||||
| 	@bindThis | ||||
|  | @ -292,6 +294,18 @@ export class NoteCreateService implements OnApplicationShutdown { | |||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// Check blocking
 | ||||
| 		if (data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)) { | ||||
| 			if (data.renote.userHost === null) { | ||||
| 				if (data.renote.userId !== user.id) { | ||||
| 					const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); | ||||
| 					if (blocked) { | ||||
| 						throw new Error('blocked'); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// 返信対象がpublicではないならhomeにする
 | ||||
| 		if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { | ||||
| 			data.visibility = 'home'; | ||||
|  | @ -825,6 +839,7 @@ export class NoteCreateService implements OnApplicationShutdown { | |||
| 	@bindThis | ||||
| 	private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { | ||||
| 		const meta = await this.metaService.fetch(); | ||||
| 		if (!meta.enableFanoutTimeline) return; | ||||
| 
 | ||||
| 		const r = this.redisForTimelines.pipeline(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -40,7 +40,7 @@ export class QueryService { | |||
| 	) { | ||||
| 	} | ||||
| 
 | ||||
| 	public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder<T> { | ||||
| 	public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> { | ||||
| 		if (sinceId && untilId) { | ||||
| 			q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); | ||||
| 			q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); | ||||
|  |  | |||
|  | @ -7,10 +7,12 @@ import { Injectable } from '@nestjs/common'; | |||
| import type { } from '@/models/Blocking.js'; | ||||
| import type { MiSignin } from '@/models/Signin.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class SigninEntityService { | ||||
| 	constructor( | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 	} | ||||
| 
 | ||||
|  | @ -18,7 +20,13 @@ export class SigninEntityService { | |||
| 	public async pack( | ||||
| 		src: MiSignin, | ||||
| 	) { | ||||
| 		return src; | ||||
| 		return { | ||||
| 			id: src.id, | ||||
| 			createdAt: this.idService.parse(src.id).date.toISOString(), | ||||
| 			ip: src.ip, | ||||
| 			headers: src.headers, | ||||
| 			success: src.success, | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -504,6 +504,11 @@ export class MiMeta { | |||
| 	}) | ||||
| 	public preservedUsernames: string[]; | ||||
| 
 | ||||
| 	@Column('boolean', { | ||||
| 		default: true, | ||||
| 	}) | ||||
| 	public enableFanoutTimeline: boolean; | ||||
| 
 | ||||
| 	@Column('integer', { | ||||
| 		default: 300, | ||||
| 	}) | ||||
|  |  | |||
|  | @ -106,11 +106,11 @@ export const meta = { | |||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			silencedHosts: { | ||||
| 				type: "array", | ||||
| 				type: 'array', | ||||
| 				optional: true, | ||||
| 				nullable: false, | ||||
| 				items: { | ||||
| 					type: "string", | ||||
| 					type: 'string', | ||||
| 					optional: false, | ||||
| 					nullable: false, | ||||
| 				}, | ||||
|  | @ -291,6 +291,10 @@ export const meta = { | |||
| 				type: 'object', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			enableFanoutTimeline: { | ||||
| 				type: 'boolean', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			perLocalUserUserTimelineCacheMax: { | ||||
| 				type: 'number', | ||||
| 				optional: false, nullable: false, | ||||
|  | @ -423,6 +427,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				enableIdenticonGeneration: instance.enableIdenticonGeneration, | ||||
| 				policies: { ...DEFAULT_POLICIES, ...instance.policies }, | ||||
| 				manifestJsonOverride: instance.manifestJsonOverride, | ||||
| 				enableFanoutTimeline: instance.enableFanoutTimeline, | ||||
| 				perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, | ||||
| 				perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, | ||||
| 				perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, | ||||
|  |  | |||
|  | @ -121,6 +121,7 @@ export const paramDef = { | |||
| 		serverRules: { type: 'array', items: { type: 'string' } }, | ||||
| 		preservedUsernames: { type: 'array', items: { type: 'string' } }, | ||||
| 		manifestJsonOverride: { type: 'string' }, | ||||
| 		enableFanoutTimeline: { type: 'boolean' }, | ||||
| 		perLocalUserUserTimelineCacheMax: { type: 'integer' }, | ||||
| 		perRemoteUserUserTimelineCacheMax: { type: 'integer' }, | ||||
| 		perUserHomeTimelineCacheMax: { type: 'integer' }, | ||||
|  | @ -491,6 +492,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				set.manifestJsonOverride = ps.manifestJsonOverride; | ||||
| 			} | ||||
| 
 | ||||
| 			if (ps.enableFanoutTimeline !== undefined) { | ||||
| 				set.enableFanoutTimeline = ps.enableFanoutTimeline; | ||||
| 			} | ||||
| 
 | ||||
| 			if (ps.perLocalUserUserTimelineCacheMax !== undefined) { | ||||
| 				set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; | ||||
| 			} | ||||
|  |  | |||
|  | @ -17,6 +17,8 @@ import { CacheService } from '@/core/CacheService.js'; | |||
| import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { MiLocalUser } from '@/models/User.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
|  | @ -75,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private funoutTimelineService: FunoutTimelineService, | ||||
| 		private queryService: QueryService, | ||||
| 		private userFollowingService: UserFollowingService, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); | ||||
|  | @ -85,163 +88,200 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				throw new ApiError(meta.errors.stlDisabled); | ||||
| 			} | ||||
| 
 | ||||
| 			const [ | ||||
| 				userIdsWhoMeMuting, | ||||
| 				userIdsWhoMeMutingRenotes, | ||||
| 				userIdsWhoBlockingMe, | ||||
| 			] = await Promise.all([ | ||||
| 				this.cacheService.userMutingsCache.fetch(me.id), | ||||
| 				this.cacheService.renoteMutingsCache.fetch(me.id), | ||||
| 				this.cacheService.userBlockedCache.fetch(me.id), | ||||
| 			]); | ||||
| 			const serverSettings = await this.metaService.fetch(); | ||||
| 
 | ||||
| 			let noteIds: string[]; | ||||
| 			let shouldFallbackToDb = false; | ||||
| 			if (serverSettings.enableFanoutTimeline) { | ||||
| 				const [ | ||||
| 					userIdsWhoMeMuting, | ||||
| 					userIdsWhoMeMutingRenotes, | ||||
| 					userIdsWhoBlockingMe, | ||||
| 				] = await Promise.all([ | ||||
| 					this.cacheService.userMutingsCache.fetch(me.id), | ||||
| 					this.cacheService.renoteMutingsCache.fetch(me.id), | ||||
| 					this.cacheService.userBlockedCache.fetch(me.id), | ||||
| 				]); | ||||
| 
 | ||||
| 			if (ps.withFiles) { | ||||
| 				const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 					`homeTimelineWithFiles:${me.id}`, | ||||
| 					'localTimelineWithFiles', | ||||
| 				], untilId, sinceId); | ||||
| 				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); | ||||
| 			} else if (ps.withReplies) { | ||||
| 				const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 					`homeTimeline:${me.id}`, | ||||
| 					'localTimeline', | ||||
| 					'localTimelineWithReplies', | ||||
| 				], untilId, sinceId); | ||||
| 				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); | ||||
| 			} else { | ||||
| 				const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 					`homeTimeline:${me.id}`, | ||||
| 					'localTimeline', | ||||
| 				], untilId, sinceId); | ||||
| 				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); | ||||
| 				shouldFallbackToDb = htlNoteIds.length === 0; | ||||
| 			} | ||||
| 
 | ||||
| 			noteIds.sort((a, b) => a > b ? -1 : 1); | ||||
| 			noteIds = noteIds.slice(0, ps.limit); | ||||
| 
 | ||||
| 			shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); | ||||
| 
 | ||||
| 			if (!shouldFallbackToDb) { | ||||
| 				const query = this.notesRepository.createQueryBuilder('note') | ||||
| 					.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 					.innerJoinAndSelect('note.user', 'user') | ||||
| 					.leftJoinAndSelect('note.reply', 'reply') | ||||
| 					.leftJoinAndSelect('note.renote', 'renote') | ||||
| 					.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 					.leftJoinAndSelect('renote.user', 'renoteUser') | ||||
| 					.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 
 | ||||
| 				let timeline = await query.getMany(); | ||||
| 
 | ||||
| 				timeline = timeline.filter(note => { | ||||
| 					if (note.userId === me.id) { | ||||
| 						return true; | ||||
| 					} | ||||
| 					if (isUserRelated(note, userIdsWhoBlockingMe)) return false; | ||||
| 					if (isUserRelated(note, userIdsWhoMeMuting)) return false; | ||||
| 					if (note.renoteId) { | ||||
| 						if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { | ||||
| 							if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; | ||||
| 							if (ps.withRenotes === false) return false; | ||||
| 						} | ||||
| 					} | ||||
| 
 | ||||
| 					return true; | ||||
| 				}); | ||||
| 
 | ||||
| 				// TODO: フィルタした結果件数が足りなかった場合の対応
 | ||||
| 
 | ||||
| 				timeline.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
| 
 | ||||
| 				process.nextTick(() => { | ||||
| 					this.activeUsersChart.read(me); | ||||
| 				}); | ||||
| 
 | ||||
| 				return await this.noteEntityService.packMany(timeline, me); | ||||
| 			} else { // fallback to db
 | ||||
| 				const followees = await this.userFollowingService.getFollowees(me.id); | ||||
| 
 | ||||
| 				const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) | ||||
| 					.andWhere(new Brackets(qb => { | ||||
| 						if (followees.length > 0) { | ||||
| 							const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; | ||||
| 							qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); | ||||
| 							qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); | ||||
| 						} else { | ||||
| 							qb.where('note.userId = :meId', { meId: me.id }); | ||||
| 							qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); | ||||
| 						} | ||||
| 					})) | ||||
| 					.innerJoinAndSelect('note.user', 'user') | ||||
| 					.leftJoinAndSelect('note.reply', 'reply') | ||||
| 					.leftJoinAndSelect('note.renote', 'renote') | ||||
| 					.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 					.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
| 
 | ||||
| 				if (!ps.withReplies) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb | ||||
| 							.where('note.replyId IS NULL') // 返信ではない
 | ||||
| 							.orWhere(new Brackets(qb => { | ||||
| 								qb // 返信だけど投稿者自身への返信
 | ||||
| 									.where('note.replyId IS NOT NULL') | ||||
| 									.andWhere('note.replyUserId = note.userId'); | ||||
| 							})); | ||||
| 					})); | ||||
| 				} | ||||
| 
 | ||||
| 				this.queryService.generateVisibilityQuery(query, me); | ||||
| 				this.queryService.generateMutedUserQuery(query, me); | ||||
| 				this.queryService.generateBlockedUserQuery(query, me); | ||||
| 				this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | ||||
| 
 | ||||
| 				if (ps.includeMyRenotes === false) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb.orWhere('note.userId != :meId', { meId: me.id }); | ||||
| 						qb.orWhere('note.renoteId IS NULL'); | ||||
| 						qb.orWhere('note.text IS NOT NULL'); | ||||
| 						qb.orWhere('note.fileIds != \'{}\''); | ||||
| 						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 					})); | ||||
| 				} | ||||
| 
 | ||||
| 				if (ps.includeRenotedMyNotes === false) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); | ||||
| 						qb.orWhere('note.renoteId IS NULL'); | ||||
| 						qb.orWhere('note.text IS NOT NULL'); | ||||
| 						qb.orWhere('note.fileIds != \'{}\''); | ||||
| 						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 					})); | ||||
| 				} | ||||
| 
 | ||||
| 				if (ps.includeLocalRenotes === false) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb.orWhere('note.renoteUserHost IS NOT NULL'); | ||||
| 						qb.orWhere('note.renoteId IS NULL'); | ||||
| 						qb.orWhere('note.text IS NOT NULL'); | ||||
| 						qb.orWhere('note.fileIds != \'{}\''); | ||||
| 						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 					})); | ||||
| 				} | ||||
| 				let noteIds: string[]; | ||||
| 				let shouldFallbackToDb = false; | ||||
| 
 | ||||
| 				if (ps.withFiles) { | ||||
| 					query.andWhere('note.fileIds != \'{}\''); | ||||
| 					const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 						`homeTimelineWithFiles:${me.id}`, | ||||
| 						'localTimelineWithFiles', | ||||
| 					], untilId, sinceId); | ||||
| 					noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); | ||||
| 				} else if (ps.withReplies) { | ||||
| 					const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 						`homeTimeline:${me.id}`, | ||||
| 						'localTimeline', | ||||
| 						'localTimelineWithReplies', | ||||
| 					], untilId, sinceId); | ||||
| 					noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); | ||||
| 				} else { | ||||
| 					const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 						`homeTimeline:${me.id}`, | ||||
| 						'localTimeline', | ||||
| 					], untilId, sinceId); | ||||
| 					noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); | ||||
| 					shouldFallbackToDb = htlNoteIds.length === 0; | ||||
| 				} | ||||
| 				//#endregion
 | ||||
| 
 | ||||
| 				const timeline = await query.limit(ps.limit).getMany(); | ||||
| 				noteIds.sort((a, b) => a > b ? -1 : 1); | ||||
| 				noteIds = noteIds.slice(0, ps.limit); | ||||
| 
 | ||||
| 				process.nextTick(() => { | ||||
| 					this.activeUsersChart.read(me); | ||||
| 				}); | ||||
| 				shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); | ||||
| 
 | ||||
| 				return await this.noteEntityService.packMany(timeline, me); | ||||
| 				if (!shouldFallbackToDb) { | ||||
| 					const query = this.notesRepository.createQueryBuilder('note') | ||||
| 						.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 						.innerJoinAndSelect('note.user', 'user') | ||||
| 						.leftJoinAndSelect('note.reply', 'reply') | ||||
| 						.leftJoinAndSelect('note.renote', 'renote') | ||||
| 						.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 						.leftJoinAndSelect('renote.user', 'renoteUser') | ||||
| 						.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 
 | ||||
| 					let timeline = await query.getMany(); | ||||
| 
 | ||||
| 					timeline = timeline.filter(note => { | ||||
| 						if (note.userId === me.id) { | ||||
| 							return true; | ||||
| 						} | ||||
| 						if (isUserRelated(note, userIdsWhoBlockingMe)) return false; | ||||
| 						if (isUserRelated(note, userIdsWhoMeMuting)) return false; | ||||
| 						if (note.renoteId) { | ||||
| 							if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { | ||||
| 								if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; | ||||
| 								if (ps.withRenotes === false) return false; | ||||
| 							} | ||||
| 						} | ||||
| 
 | ||||
| 						return true; | ||||
| 					}); | ||||
| 
 | ||||
| 					// TODO: フィルタした結果件数が足りなかった場合の対応
 | ||||
| 
 | ||||
| 					timeline.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
| 
 | ||||
| 					process.nextTick(() => { | ||||
| 						this.activeUsersChart.read(me); | ||||
| 					}); | ||||
| 
 | ||||
| 					return await this.noteEntityService.packMany(timeline, me); | ||||
| 				} else { // fallback to db
 | ||||
| 					return await this.getFromDb({ | ||||
| 						untilId, | ||||
| 						sinceId, | ||||
| 						limit: ps.limit, | ||||
| 						includeMyRenotes: ps.includeMyRenotes, | ||||
| 						includeRenotedMyNotes: ps.includeRenotedMyNotes, | ||||
| 						includeLocalRenotes: ps.includeLocalRenotes, | ||||
| 						withFiles: ps.withFiles, | ||||
| 						withReplies: ps.withReplies, | ||||
| 					}, me); | ||||
| 				} | ||||
| 			} else { | ||||
| 				return await this.getFromDb({ | ||||
| 					untilId, | ||||
| 					sinceId, | ||||
| 					limit: ps.limit, | ||||
| 					includeMyRenotes: ps.includeMyRenotes, | ||||
| 					includeRenotedMyNotes: ps.includeRenotedMyNotes, | ||||
| 					includeLocalRenotes: ps.includeLocalRenotes, | ||||
| 					withFiles: ps.withFiles, | ||||
| 					withReplies: ps.withReplies, | ||||
| 				}, me); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	private async getFromDb(ps: { | ||||
| 		untilId: string | null, | ||||
| 		sinceId: string | null, | ||||
| 		limit: number, | ||||
| 		includeMyRenotes: boolean, | ||||
| 		includeRenotedMyNotes: boolean, | ||||
| 		includeLocalRenotes: boolean, | ||||
| 		withFiles: boolean, | ||||
| 		withReplies: boolean, | ||||
| 	}, me: MiLocalUser) { | ||||
| 		const followees = await this.userFollowingService.getFollowees(me.id); | ||||
| 
 | ||||
| 		const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) | ||||
| 			.andWhere(new Brackets(qb => { | ||||
| 				if (followees.length > 0) { | ||||
| 					const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; | ||||
| 					qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); | ||||
| 					qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); | ||||
| 				} else { | ||||
| 					qb.where('note.userId = :meId', { meId: me.id }); | ||||
| 					qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); | ||||
| 				} | ||||
| 			})) | ||||
| 			.innerJoinAndSelect('note.user', 'user') | ||||
| 			.leftJoinAndSelect('note.reply', 'reply') | ||||
| 			.leftJoinAndSelect('note.renote', 'renote') | ||||
| 			.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 			.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
| 
 | ||||
| 		if (!ps.withReplies) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.where('note.replyId IS NULL') // 返信ではない
 | ||||
| 					.orWhere(new Brackets(qb => { | ||||
| 						qb // 返信だけど投稿者自身への返信
 | ||||
| 							.where('note.replyId IS NOT NULL') | ||||
| 							.andWhere('note.replyUserId = note.userId'); | ||||
| 					})); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		this.queryService.generateVisibilityQuery(query, me); | ||||
| 		this.queryService.generateMutedUserQuery(query, me); | ||||
| 		this.queryService.generateBlockedUserQuery(query, me); | ||||
| 		this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | ||||
| 
 | ||||
| 		if (ps.includeMyRenotes === false) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('note.userId != :meId', { meId: me.id }); | ||||
| 				qb.orWhere('note.renoteId IS NULL'); | ||||
| 				qb.orWhere('note.text IS NOT NULL'); | ||||
| 				qb.orWhere('note.fileIds != \'{}\''); | ||||
| 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (ps.includeRenotedMyNotes === false) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); | ||||
| 				qb.orWhere('note.renoteId IS NULL'); | ||||
| 				qb.orWhere('note.text IS NOT NULL'); | ||||
| 				qb.orWhere('note.fileIds != \'{}\''); | ||||
| 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (ps.includeLocalRenotes === false) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('note.renoteUserHost IS NOT NULL'); | ||||
| 				qb.orWhere('note.renoteId IS NULL'); | ||||
| 				qb.orWhere('note.text IS NOT NULL'); | ||||
| 				qb.orWhere('note.fileIds != \'{}\''); | ||||
| 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (ps.withFiles) { | ||||
| 			query.andWhere('note.fileIds != \'{}\''); | ||||
| 		} | ||||
| 		//#endregion
 | ||||
| 
 | ||||
| 		const timeline = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
| 		process.nextTick(() => { | ||||
| 			this.activeUsersChart.read(me); | ||||
| 		}); | ||||
| 
 | ||||
| 		return await this.noteEntityService.packMany(timeline, me); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; | |||
| import { isUserRelated } from '@/misc/is-user-related.js'; | ||||
| import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { MiLocalUser } from '@/models/User.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
|  | @ -69,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private cacheService: CacheService, | ||||
| 		private funoutTimelineService: FunoutTimelineService, | ||||
| 		private queryService: QueryService, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); | ||||
|  | @ -79,112 +82,140 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				throw new ApiError(meta.errors.ltlDisabled); | ||||
| 			} | ||||
| 
 | ||||
| 			const [ | ||||
| 				userIdsWhoMeMuting, | ||||
| 				userIdsWhoMeMutingRenotes, | ||||
| 				userIdsWhoBlockingMe, | ||||
| 			] = me ? await Promise.all([ | ||||
| 				this.cacheService.userMutingsCache.fetch(me.id), | ||||
| 				this.cacheService.renoteMutingsCache.fetch(me.id), | ||||
| 				this.cacheService.userBlockedCache.fetch(me.id), | ||||
| 			]) : [new Set<string>(), new Set<string>(), new Set<string>()]; | ||||
| 			const serverSettings = await this.metaService.fetch(); | ||||
| 
 | ||||
| 			let noteIds: string[]; | ||||
| 			if (serverSettings.enableFanoutTimeline) { | ||||
| 				const [ | ||||
| 					userIdsWhoMeMuting, | ||||
| 					userIdsWhoMeMutingRenotes, | ||||
| 					userIdsWhoBlockingMe, | ||||
| 				] = me ? await Promise.all([ | ||||
| 					this.cacheService.userMutingsCache.fetch(me.id), | ||||
| 					this.cacheService.renoteMutingsCache.fetch(me.id), | ||||
| 					this.cacheService.userBlockedCache.fetch(me.id), | ||||
| 				]) : [new Set<string>(), new Set<string>(), new Set<string>()]; | ||||
| 
 | ||||
| 			if (ps.withFiles) { | ||||
| 				noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); | ||||
| 			} else { | ||||
| 				const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 					'localTimeline', | ||||
| 					'localTimelineWithReplies', | ||||
| 				], untilId, sinceId); | ||||
| 				noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); | ||||
| 				noteIds.sort((a, b) => a > b ? -1 : 1); | ||||
| 			} | ||||
| 
 | ||||
| 			noteIds = noteIds.slice(0, ps.limit); | ||||
| 
 | ||||
| 			if (noteIds.length > 0) { | ||||
| 				const query = this.notesRepository.createQueryBuilder('note') | ||||
| 					.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 					.innerJoinAndSelect('note.user', 'user') | ||||
| 					.leftJoinAndSelect('note.reply', 'reply') | ||||
| 					.leftJoinAndSelect('note.renote', 'renote') | ||||
| 					.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 					.leftJoinAndSelect('renote.user', 'renoteUser') | ||||
| 					.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 
 | ||||
| 				let timeline = await query.getMany(); | ||||
| 
 | ||||
| 				timeline = timeline.filter(note => { | ||||
| 					if (me && (note.userId === me.id)) { | ||||
| 						return true; | ||||
| 					} | ||||
| 					if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; | ||||
| 					if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; | ||||
| 					if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; | ||||
| 					if (note.renoteId) { | ||||
| 						if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { | ||||
| 							if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; | ||||
| 							if (ps.withRenotes === false) return false; | ||||
| 						} | ||||
| 					} | ||||
| 
 | ||||
| 					return true; | ||||
| 				}); | ||||
| 
 | ||||
| 				// TODO: フィルタした結果件数が足りなかった場合の対応
 | ||||
| 
 | ||||
| 				timeline.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
| 
 | ||||
| 				process.nextTick(() => { | ||||
| 					if (me) { | ||||
| 						this.activeUsersChart.read(me); | ||||
| 					} | ||||
| 				}); | ||||
| 
 | ||||
| 				return await this.noteEntityService.packMany(timeline, me); | ||||
| 			} else { // fallback to db
 | ||||
| 				const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), | ||||
| 					ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) | ||||
| 					.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') | ||||
| 					.innerJoinAndSelect('note.user', 'user') | ||||
| 					.leftJoinAndSelect('note.reply', 'reply') | ||||
| 					.leftJoinAndSelect('note.renote', 'renote') | ||||
| 					.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 					.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
| 
 | ||||
| 				this.queryService.generateVisibilityQuery(query, me); | ||||
| 				if (me) this.queryService.generateMutedUserQuery(query, me); | ||||
| 				if (me) this.queryService.generateBlockedUserQuery(query, me); | ||||
| 				if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | ||||
| 				let noteIds: string[]; | ||||
| 
 | ||||
| 				if (ps.withFiles) { | ||||
| 					query.andWhere('note.fileIds != \'{}\''); | ||||
| 					noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); | ||||
| 				} else { | ||||
| 					const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ | ||||
| 						'localTimeline', | ||||
| 						'localTimelineWithReplies', | ||||
| 					], untilId, sinceId); | ||||
| 					noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); | ||||
| 					noteIds.sort((a, b) => a > b ? -1 : 1); | ||||
| 				} | ||||
| 
 | ||||
| 				if (!ps.withReplies) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb | ||||
| 							.where('note.replyId IS NULL') // 返信ではない
 | ||||
| 							.orWhere(new Brackets(qb => { | ||||
| 								qb // 返信だけど投稿者自身への返信
 | ||||
| 									.where('note.replyId IS NOT NULL') | ||||
| 									.andWhere('note.replyUserId = note.userId'); | ||||
| 							})); | ||||
| 					})); | ||||
| 				noteIds = noteIds.slice(0, ps.limit); | ||||
| 
 | ||||
| 				if (noteIds.length > 0) { | ||||
| 					const query = this.notesRepository.createQueryBuilder('note') | ||||
| 						.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 						.innerJoinAndSelect('note.user', 'user') | ||||
| 						.leftJoinAndSelect('note.reply', 'reply') | ||||
| 						.leftJoinAndSelect('note.renote', 'renote') | ||||
| 						.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 						.leftJoinAndSelect('renote.user', 'renoteUser') | ||||
| 						.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 
 | ||||
| 					let timeline = await query.getMany(); | ||||
| 
 | ||||
| 					timeline = timeline.filter(note => { | ||||
| 						if (me && (note.userId === me.id)) { | ||||
| 							return true; | ||||
| 						} | ||||
| 						if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; | ||||
| 						if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; | ||||
| 						if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; | ||||
| 						if (note.renoteId) { | ||||
| 							if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { | ||||
| 								if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; | ||||
| 								if (ps.withRenotes === false) return false; | ||||
| 							} | ||||
| 						} | ||||
| 
 | ||||
| 						return true; | ||||
| 					}); | ||||
| 
 | ||||
| 					// TODO: フィルタした結果件数が足りなかった場合の対応
 | ||||
| 
 | ||||
| 					timeline.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
| 
 | ||||
| 					process.nextTick(() => { | ||||
| 						if (me) { | ||||
| 							this.activeUsersChart.read(me); | ||||
| 						} | ||||
| 					}); | ||||
| 
 | ||||
| 					return await this.noteEntityService.packMany(timeline, me); | ||||
| 				} else { // fallback to db
 | ||||
| 					return await this.getFromDb({ | ||||
| 						untilId, | ||||
| 						sinceId, | ||||
| 						limit: ps.limit, | ||||
| 						withFiles: ps.withFiles, | ||||
| 						withReplies: ps.withReplies, | ||||
| 					}, me); | ||||
| 				} | ||||
| 
 | ||||
| 				const timeline = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
| 				process.nextTick(() => { | ||||
| 					if (me) { | ||||
| 						this.activeUsersChart.read(me); | ||||
| 					} | ||||
| 				}); | ||||
| 
 | ||||
| 				return await this.noteEntityService.packMany(timeline, me); | ||||
| 			} else { | ||||
| 				return await this.getFromDb({ | ||||
| 					untilId, | ||||
| 					sinceId, | ||||
| 					limit: ps.limit, | ||||
| 					withFiles: ps.withFiles, | ||||
| 					withReplies: ps.withReplies, | ||||
| 				}, me); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	private async getFromDb(ps: { | ||||
| 		sinceId: string | null, | ||||
| 		untilId: string | null, | ||||
| 		limit: number, | ||||
| 		withFiles: boolean, | ||||
| 		withReplies: boolean, | ||||
| 	}, me: MiLocalUser | null) { | ||||
| 		const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), | ||||
| 			ps.sinceId, ps.untilId) | ||||
| 			.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') | ||||
| 			.innerJoinAndSelect('note.user', 'user') | ||||
| 			.leftJoinAndSelect('note.reply', 'reply') | ||||
| 			.leftJoinAndSelect('note.renote', 'renote') | ||||
| 			.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 			.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
| 
 | ||||
| 		this.queryService.generateVisibilityQuery(query, me); | ||||
| 		if (me) this.queryService.generateMutedUserQuery(query, me); | ||||
| 		if (me) this.queryService.generateBlockedUserQuery(query, me); | ||||
| 		if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | ||||
| 
 | ||||
| 		if (ps.withFiles) { | ||||
| 			query.andWhere('note.fileIds != \'{}\''); | ||||
| 		} | ||||
| 
 | ||||
| 		if (!ps.withReplies) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.where('note.replyId IS NULL') // 返信ではない
 | ||||
| 					.orWhere(new Brackets(qb => { | ||||
| 						qb // 返信だけど投稿者自身への返信
 | ||||
| 							.where('note.replyId IS NOT NULL') | ||||
| 							.andWhere('note.replyUserId = note.userId'); | ||||
| 					})); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		const timeline = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
| 		process.nextTick(() => { | ||||
| 			if (me) { | ||||
| 				this.activeUsersChart.read(me); | ||||
| 			} | ||||
| 		}); | ||||
| 
 | ||||
| 		return await this.noteEntityService.packMany(timeline, me); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; | |||
| import { isUserRelated } from '@/misc/is-user-related.js'; | ||||
| import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; | ||||
| import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||
| import { MiLocalUser } from '@/models/User.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['notes'], | ||||
|  | @ -63,144 +65,171 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private funoutTimelineService: FunoutTimelineService, | ||||
| 		private userFollowingService: UserFollowingService, | ||||
| 		private queryService: QueryService, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); | ||||
| 			const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); | ||||
| 
 | ||||
| 			const [ | ||||
| 				followings, | ||||
| 				userIdsWhoMeMuting, | ||||
| 				userIdsWhoMeMutingRenotes, | ||||
| 				userIdsWhoBlockingMe, | ||||
| 			] = await Promise.all([ | ||||
| 				this.cacheService.userFollowingsCache.fetch(me.id), | ||||
| 				this.cacheService.userMutingsCache.fetch(me.id), | ||||
| 				this.cacheService.renoteMutingsCache.fetch(me.id), | ||||
| 				this.cacheService.userBlockedCache.fetch(me.id), | ||||
| 			]); | ||||
| 			const serverSettings = await this.metaService.fetch(); | ||||
| 
 | ||||
| 			let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); | ||||
| 			noteIds = noteIds.slice(0, ps.limit); | ||||
| 			if (serverSettings.enableFanoutTimeline) { | ||||
| 				const [ | ||||
| 					followings, | ||||
| 					userIdsWhoMeMuting, | ||||
| 					userIdsWhoMeMutingRenotes, | ||||
| 					userIdsWhoBlockingMe, | ||||
| 				] = await Promise.all([ | ||||
| 					this.cacheService.userFollowingsCache.fetch(me.id), | ||||
| 					this.cacheService.userMutingsCache.fetch(me.id), | ||||
| 					this.cacheService.renoteMutingsCache.fetch(me.id), | ||||
| 					this.cacheService.userBlockedCache.fetch(me.id), | ||||
| 				]); | ||||
| 
 | ||||
| 			if (noteIds.length > 0) { | ||||
| 				const query = this.notesRepository.createQueryBuilder('note') | ||||
| 					.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 					.innerJoinAndSelect('note.user', 'user') | ||||
| 					.leftJoinAndSelect('note.reply', 'reply') | ||||
| 					.leftJoinAndSelect('note.renote', 'renote') | ||||
| 					.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 					.leftJoinAndSelect('renote.user', 'renoteUser') | ||||
| 					.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 				let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); | ||||
| 				noteIds = noteIds.slice(0, ps.limit); | ||||
| 
 | ||||
| 				let timeline = await query.getMany(); | ||||
| 				if (noteIds.length > 0) { | ||||
| 					const query = this.notesRepository.createQueryBuilder('note') | ||||
| 						.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 						.innerJoinAndSelect('note.user', 'user') | ||||
| 						.leftJoinAndSelect('note.reply', 'reply') | ||||
| 						.leftJoinAndSelect('note.renote', 'renote') | ||||
| 						.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 						.leftJoinAndSelect('renote.user', 'renoteUser') | ||||
| 						.leftJoinAndSelect('note.channel', 'channel'); | ||||
| 
 | ||||
| 				timeline = timeline.filter(note => { | ||||
| 					if (note.userId === me.id) { | ||||
| 						return true; | ||||
| 					} | ||||
| 					if (isUserRelated(note, userIdsWhoBlockingMe)) return false; | ||||
| 					if (isUserRelated(note, userIdsWhoMeMuting)) return false; | ||||
| 					if (note.renoteId) { | ||||
| 						if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { | ||||
| 							if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; | ||||
| 							if (ps.withRenotes === false) return false; | ||||
| 					let timeline = await query.getMany(); | ||||
| 
 | ||||
| 					timeline = timeline.filter(note => { | ||||
| 						if (note.userId === me.id) { | ||||
| 							return true; | ||||
| 						} | ||||
| 						if (isUserRelated(note, userIdsWhoBlockingMe)) return false; | ||||
| 						if (isUserRelated(note, userIdsWhoMeMuting)) return false; | ||||
| 						if (note.renoteId) { | ||||
| 							if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { | ||||
| 								if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; | ||||
| 								if (ps.withRenotes === false) return false; | ||||
| 							} | ||||
| 						} | ||||
| 						if (note.reply && note.reply.visibility === 'followers') { | ||||
| 							if (!Object.hasOwn(followings, note.reply.userId)) return false; | ||||
| 						} | ||||
| 					} | ||||
| 					if (note.reply && note.reply.visibility === 'followers') { | ||||
| 						if (!Object.hasOwn(followings, note.reply.userId)) return false; | ||||
| 					} | ||||
| 
 | ||||
| 					return true; | ||||
| 				}); | ||||
| 						return true; | ||||
| 					}); | ||||
| 
 | ||||
| 				// TODO: フィルタした結果件数が足りなかった場合の対応
 | ||||
| 					// TODO: フィルタした結果件数が足りなかった場合の対応
 | ||||
| 
 | ||||
| 				timeline.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
| 					timeline.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
| 
 | ||||
| 				process.nextTick(() => { | ||||
| 					this.activeUsersChart.read(me); | ||||
| 				}); | ||||
| 					process.nextTick(() => { | ||||
| 						this.activeUsersChart.read(me); | ||||
| 					}); | ||||
| 
 | ||||
| 				return await this.noteEntityService.packMany(timeline, me); | ||||
| 			} else { // fallback to db
 | ||||
| 				const followees = await this.userFollowingService.getFollowees(me.id); | ||||
| 
 | ||||
| 				//#region Construct query
 | ||||
| 				const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) | ||||
| 					.andWhere('note.channelId IS NULL') | ||||
| 					.innerJoinAndSelect('note.user', 'user') | ||||
| 					.leftJoinAndSelect('note.reply', 'reply') | ||||
| 					.leftJoinAndSelect('note.renote', 'renote') | ||||
| 					.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 					.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
| 
 | ||||
| 				if (followees.length > 0) { | ||||
| 					const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; | ||||
| 
 | ||||
| 					query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); | ||||
| 				} else { | ||||
| 					query.andWhere('note.userId = :meId', { meId: me.id }); | ||||
| 					return await this.noteEntityService.packMany(timeline, me); | ||||
| 				} else { // fallback to db
 | ||||
| 					return await this.getFromDb({ | ||||
| 						untilId, | ||||
| 						sinceId, | ||||
| 						limit: ps.limit, | ||||
| 						includeMyRenotes: ps.includeMyRenotes, | ||||
| 						includeRenotedMyNotes: ps.includeRenotedMyNotes, | ||||
| 						includeLocalRenotes: ps.includeLocalRenotes, | ||||
| 						withFiles: ps.withFiles, | ||||
| 					}, me); | ||||
| 				} | ||||
| 
 | ||||
| 				query.andWhere(new Brackets(qb => { | ||||
| 					qb | ||||
| 						.where('note.replyId IS NULL') // 返信ではない
 | ||||
| 						.orWhere(new Brackets(qb => { | ||||
| 							qb // 返信だけど投稿者自身への返信
 | ||||
| 								.where('note.replyId IS NOT NULL') | ||||
| 								.andWhere('note.replyUserId = note.userId'); | ||||
| 						})); | ||||
| 				})); | ||||
| 
 | ||||
| 				this.queryService.generateVisibilityQuery(query, me); | ||||
| 				this.queryService.generateMutedUserQuery(query, me); | ||||
| 				this.queryService.generateBlockedUserQuery(query, me); | ||||
| 				this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | ||||
| 
 | ||||
| 				if (ps.includeMyRenotes === false) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb.orWhere('note.userId != :meId', { meId: me.id }); | ||||
| 						qb.orWhere('note.renoteId IS NULL'); | ||||
| 						qb.orWhere('note.text IS NOT NULL'); | ||||
| 						qb.orWhere('note.fileIds != \'{}\''); | ||||
| 						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 					})); | ||||
| 				} | ||||
| 
 | ||||
| 				if (ps.includeRenotedMyNotes === false) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); | ||||
| 						qb.orWhere('note.renoteId IS NULL'); | ||||
| 						qb.orWhere('note.text IS NOT NULL'); | ||||
| 						qb.orWhere('note.fileIds != \'{}\''); | ||||
| 						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 					})); | ||||
| 				} | ||||
| 
 | ||||
| 				if (ps.includeLocalRenotes === false) { | ||||
| 					query.andWhere(new Brackets(qb => { | ||||
| 						qb.orWhere('note.renoteUserHost IS NOT NULL'); | ||||
| 						qb.orWhere('note.renoteId IS NULL'); | ||||
| 						qb.orWhere('note.text IS NOT NULL'); | ||||
| 						qb.orWhere('note.fileIds != \'{}\''); | ||||
| 						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 					})); | ||||
| 				} | ||||
| 
 | ||||
| 				if (ps.withFiles) { | ||||
| 					query.andWhere('note.fileIds != \'{}\''); | ||||
| 				} | ||||
| 				//#endregion
 | ||||
| 
 | ||||
| 				const timeline = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
| 				process.nextTick(() => { | ||||
| 					this.activeUsersChart.read(me); | ||||
| 				}); | ||||
| 
 | ||||
| 				return await this.noteEntityService.packMany(timeline, me); | ||||
| 			} else { | ||||
| 				return await this.getFromDb({ | ||||
| 					untilId, | ||||
| 					sinceId, | ||||
| 					limit: ps.limit, | ||||
| 					includeMyRenotes: ps.includeMyRenotes, | ||||
| 					includeRenotedMyNotes: ps.includeRenotedMyNotes, | ||||
| 					includeLocalRenotes: ps.includeLocalRenotes, | ||||
| 					withFiles: ps.withFiles, | ||||
| 				}, me); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; }, me: MiLocalUser) { | ||||
| 		const followees = await this.userFollowingService.getFollowees(me.id); | ||||
| 
 | ||||
| 		//#region Construct query
 | ||||
| 		const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) | ||||
| 			.andWhere('note.channelId IS NULL') | ||||
| 			.innerJoinAndSelect('note.user', 'user') | ||||
| 			.leftJoinAndSelect('note.reply', 'reply') | ||||
| 			.leftJoinAndSelect('note.renote', 'renote') | ||||
| 			.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 			.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
| 
 | ||||
| 		if (followees.length > 0) { | ||||
| 			const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; | ||||
| 
 | ||||
| 			query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); | ||||
| 		} else { | ||||
| 			query.andWhere('note.userId = :meId', { meId: me.id }); | ||||
| 		} | ||||
| 
 | ||||
| 		query.andWhere(new Brackets(qb => { | ||||
| 			qb | ||||
| 				.where('note.replyId IS NULL') // 返信ではない
 | ||||
| 				.orWhere(new Brackets(qb => { | ||||
| 					qb // 返信だけど投稿者自身への返信
 | ||||
| 						.where('note.replyId IS NOT NULL') | ||||
| 						.andWhere('note.replyUserId = note.userId'); | ||||
| 				})); | ||||
| 		})); | ||||
| 
 | ||||
| 		this.queryService.generateVisibilityQuery(query, me); | ||||
| 		this.queryService.generateMutedUserQuery(query, me); | ||||
| 		this.queryService.generateBlockedUserQuery(query, me); | ||||
| 		this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | ||||
| 
 | ||||
| 		if (ps.includeMyRenotes === false) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('note.userId != :meId', { meId: me.id }); | ||||
| 				qb.orWhere('note.renoteId IS NULL'); | ||||
| 				qb.orWhere('note.text IS NOT NULL'); | ||||
| 				qb.orWhere('note.fileIds != \'{}\''); | ||||
| 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (ps.includeRenotedMyNotes === false) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); | ||||
| 				qb.orWhere('note.renoteId IS NULL'); | ||||
| 				qb.orWhere('note.text IS NOT NULL'); | ||||
| 				qb.orWhere('note.fileIds != \'{}\''); | ||||
| 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (ps.includeLocalRenotes === false) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('note.renoteUserHost IS NOT NULL'); | ||||
| 				qb.orWhere('note.renoteId IS NULL'); | ||||
| 				qb.orWhere('note.text IS NOT NULL'); | ||||
| 				qb.orWhere('note.fileIds != \'{}\''); | ||||
| 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (ps.withFiles) { | ||||
| 			query.andWhere('note.fileIds != \'{}\''); | ||||
| 		} | ||||
| 		//#endregion
 | ||||
| 
 | ||||
| 		const timeline = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
| 		process.nextTick(() => { | ||||
| 			this.activeUsersChart.read(me); | ||||
| 		}); | ||||
| 
 | ||||
| 		return await this.noteEntityService.packMany(timeline, me); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -59,11 +59,15 @@ class HomeTimelineChannel extends Channel { | |||
| 			if (!note.visibleUserIds!.includes(this.user!.id)) return; | ||||
| 		} | ||||
| 
 | ||||
| 		// 関係ない返信は除外
 | ||||
| 		if (note.reply && !this.following[note.userId]?.withReplies) { | ||||
| 		if (note.reply) { | ||||
| 			const reply = note.reply; | ||||
| 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | ||||
| 			if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; | ||||
| 			if (this.following[note.userId]?.withReplies) { | ||||
| 				// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
 | ||||
| 				if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; | ||||
| 			} else { | ||||
| 				// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | ||||
| 				if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; | ||||
|  |  | |||
|  | @ -72,11 +72,16 @@ class HybridTimelineChannel extends Channel { | |||
| 
 | ||||
| 		// Ignore notes from instances the user has muted
 | ||||
| 		if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return; | ||||
| 		// 関係ない返信は除外
 | ||||
| 		if (note.reply && !this.following[note.userId]?.withReplies && !this.withReplies) { | ||||
| 
 | ||||
| 		if (note.reply) { | ||||
| 			const reply = note.reply; | ||||
| 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | ||||
| 			if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; | ||||
| 			if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { | ||||
| 				// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
 | ||||
| 				if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; | ||||
| 			} else { | ||||
| 				// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | ||||
| 				if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; | ||||
|  |  | |||
|  | @ -90,11 +90,15 @@ class UserListChannel extends Channel { | |||
| 			if (!note.visibleUserIds!.includes(this.user!.id)) return; | ||||
| 		} | ||||
| 
 | ||||
| 		// 関係ない返信は除外
 | ||||
| 		if (note.reply && !this.membershipsMap[note.userId]?.withReplies) { | ||||
| 		if (note.reply) { | ||||
| 			const reply = note.reply; | ||||
| 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | ||||
| 			if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; | ||||
| 			if (this.membershipsMap[note.userId]?.withReplies) { | ||||
| 				// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
 | ||||
| 				if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; | ||||
| 			} else { | ||||
| 				// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | ||||
| 				if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
 | ||||
|  |  | |||
|  | @ -159,6 +159,10 @@ describe('Streaming', () => { | |||
| 			}); | ||||
| 			*/ | ||||
| 
 | ||||
| 			test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => { | ||||
| 				// TODO
 | ||||
| 			}); | ||||
| 
 | ||||
| 			test('フォローしていないユーザーの投稿は流れない', async () => { | ||||
| 				const fired = await waitFire( | ||||
| 					kyoko, 'homeTimeline',	// kyoko:home
 | ||||
|  |  | |||
|  | @ -93,6 +93,7 @@ describe('ActivityPub', () => { | |||
| 	const metaInitial = { | ||||
| 		cacheRemoteFiles: true, | ||||
| 		cacheRemoteSensitiveFiles: true, | ||||
| 		enableFanoutTimeline: true, | ||||
| 		perUserHomeTimelineCacheMax: 100, | ||||
| 		perLocalUserUserTimelineCacheMax: 100, | ||||
| 		perRemoteUserUserTimelineCacheMax: 100, | ||||
|  |  | |||
|  | @ -26,8 +26,7 @@ | |||
| 		"@tabler/icons-webfont": "2.37.0", | ||||
| 		"@vitejs/plugin-vue": "4.4.0", | ||||
| 		"@vue-macros/reactivity-transform": "0.3.23", | ||||
| 		"@vue/compiler-sfc": "3.3.4", | ||||
| 		"@vue/compiler-sfc": "3.3.5", | ||||
| 		"@vue/compiler-sfc": "3.3.6", | ||||
| 		"astring": "1.8.6", | ||||
| 		"autosize": "6.0.1", | ||||
| 		"broadcast-channel": "5.5.0", | ||||
|  | @ -74,7 +73,7 @@ | |||
| 		"v-code-diff": "1.7.1", | ||||
| 		"vanilla-tilt": "1.8.1", | ||||
| 		"vite": "4.5.0", | ||||
| 		"vue": "3.3.5", | ||||
| 		"vue": "3.3.6", | ||||
| 		"vue-prism-editor": "2.0.0-alpha.2", | ||||
| 		"vuedraggable": "next" | ||||
| 	}, | ||||
|  | @ -113,11 +112,11 @@ | |||
| 		"@typescript-eslint/eslint-plugin": "6.8.0", | ||||
| 		"@typescript-eslint/parser": "6.8.0", | ||||
| 		"@vitest/coverage-v8": "0.34.6", | ||||
| 		"@vue/runtime-core": "3.3.5", | ||||
| 		"@vue/runtime-core": "3.3.6", | ||||
| 		"acorn": "8.10.0", | ||||
| 		"cross-env": "7.0.3", | ||||
| 		"cypress": "13.3.2", | ||||
| 		"eslint": "8.51.0", | ||||
| 		"eslint": "8.52.0", | ||||
| 		"eslint-plugin-import": "2.28.1", | ||||
| 		"eslint-plugin-vue": "9.17.0", | ||||
| 		"fast-glob": "3.3.1", | ||||
|  |  | |||
|  | @ -175,7 +175,7 @@ export async function common(createVue: () => App<Element>) { | |||
| 		defaultStore.set('darkMode', isDeviceDarkmode()); | ||||
| 	} | ||||
| 
 | ||||
| 	window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { | ||||
| 	window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { | ||||
| 		if (ColdDeviceStorage.get('syncDeviceDarkMode')) { | ||||
| 			defaultStore.set('darkMode', mql.matches); | ||||
| 		} | ||||
|  |  | |||
|  | @ -87,9 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 					</FormSection> | ||||
| 
 | ||||
| 					<FormSection> | ||||
| 						<template #label>Timeline caching</template> | ||||
| 						<template #label>Misskey® Fan-out Timeline Technology™ (FTT)</template> | ||||
| 
 | ||||
| 						<div class="_gaps_m"> | ||||
| 							<MkSwitch v-model="enableFanoutTimeline"> | ||||
| 								<template #label>{{ i18n.ts.enable }}</template> | ||||
| 								<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</template> | ||||
| 							</MkSwitch> | ||||
| 
 | ||||
| 							<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number"> | ||||
| 								<template #label>perLocalUserUserTimelineCacheMax</template> | ||||
| 							</MkInput> | ||||
|  | @ -165,6 +170,7 @@ let cacheRemoteSensitiveFiles: boolean = $ref(false); | |||
| let enableServiceWorker: boolean = $ref(false); | ||||
| let swPublicKey: any = $ref(null); | ||||
| let swPrivateKey: any = $ref(null); | ||||
| let enableFanoutTimeline: boolean = $ref(false); | ||||
| let perLocalUserUserTimelineCacheMax: number = $ref(0); | ||||
| let perRemoteUserUserTimelineCacheMax: number = $ref(0); | ||||
| let perUserHomeTimelineCacheMax: number = $ref(0); | ||||
|  | @ -185,6 +191,7 @@ async function init(): Promise<void> { | |||
| 	enableServiceWorker = meta.enableServiceWorker; | ||||
| 	swPublicKey = meta.swPublickey; | ||||
| 	swPrivateKey = meta.swPrivateKey; | ||||
| 	enableFanoutTimeline = meta.enableFanoutTimeline; | ||||
| 	perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax; | ||||
| 	perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax; | ||||
| 	perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax; | ||||
|  | @ -206,6 +213,7 @@ async function save(): void { | |||
| 		enableServiceWorker, | ||||
| 		swPublicKey, | ||||
| 		swPrivateKey, | ||||
| 		enableFanoutTimeline, | ||||
| 		perLocalUserUserTimelineCacheMax, | ||||
| 		perRemoteUserUserTimelineCacheMax, | ||||
| 		perUserHomeTimelineCacheMax, | ||||
|  |  | |||
|  | @ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { ref, computed, onMounted, nextTick } from 'vue'; | ||||
| import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue'; | ||||
| import MkLoading from '@/components/global/MkLoading.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import FormSection from '@/components/form/section.vue'; | ||||
|  | @ -120,9 +120,8 @@ const errorKV = ref<{ | |||
| 	description: '', | ||||
| }); | ||||
| 
 | ||||
| const urlParams = new URLSearchParams(window.location.search); | ||||
| const url = urlParams.get('url'); | ||||
| const hash = urlParams.get('hash'); | ||||
| const url = ref<string | null>(null); | ||||
| const hash = ref<string | null>(null); | ||||
| 
 | ||||
| const data = ref<{ | ||||
| 	type: 'plugin' | 'theme'; | ||||
|  | @ -152,7 +151,7 @@ function goToMisskey(): void { | |||
| } | ||||
| 
 | ||||
| async function fetch() { | ||||
| 	if (!url || !hash) { | ||||
| 	if (!url.value || !hash.value) { | ||||
| 		errorKV.value = { | ||||
| 			title: i18n.ts._externalResourceInstaller._errors._invalidParams.title, | ||||
| 			description: i18n.ts._externalResourceInstaller._errors._invalidParams.description, | ||||
|  | @ -161,8 +160,8 @@ async function fetch() { | |||
| 		return; | ||||
| 	} | ||||
| 	const res = await os.api('fetch-external-resources', { | ||||
| 		url, | ||||
| 		hash, | ||||
| 		url: url.value, | ||||
| 		hash: hash.value, | ||||
| 	}).catch((err) => { | ||||
| 		switch (err.id) { | ||||
| 			case 'bb774091-7a15-4a70-9dc5-6ac8cf125856': | ||||
|  | @ -240,7 +239,7 @@ async function fetch() { | |||
| 							description: i18n.ts._theme.alreadyInstalled, | ||||
| 						}; | ||||
| 						break; | ||||
| 					 | ||||
| 
 | ||||
| 					default: | ||||
| 						errorKV.value = { | ||||
| 							title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title, | ||||
|  | @ -297,10 +296,17 @@ async function install() { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| onActivated(() => { | ||||
| 	const urlParams = new URLSearchParams(window.location.search); | ||||
| 	url.value = urlParams.get('url'); | ||||
| 	hash.value = urlParams.get('hash'); | ||||
| 	fetch(); | ||||
| }); | ||||
| 
 | ||||
| onDeactivated(() => { | ||||
| 	uiPhase.value = 'fetching'; | ||||
| }); | ||||
| 
 | ||||
| const headerActions = computed(() => []); | ||||
| 
 | ||||
| const headerTabs = computed(() => []); | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ | |||
| 		"@types/node": "20.8.7", | ||||
| 		"@typescript-eslint/eslint-plugin": "6.8.0", | ||||
| 		"@typescript-eslint/parser": "6.8.0", | ||||
| 		"eslint": "8.51.0", | ||||
| 		"eslint": "8.52.0", | ||||
| 		"jest": "29.7.0", | ||||
| 		"jest-fetch-mock": "3.0.3", | ||||
| 		"jest-websocket-mock": "2.5.0", | ||||
|  | @ -39,7 +39,7 @@ | |||
| 	], | ||||
| 	"dependencies": { | ||||
| 		"@swc/cli": "0.1.62", | ||||
| 		"@swc/core": "1.3.93", | ||||
| 		"@swc/core": "1.3.94", | ||||
| 		"eventemitter3": "5.0.1", | ||||
| 		"reconnecting-websocket": "4.4.0" | ||||
| 	} | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ | |||
| 	"devDependencies": { | ||||
| 		"@typescript-eslint/parser": "6.8.0", | ||||
| 		"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67", | ||||
| 		"eslint": "8.51.0", | ||||
| 		"eslint": "8.52.0", | ||||
| 		"eslint-plugin-import": "2.28.1", | ||||
| 		"typescript": "5.2.2" | ||||
| 	}, | ||||
|  |  | |||
							
								
								
									
										604
									
								
								pnpm-lock.yaml
								
								
								
								
							
							
						
						
									
										604
									
								
								pnpm-lock.yaml
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Loading…
	
		Reference in New Issue