タイムライン取得処理への組み込み
This commit is contained in:
		
							parent
							
								
									94ededa68d
								
							
						
					
					
						commit
						7d7c2d4daf
					
				|  | @ -20,11 +20,16 @@ export class AddChannelMuting1718015380000 { | |||
| 			); | ||||
| 			CREATE INDEX "IDX_channel_muting_userId" ON "channel_muting" ("userId"); | ||||
| 			CREATE INDEX "IDX_channel_muting_channelId" ON "channel_muting" ("channelId"); | ||||
| 
 | ||||
| 			ALTER TABLE note ADD "renoteChannelId" varchar(32); | ||||
| 			COMMENT ON COLUMN note."renoteChannelId" is '[Denormalized]'; | ||||
| 		`);
 | ||||
| 	} | ||||
| 
 | ||||
| 	async down(queryRunner) { | ||||
| 		await queryRunner.query(` | ||||
| 			ALTER TABLE note DROP COLUMN "renoteChannelId"; | ||||
| 
 | ||||
| 			ALTER TABLE "channel_muting" | ||||
| 				DROP CONSTRAINT "FK_channel_muting_userId"; | ||||
| 			ALTER TABLE "channel_muting" | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ | |||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { ChannelMutingRepository, MiChannel, MiUser } from '@/models/_.js'; | ||||
| import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiUser } from '@/models/_.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | @ -21,6 +21,8 @@ export class ChannelMutingService { | |||
| 		private redisClient: Redis.Redis, | ||||
| 		@Inject(DI.redisForSub) | ||||
| 		private redisForSub: Redis.Redis, | ||||
| 		@Inject(DI.channelsRepository) | ||||
| 		private channelsRepository: ChannelsRepository, | ||||
| 		@Inject(DI.channelMutingRepository) | ||||
| 		private channelMutingRepository: ChannelMutingRepository, | ||||
| 		private idService: IdService, | ||||
|  | @ -40,6 +42,61 @@ export class ChannelMutingService { | |||
| 		this.redisForSub.on('message', this.onMessage); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * ミュートしているチャンネルの一覧を取得する. | ||||
| 	 * @param params | ||||
| 	 * @param [opts] | ||||
| 	 * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). | ||||
| 	 * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async list( | ||||
| 		params: { | ||||
| 			requestUserId: MiUser['id'], | ||||
| 		}, | ||||
| 		opts?: { | ||||
| 			joinUser?: boolean; | ||||
| 			joinBannerFile?: boolean; | ||||
| 		}, | ||||
| 	): Promise<MiChannel[]> { | ||||
| 		const q = this.channelsRepository.createQueryBuilder('channel') | ||||
| 			.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id') | ||||
| 			.where('channel_muting.userId = :userId', { userId: params.requestUserId }) | ||||
| 			.andWhere(qb => { | ||||
| 				qb.where('channel_muting.expiresAt IS NULL') | ||||
| 					.orWhere('channel_muting.expiresAt > :now:', { now: new Date() }); | ||||
| 			}); | ||||
| 
 | ||||
| 		if (opts?.joinUser) { | ||||
| 			q.innerJoinAndSelect('channel.user', 'user'); | ||||
| 		} | ||||
| 
 | ||||
| 		if (opts?.joinBannerFile) { | ||||
| 			q.leftJoinAndSelect('channel.banner', 'drive_file'); | ||||
| 		} | ||||
| 
 | ||||
| 		return q.getMany(); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * 既にミュートされているかどうかをキャッシュから取得する. | ||||
| 	 * @param params | ||||
| 	 * @param params.requestUserId | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async isMuted(params: { | ||||
| 		requestUserId: MiUser['id'], | ||||
| 		targetChannelId: MiChannel['id'], | ||||
| 	}): Promise<boolean> { | ||||
| 		const mutedChannels = await this.userMutingChannelsCache.get(params.requestUserId); | ||||
| 		return (mutedChannels?.has(params.targetChannelId) ?? false); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * チャンネルをミュートする. | ||||
| 	 * @param params | ||||
| 	 * @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async mute(params: { | ||||
| 		requestUserId: MiUser['id'], | ||||
|  | @ -59,6 +116,10 @@ export class ChannelMutingService { | |||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * チャンネルのミュートを解除する. | ||||
| 	 * @param params | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async unmute(params: { | ||||
| 		requestUserId: MiUser['id'], | ||||
|  |  | |||
|  | @ -17,6 +17,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; | |||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import { isReply } from '@/misc/is-reply.js'; | ||||
| import { isInstanceMuted } from '@/misc/is-instance-muted.js'; | ||||
| import { ChannelMutingService } from '@/core/ChannelMutingService.js'; | ||||
| import { isChannelRelated } from '@/misc/is-channel-related.js'; | ||||
| 
 | ||||
| type TimelineOptions = { | ||||
| 	untilId: string | null, | ||||
|  | @ -33,6 +35,7 @@ type TimelineOptions = { | |||
| 	excludeNoFiles?: boolean; | ||||
| 	excludeReplies?: boolean; | ||||
| 	excludePureRenotes: boolean; | ||||
| 	excludeMutedChannels?: boolean; | ||||
| 	dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>, | ||||
| }; | ||||
| 
 | ||||
|  | @ -45,6 +48,7 @@ export class FanoutTimelineEndpointService { | |||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private cacheService: CacheService, | ||||
| 		private fanoutTimelineService: FanoutTimelineService, | ||||
| 		private channelMutingService: ChannelMutingService, | ||||
| 	) { | ||||
| 	} | ||||
| 
 | ||||
|  | @ -101,11 +105,13 @@ export class FanoutTimelineEndpointService { | |||
| 					userIdsWhoMeMutingRenotes, | ||||
| 					userIdsWhoBlockingMe, | ||||
| 					userMutedInstances, | ||||
| 					userMutedChannels, | ||||
| 				] = await Promise.all([ | ||||
| 					this.cacheService.userMutingsCache.fetch(ps.me.id), | ||||
| 					this.cacheService.renoteMutingsCache.fetch(ps.me.id), | ||||
| 					this.cacheService.userBlockedCache.fetch(ps.me.id), | ||||
| 					this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)), | ||||
| 					ps.excludeMutedChannels ? this.channelMutingService.userMutingChannelsCache.fetch(me.id) : Promise.resolve(new Set<string>()), | ||||
| 				]); | ||||
| 
 | ||||
| 				const parentFilter = filter; | ||||
|  | @ -114,6 +120,7 @@ export class FanoutTimelineEndpointService { | |||
| 					if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; | ||||
| 					if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; | ||||
| 					if (isInstanceMuted(note, userMutedInstances)) return false; | ||||
| 					if (ps.excludeMutedChannels && isChannelRelated(note, userMutedChannels)) return false; | ||||
| 
 | ||||
| 					return parentFilter(note); | ||||
| 				}; | ||||
|  |  | |||
|  | @ -434,6 +434,7 @@ export class NoteCreateService implements OnApplicationShutdown { | |||
| 			replyUserHost: data.reply ? data.reply.userHost : null, | ||||
| 			renoteUserId: data.renote ? data.renote.userId : null, | ||||
| 			renoteUserHost: data.renote ? data.renote.userHost : null, | ||||
| 			renoteChannelId: data.renote ? data.renote.channelId : null, | ||||
| 			userHost: user.host, | ||||
| 		}); | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,36 +4,39 @@ | |||
|  */ | ||||
| 
 | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js'; | ||||
| import type { | ||||
| 	ChannelFavoritesRepository, | ||||
| 	ChannelFollowingsRepository, | ||||
| 	ChannelsRepository, | ||||
| 	DriveFilesRepository, | ||||
| 	MiDriveFile, | ||||
| 	MiNote, | ||||
| 	NotesRepository, | ||||
| } from '@/models/_.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/Blocking.js'; | ||||
| import type { MiUser } from '@/models/User.js'; | ||||
| import type { MiChannel } from '@/models/Channel.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { isNotNull } from '@/misc/is-not-null.js'; | ||||
| import { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| import { NoteEntityService } from './NoteEntityService.js'; | ||||
| import { In } from 'typeorm'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class ChannelEntityService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.channelsRepository) | ||||
| 		private channelsRepository: ChannelsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.channelFavoritesRepository) | ||||
| 		private channelFavoritesRepository: ChannelFavoritesRepository, | ||||
| 
 | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
| 
 | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
| 
 | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private driveFileEntityService: DriveFileEntityService, | ||||
| 		private idService: IdService, | ||||
|  | @ -45,31 +48,50 @@ export class ChannelEntityService { | |||
| 		src: MiChannel['id'] | MiChannel, | ||||
| 		me?: { id: MiUser['id'] } | null | undefined, | ||||
| 		detailed?: boolean, | ||||
| 		opts?: { | ||||
| 			bannerFiles?: Map<MiDriveFile['id'], MiDriveFile>; | ||||
| 			followings?: Set<MiChannel['id']>; | ||||
| 			favorites?: Set<MiChannel['id']>; | ||||
| 			pinnedNotes?: Map<MiNote['id'], MiNote>; | ||||
| 		}, | ||||
| 	): Promise<Packed<'Channel'>> { | ||||
| 		const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); | ||||
| 		const meId = me ? me.id : null; | ||||
| 
 | ||||
| 		const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; | ||||
| 		let bannerFile: MiDriveFile | null = null; | ||||
| 		if (channel.bannerId) { | ||||
| 			bannerFile = opts?.bannerFiles?.get(channel.bannerId) | ||||
| 				?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId }); | ||||
| 		} | ||||
| 
 | ||||
| 		const isFollowing = meId ? await this.channelFollowingsRepository.exists({ | ||||
| 			where: { | ||||
| 				followerId: meId, | ||||
| 				followeeId: channel.id, | ||||
| 			}, | ||||
| 		}) : false; | ||||
| 		let isFollowing = false; | ||||
| 		let isFavorite = false; | ||||
| 		if (me) { | ||||
| 			isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({ | ||||
| 				where: { | ||||
| 					followerId: me.id, | ||||
| 					followeeId: channel.id, | ||||
| 				}, | ||||
| 			}); | ||||
| 
 | ||||
| 		const isFavorited = meId ? await this.channelFavoritesRepository.exists({ | ||||
| 			where: { | ||||
| 				userId: meId, | ||||
| 				channelId: channel.id, | ||||
| 			}, | ||||
| 		}) : false; | ||||
| 			isFavorite = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({ | ||||
| 				where: { | ||||
| 					userId: me.id, | ||||
| 					channelId: channel.id, | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ | ||||
| 			where: { | ||||
| 				id: In(channel.pinnedNoteIds), | ||||
| 			}, | ||||
| 		}) : []; | ||||
| 		const pinnedNotes = Array.of<MiNote>(); | ||||
| 		if (channel.pinnedNoteIds.length > 0) { | ||||
| 			pinnedNotes.push( | ||||
| 				...( | ||||
| 					opts?.pinnedNotes | ||||
| 						// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | ||||
| 						? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(isNotNull) | ||||
| 						: await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) }) | ||||
| 				), | ||||
| 			); | ||||
| 		} | ||||
| 
 | ||||
| 		return { | ||||
| 			id: channel.id, | ||||
|  | @ -78,7 +100,7 @@ export class ChannelEntityService { | |||
| 			name: channel.name, | ||||
| 			description: channel.description, | ||||
| 			userId: channel.userId, | ||||
| 			bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, | ||||
| 			bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null, | ||||
| 			pinnedNoteIds: channel.pinnedNoteIds, | ||||
| 			color: channel.color, | ||||
| 			isArchived: channel.isArchived, | ||||
|  | @ -89,7 +111,7 @@ export class ChannelEntityService { | |||
| 
 | ||||
| 			...(me ? { | ||||
| 				isFollowing, | ||||
| 				isFavorited, | ||||
| 				isFavorite, | ||||
| 				hasUnreadNote: false, // 後方互換性のため
 | ||||
| 			} : {}), | ||||
| 
 | ||||
|  | @ -98,5 +120,62 @@ export class ChannelEntityService { | |||
| 			} : {}), | ||||
| 		}; | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		src: MiChannel['id'][] | MiChannel[], | ||||
| 		me?: { id: MiUser['id'] } | null | undefined, | ||||
| 		detailed?: boolean, | ||||
| 	): Promise<Packed<'Channel'>[]> { | ||||
| 		// IDのみの要素がある場合、DBからオブジェクトを取得して補う
 | ||||
| 		const channels = src.filter(it => typeof it === 'object') as MiChannel[]; | ||||
| 		channels.push( | ||||
| 			...(await this.channelsRepository.find({ | ||||
| 				where: { | ||||
| 					id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]), | ||||
| 				}, | ||||
| 			})), | ||||
| 		); | ||||
| 		channels.sort((a, b) => a.id.localeCompare(b.id)); | ||||
| 
 | ||||
| 		const bannerFiles = await this.driveFilesRepository | ||||
| 			.findBy({ | ||||
| 				id: In(channels.map(it => it.bannerId).filter(it => it != null)), | ||||
| 			}) | ||||
| 			.then(it => new Map(it.map(it => [it.id, it]))); | ||||
| 
 | ||||
| 		const followings = me | ||||
| 			? await this.channelFollowingsRepository | ||||
| 				.findBy({ | ||||
| 					followerId: me.id, | ||||
| 					followeeId: In(channels.map(it => it.id)), | ||||
| 				}) | ||||
| 				.then(it => new Set(it.map(it => it.followeeId))) | ||||
| 			: new Set<MiChannel['id']>(); | ||||
| 
 | ||||
| 		const favorites = me | ||||
| 			? await this.channelFavoritesRepository | ||||
| 				.findBy({ | ||||
| 					userId: me.id, | ||||
| 					channelId: In(channels.map(it => it.id)), | ||||
| 				}) | ||||
| 				.then(it => new Set(it.map(it => it.channelId))) | ||||
| 			: new Set<MiChannel['id']>(); | ||||
| 
 | ||||
| 		const pinnedNotes = await this.notesRepository | ||||
| 			.find({ | ||||
| 				where: { | ||||
| 					id: In(channels.flatMap(it => it.pinnedNoteIds)), | ||||
| 				}, | ||||
| 			}) | ||||
| 			.then(it => new Map(it.map(it => [it.id, it]))); | ||||
| 
 | ||||
| 		return Promise.all(channels.map(it => this.pack(it, me, detailed, { | ||||
| 			bannerFiles, | ||||
| 			followings, | ||||
| 			favorites, | ||||
| 			pinnedNotes, | ||||
| 		}))); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,33 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { MiNote } from '@/models/Note.js'; | ||||
| import { Packed } from '@/misc/json-schema.js'; | ||||
| 
 | ||||
| /** | ||||
|  * {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。 | ||||
|  * 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。 | ||||
|  * | ||||
|  * @param note 確認対象のノート | ||||
|  * @param channelIds 確認対象のチャンネルID一覧 | ||||
|  */ | ||||
| export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set<string>): boolean { | ||||
| 	if (!note.channelId) { | ||||
| 		// チャンネル投稿じゃなければ無条件でOK
 | ||||
| 		return true; | ||||
| 	} | ||||
| 
 | ||||
| 	if (channelIds.has(note.channelId)) { | ||||
| 		return true; | ||||
| 	} | ||||
| 
 | ||||
| 	if (note.renote != null && note.renote.channelId && channelIds.has(note.renote.channelId)) { | ||||
| 		return true; | ||||
| 	} | ||||
| 
 | ||||
| 	// NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない
 | ||||
| 
 | ||||
| 	return false; | ||||
| } | ||||
|  | @ -229,6 +229,13 @@ export class MiNote { | |||
| 		comment: '[Denormalized]', | ||||
| 	}) | ||||
| 	public renoteUserHost: string | null; | ||||
| 
 | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		nullable: true, | ||||
| 		comment: '[Denormalized]', | ||||
| 	}) | ||||
| 	public renoteChannelId: MiChannel['id'] | null; | ||||
| 	//#endregion
 | ||||
| 
 | ||||
| 	constructor(data: Partial<MiNote>) { | ||||
|  |  | |||
|  | @ -124,6 +124,9 @@ import * as ep___channels_favorite from './endpoints/channels/favorite.js'; | |||
| import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; | ||||
| import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; | ||||
| import * as ep___channels_search from './endpoints/channels/search.js'; | ||||
| import * as ep___channels_mute_create from './endpoints/channels/mute/create.js'; | ||||
| import * as ep___channels_mute_delete from './endpoints/channels/mute/delete.js'; | ||||
| import * as ep___channels_mute_list from './endpoints/channels/mute/list.js'; | ||||
| import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; | ||||
| import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; | ||||
| import * as ep___charts_drive from './endpoints/charts/drive.js'; | ||||
|  | @ -507,6 +510,9 @@ const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass | |||
| const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; | ||||
| const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; | ||||
| const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; | ||||
| const $channels_mute_create: Provider = { provide: 'ep:channels/mute/create', useClass: ep___channels_mute_create.default }; | ||||
| const $channels_mute_delete: Provider = { provide: 'ep:channels/mute/delete', useClass: ep___channels_mute_delete.default }; | ||||
| const $channels_mute_list: Provider = { provide: 'ep:channels/mute/list', useClass: ep___channels_mute_list.default }; | ||||
| const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; | ||||
| const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; | ||||
| const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; | ||||
|  | @ -894,6 +900,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | |||
| 		$channels_unfavorite, | ||||
| 		$channels_myFavorites, | ||||
| 		$channels_search, | ||||
| 		$channels_mute_create, | ||||
| 		$channels_mute_delete, | ||||
| 		$channels_mute_list, | ||||
| 		$charts_activeUsers, | ||||
| 		$charts_apRequest, | ||||
| 		$charts_drive, | ||||
|  | @ -1275,6 +1284,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | |||
| 		$channels_unfavorite, | ||||
| 		$channels_myFavorites, | ||||
| 		$channels_search, | ||||
| 		$channels_mute_create, | ||||
| 		$channels_mute_delete, | ||||
| 		$channels_mute_list, | ||||
| 		$charts_activeUsers, | ||||
| 		$charts_apRequest, | ||||
| 		$charts_drive, | ||||
|  |  | |||
|  | @ -130,6 +130,9 @@ import * as ep___channels_favorite from './endpoints/channels/favorite.js'; | |||
| import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; | ||||
| import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; | ||||
| import * as ep___channels_search from './endpoints/channels/search.js'; | ||||
| import * as ep___channels_mute_create from './endpoints/channels/mute/create.js'; | ||||
| import * as ep___channels_mute_delete from './endpoints/channels/mute/delete.js'; | ||||
| import * as ep___channels_mute_list from './endpoints/channels/mute/list.js'; | ||||
| import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; | ||||
| import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; | ||||
| import * as ep___charts_drive from './endpoints/charts/drive.js'; | ||||
|  | @ -511,6 +514,9 @@ const eps = [ | |||
| 	['channels/unfavorite', ep___channels_unfavorite], | ||||
| 	['channels/my-favorites', ep___channels_myFavorites], | ||||
| 	['channels/search', ep___channels_search], | ||||
| 	['channels/mute/create', ep___channels_mute_create], | ||||
| 	['channels/mute/delete', ep___channels_mute_delete], | ||||
| 	['channels/mute/list', ep___channels_mute_list], | ||||
| 	['charts/active-users', ep___charts_activeUsers], | ||||
| 	['charts/ap-request', ep___charts_apRequest], | ||||
| 	['charts/drive', ep___charts_drive], | ||||
|  |  | |||
|  | @ -0,0 +1,90 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { ChannelsRepository } from '@/models/_.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
| import { ChannelMutingService } from '@/core/ChannelMutingService.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels', 'mute'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	prohibitMoved: true, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such Channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: '7174361e-d58f-31d6-2e7c-6fb830786a3f', | ||||
| 		}, | ||||
| 
 | ||||
| 		alreadyMuting: { | ||||
| 			message: 'You are already muting that user.', | ||||
| 			code: 'ALREADY_MUTING_CHANNEL', | ||||
| 			id: '5a251978-769a-da44-3e89-3931e43bb592', | ||||
| 		}, | ||||
| 
 | ||||
| 		expiresAtIsPast: { | ||||
| 			message: 'Cannot set past date to "expiresAt".', | ||||
| 			code: 'EXPIRES_AT_IS_PAST', | ||||
| 			id: '42b32236-df2c-a45f-fdbf-def67268f749', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		channelId: { type: 'string', format: 'misskey:id' }, | ||||
| 		expiresAt: { | ||||
| 			type: 'integer', | ||||
| 			nullable: true, | ||||
| 			description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.', | ||||
| 		}, | ||||
| 	}, | ||||
| 	required: ['channelId'], | ||||
| } as const; | ||||
| 
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | ||||
| 	constructor( | ||||
| 		@Inject(DI.channelsRepository) | ||||
| 		private channelsRepository: ChannelsRepository, | ||||
| 		private channelMutingService: ChannelMutingService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			// Check if exists the channel
 | ||||
| 			const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId }); | ||||
| 			if (!targetChannel) { | ||||
| 				throw new ApiError(meta.errors.noSuchChannel); | ||||
| 			} | ||||
| 
 | ||||
| 			// Check if already muting
 | ||||
| 			const exist = await this.channelMutingService.isMuted({ | ||||
| 				requestUserId: me.id, | ||||
| 				targetChannelId: targetChannel.id, | ||||
| 			}); | ||||
| 			if (exist) { | ||||
| 				throw new ApiError(meta.errors.alreadyMuting); | ||||
| 			} | ||||
| 
 | ||||
| 			// Check if expiresAt is past
 | ||||
| 			if (ps.expiresAt && ps.expiresAt <= Date.now()) { | ||||
| 				throw new ApiError(meta.errors.expiresAtIsPast); | ||||
| 			} | ||||
| 
 | ||||
| 			await this.channelMutingService.mute({ | ||||
| 				requestUserId: me.id, | ||||
| 				targetChannelId: targetChannel.id, | ||||
| 				expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,73 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { ChannelsRepository } from '@/models/_.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ChannelMutingService } from '@/core/ChannelMutingService.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels', 'mute'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	prohibitMoved: true, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such Channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: 'e7998769-6e94-d9c2-6b8f-94a527314aba', | ||||
| 		}, | ||||
| 
 | ||||
| 		notMuting: { | ||||
| 			message: 'You are not muting that channel.', | ||||
| 			code: 'NOT_MUTING_CHANNEL', | ||||
| 			id: '14d55962-6ea8-d990-1333-d6bef78dc2ab', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		channelId: { type: 'string', format: 'misskey:id' }, | ||||
| 	}, | ||||
| 	required: ['channelId'], | ||||
| } as const; | ||||
| 
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | ||||
| 	constructor( | ||||
| 		@Inject(DI.channelsRepository) | ||||
| 		private channelsRepository: ChannelsRepository, | ||||
| 		private channelMutingService: ChannelMutingService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			// Check if exists the channel
 | ||||
| 			const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId }); | ||||
| 			if (!targetChannel) { | ||||
| 				throw new ApiError(meta.errors.noSuchChannel); | ||||
| 			} | ||||
| 
 | ||||
| 			// Check if already muting
 | ||||
| 			const exist = await this.channelMutingService.isMuted({ | ||||
| 				requestUserId: me.id, | ||||
| 				targetChannelId: targetChannel.id, | ||||
| 			}); | ||||
| 			if (exist) { | ||||
| 				throw new ApiError(meta.errors.notMuting); | ||||
| 			} | ||||
| 
 | ||||
| 			await this.channelMutingService.unmute({ | ||||
| 				requestUserId: me.id, | ||||
| 				targetChannelId: targetChannel.id, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,49 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { ChannelMutingService } from '@/core/ChannelMutingService.js'; | ||||
| import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels', 'mute'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	prohibitMoved: true, | ||||
| 
 | ||||
| 	kind: 'read:channels', | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array', | ||||
| 		optional: false, nullable: false, | ||||
| 		items: { | ||||
| 			type: 'object', | ||||
| 			optional: false, nullable: false, | ||||
| 			ref: 'Channel', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: {}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| 
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | ||||
| 	constructor( | ||||
| 		private channelMutingService: ChannelMutingService, | ||||
| 		private channelEntityService: ChannelEntityService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const mutings = await this.channelMutingService.list({ | ||||
| 				requestUserId: me.id, | ||||
| 			}); | ||||
| 			return await this.channelEntityService.packMany(mutings, me); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -19,6 +19,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; | |||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { MiLocalUser } from '@/models/User.js'; | ||||
| import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; | ||||
| import { ChannelMutingService } from '@/core/ChannelMutingService.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
|  | @ -47,7 +48,7 @@ export const meta = { | |||
| 		bothWithRepliesAndWithFiles: { | ||||
| 			message: 'Specifying both withReplies and withFiles is not supported', | ||||
| 			code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', | ||||
| 			id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f' | ||||
| 			id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | @ -87,6 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private cacheService: CacheService, | ||||
| 		private queryService: QueryService, | ||||
| 		private userFollowingService: UserFollowingService, | ||||
| 		private channelMutingService: ChannelMutingService, | ||||
| 		private metaService: MetaService, | ||||
| 		private fanoutTimelineEndpointService: FanoutTimelineEndpointService, | ||||
| 	) { | ||||
|  | @ -152,6 +154,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				useDbFallback: serverSettings.enableFanoutTimelineDbFallback, | ||||
| 				alwaysIncludeMyNotes: true, | ||||
| 				excludePureRenotes: !ps.withRenotes, | ||||
| 				excludeMutedChannels: true, | ||||
| 				dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ | ||||
| 					untilId, | ||||
| 					sinceId, | ||||
|  | @ -188,6 +191,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				followerId: me.id, | ||||
| 			}, | ||||
| 		}); | ||||
| 		const mutingChannelIds = (followingChannels.length > 0) | ||||
| 			? await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)) | ||||
| 			: []; | ||||
| 
 | ||||
| 		const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) | ||||
| 			.andWhere(new Brackets(qb => { | ||||
|  | @ -217,6 +223,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 			query.andWhere('note.channelId IS NULL'); | ||||
| 		} | ||||
| 
 | ||||
| 		if (mutingChannelIds.length > 0) { | ||||
| 			// ミュートしてるチャンネルは含めない
 | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }) | ||||
| 					.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (!ps.withReplies) { | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ | |||
| 
 | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; | ||||
| import type { NotesRepository, ChannelFollowingsRepository, ChannelMutingRepository } from '@/models/_.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import ActiveUsersChart from '@/core/chart/charts/active-users.js'; | ||||
|  | @ -17,6 +17,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; | |||
| import { MiLocalUser } from '@/models/User.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; | ||||
| import { ChannelMutingService } from '@/core/ChannelMutingService.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['notes'], | ||||
|  | @ -68,6 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private cacheService: CacheService, | ||||
| 		private fanoutTimelineEndpointService: FanoutTimelineEndpointService, | ||||
| 		private userFollowingService: UserFollowingService, | ||||
| 		private channelMutingService: ChannelMutingService, | ||||
| 		private queryService: QueryService, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
|  | @ -112,6 +114,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], | ||||
| 				alwaysIncludeMyNotes: true, | ||||
| 				excludePureRenotes: !ps.withRenotes, | ||||
| 				excludeMutedChannels: true, | ||||
| 				noteFilter: note => { | ||||
| 					if (note.reply && note.reply.visibility === 'followers') { | ||||
| 						if (!Object.hasOwn(followings, note.reply.userId)) return false; | ||||
|  | @ -146,6 +149,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				followerId: me.id, | ||||
| 			}, | ||||
| 		}); | ||||
| 		const mutingChannelIds = (followingChannels.length > 0) | ||||
| 			? await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)) | ||||
| 			: []; | ||||
| 
 | ||||
| 		//#region Construct query
 | ||||
| 		const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) | ||||
|  | @ -163,7 +169,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				qb | ||||
| 					.where(new Brackets(qb2 => { | ||||
| 						qb2 | ||||
| 							.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) | ||||
| 							.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) | ||||
| 							.andWhere('note.channelId IS NULL'); | ||||
| 					})) | ||||
| 					.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); | ||||
|  | @ -171,9 +177,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		} else if (followees.length > 0) { | ||||
| 			// ユーザーフォローのみ(チャンネルフォローなし)
 | ||||
| 			const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; | ||||
| 			query | ||||
| 				.andWhere('note.channelId IS NULL') | ||||
| 				.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.andWhere('note.channelId IS NULL') | ||||
| 					.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); | ||||
| 			})); | ||||
| 		} else if (followingChannels.length > 0) { | ||||
| 			// チャンネルフォローのみ(ユーザーフォローなし)
 | ||||
| 			const followingChannelIds = followingChannels.map(x => x.followeeId); | ||||
|  | @ -184,9 +192,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 			})); | ||||
| 		} else { | ||||
| 			// フォローなし
 | ||||
| 			query | ||||
| 				.andWhere('note.channelId IS NULL') | ||||
| 				.andWhere('note.userId = :meId', { meId: me.id }); | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.andWhere('note.channelId IS NULL') | ||||
| 					.andWhere('note.userId = :meId', { meId: me.id }); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		if (mutingChannelIds.length > 0) { | ||||
| 			// ミュートしてるチャンネルは含めない
 | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }) | ||||
| 					.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); | ||||
| 			})); | ||||
| 		} | ||||
| 
 | ||||
| 		query.andWhere(new Brackets(qb => { | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ import type { Packed } from '@/misc/json-schema.js'; | |||
| import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; | ||||
| import { isChannelRelated } from '@/misc/is-channel-related.js'; | ||||
| import Channel, { type MiChannelService } from '../channel.js'; | ||||
| 
 | ||||
| class HomeTimelineChannel extends Channel { | ||||
|  | @ -43,7 +44,10 @@ class HomeTimelineChannel extends Channel { | |||
| 		if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; | ||||
| 
 | ||||
| 		if (note.channelId) { | ||||
| 			if (!this.followingChannels.has(note.channelId)) return; | ||||
| 			// そのチャンネルをフォローしていない or そのチャンネル(リノート・引用リノート含む)はミュートしている
 | ||||
| 			if (!this.followingChannels.has(note.channelId) || isChannelRelated(note, this.mutingChannels)) { | ||||
| 				return; | ||||
| 			} | ||||
| 		} else { | ||||
| 			// その投稿のユーザーをフォローしていなかったら弾く
 | ||||
| 			if (!isMe && !Object.hasOwn(this.following, note.userId)) return; | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | |||
| import { bindThis } from '@/decorators.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; | ||||
| import { isChannelRelated } from '@/misc/is-channel-related.js'; | ||||
| import Channel, { type MiChannelService } from '../channel.js'; | ||||
| 
 | ||||
| class HybridTimelineChannel extends Channel { | ||||
|  | @ -55,12 +56,14 @@ class HybridTimelineChannel extends Channel { | |||
| 		// チャンネルの投稿ではなく、自分自身の投稿 または
 | ||||
| 		// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
 | ||||
| 		// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
 | ||||
| 		// フォローしているチャンネルの投稿 の場合だけ
 | ||||
| 		// フォローしているチャンネルの投稿 または
 | ||||
| 		// ミュートしていないチャンネルの投稿(リノート・引用リノートもチェック対象)の場合だけ
 | ||||
| 		if (!( | ||||
| 			(note.channelId == null && isMe) || | ||||
| 			(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)) | ||||
| 			(note.channelId != null && this.followingChannels.has(note.channelId)) || | ||||
| 			(note.channelId != null && !isChannelRelated(note, this.mutingChannels)) | ||||
| 		)) return; | ||||
| 
 | ||||
| 		if (note.visibility === 'followers') { | ||||
|  | @ -82,7 +85,10 @@ class HybridTimelineChannel extends Channel { | |||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; | ||||
| 		// 純粋なリノート(引用リノートでないリノート)の場合
 | ||||
| 		if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) { | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		if (this.user && note.renoteId && !note.text) { | ||||
| 			if (note.renote && Object.keys(note.renote.reactions).length > 0) { | ||||
|  |  | |||
|  | @ -60,6 +60,7 @@ describe('NoteCreateService', () => { | |||
| 			replyUserHost: null, | ||||
| 			renoteUserId: null, | ||||
| 			renoteUserHost: null, | ||||
| 			renoteChannelId: null, | ||||
| 		}; | ||||
| 
 | ||||
| 		const poll: IPoll = { | ||||
|  |  | |||
|  | @ -43,6 +43,7 @@ const base: MiNote = { | |||
| 	replyUserHost: null, | ||||
| 	renoteUserId: null, | ||||
| 	renoteUserHost: null, | ||||
| 	renoteChannelId: null, | ||||
| }; | ||||
| 
 | ||||
| describe('misc:is-renote', () => { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue