diff options
| author | Mar0xy <marie@kaifa.ch> | 2023-10-13 19:01:17 +0200 |
|---|---|---|
| committer | Mar0xy <marie@kaifa.ch> | 2023-10-13 19:01:17 +0200 |
| commit | f8f128b3477e14dc224c7c454f63379ac4c828dd (patch) | |
| tree | 1ac7ae77a43beb4d2830e61762eb7482e12a6019 /packages/backend/src/core/NoteEditService.ts | |
| parent | merge: timeline 1 (diff) | |
| parent | enhance(frontend): TLの返信表示オプションを記憶するように (diff) | |
| download | sharkey-f8f128b3477e14dc224c7c454f63379ac4c828dd.tar.gz sharkey-f8f128b3477e14dc224c7c454f63379ac4c828dd.tar.bz2 sharkey-f8f128b3477e14dc224c7c454f63379ac4c828dd.zip | |
merge: all upstream changes
Diffstat (limited to 'packages/backend/src/core/NoteEditService.ts')
| -rw-r--r-- | packages/backend/src/core/NoteEditService.ts | 261 |
1 files changed, 207 insertions, 54 deletions
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 1901f2db7d..f3bc4a601d 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -5,7 +5,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { DataSource, In, LessThan } from 'typeorm'; +import { DataSource, In, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import RE2 from 're2'; @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -48,8 +48,11 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; - -const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); +import { FeaturedService } from '@/core/FeaturedService.js'; +import { RedisTimelineService } from '@/core/RedisTimelineService.js'; +import { AntennaService } from './AntennaService.js'; +import NotesChart from './chart/charts/notes.js'; +import PerUserNotesChart from './chart/charts/per-user-notes.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -151,12 +154,12 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, - @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -172,15 +175,21 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.noteEditRepository) private noteEditRepository: NoteEditRepository, @@ -189,18 +198,23 @@ export class NoteEditService implements OnApplicationShutdown { private idService: IdService, private globalEventService: GlobalEventService, private queueService: QueueService, + private redisTimelineService: RedisTimelineService, private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, private hashtagService: HashtagService, + private antennaService: AntennaService, private webhookService: WebhookService, + private featuredService: FeaturedService, private remoteUserResolveService: RemoteUserResolveService, private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, private metaService: MetaService, private searchService: SearchService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, private instanceChart: InstanceChart, ) { } @@ -261,19 +275,30 @@ export class NoteEditService implements OnApplicationShutdown { } } - // Renote対象が「ホームまたは全体」以外の公開範囲ならreject - if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); - } - - // Renote対象がpublicではないならhomeにする - if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } + if (data.renote) { + switch (data.renote.visibility) { + case 'public': + // public noteは無条件にrenote可能 + break; + case 'home': + // home noteはhome以下にrenote可能 + if (data.visibility === 'public') { + data.visibility = 'home'; + } + break; + case 'followers': + // 他人のfollowers noteはreject + if (data.renote.userId !== user.id) { + throw new Error('Renote target is not public or home'); + } - // Renote対象がfollowersならfollowersにする - if (data.renote && data.renote.visibility === 'followers') { - data.visibility = 'followers'; + // Renote対象がfollowersならfollowersにする + data.visibility = 'followers'; + break; + case 'specified': + // specified / direct noteはreject + throw new Error('Renote target is not public or home'); + } } // 返信対象がpublicではないならhomeにする @@ -448,14 +473,6 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); } - if (data.channel) { - this.redisClient.xadd( - `channelTimeline:${data.channel.id}`, - 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), - '*', - 'note', note.id); - } - setImmediate('post edited', { signal: this.#shutdownController.signal }).then( () => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, @@ -483,30 +500,7 @@ export class NoteEditService implements OnApplicationShutdown { } // ハッシュタグ更新 - if (data.visibility === 'public' || data.visibility === 'home') { - this.hashtagService.updateHashtags(user, tags); - } - - // Word mute - mutedWordsCache.fetch(() => this.userProfilesRepository.find({ - where: { - enableWordMute: true, - }, - select: ['userId', 'mutedWords'], - })).then(us => { - for (const u of us) { - checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { - if (shouldMute) { - this.mutedNotesRepository.insert({ - id: this.idService.genId(), - userId: u.userId, - noteId: note.id, - reason: 'word', - }); - } - }); - } - }); + this.pushToTl(note, user); if (data.poll && data.poll.expiresAt) { const delay = data.poll.expiresAt.getTime() - Date.now(); @@ -780,6 +774,165 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis + private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { + const meta = await this.metaService.fetch(); + + const r = this.redisForTimelines.pipeline(); + + if (note.channelId) { + this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); + + this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + + const channelFollowings = await this.channelFollowingsRepository.find({ + where: { + followeeId: note.channelId, + }, + select: ['followerId'], + }); + + for (const channelFollowing of channelFollowings) { + this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + } + } + } else { + // TODO: キャッシュ? + // eslint-disable-next-line prefer-const + let [followings, userListMemberships] = await Promise.all([ + this.followingsRepository.find({ + where: { + followeeId: user.id, + followerHost: IsNull(), + isFollowerHibernated: false, + }, + select: ['followerId', 'withReplies'], + }), + this.userListMembershipsRepository.find({ + where: { + userId: user.id, + }, + select: ['userListId', 'userListUserId', 'withReplies'], + }), + ]); + + if (note.visibility === 'followers') { + // TODO: 重そうだから何とかしたい Set 使う? + userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId)); + } + + // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする + for (const following of followings) { + // 基本的にvisibleUserIdsには自身のidが含まれている前提であること + if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; + + // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 + if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) { + if (!following.withReplies) continue; + } + + this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + } + } + + for (const userListMembership of userListMemberships) { + // ダイレクトのとき、そのリストが対象外のユーザーの場合 + if ( + note.visibility === 'specified' && + !note.visibleUserIds.some(v => v === userListMembership.userListUserId) + ) continue; + + // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 + if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) { + if (!userListMembership.withReplies) continue; + } + + this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); + } + } + + if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL + this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + } + } + + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + + if (note.visibility === 'public' && note.userHost == null) { + this.redisTimelineService.push('localTimelineWithReplies', note.id, 300, r); + } + } else { + this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + } + + if (note.visibility === 'public' && note.userHost == null) { + this.redisTimelineService.push('localTimeline', note.id, 1000, r); + if (note.fileIds.length > 0) { + this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r); + } + } + } + + if (Math.random() < 0.1) { + process.nextTick(() => { + this.checkHibernation(followings); + }); + } + } + + r.exec(); + } + + @bindThis + public async checkHibernation(followings: MiFollowing[]) { + if (followings.length === 0) return; + + const shuffle = (array: MiFollowing[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // ランダムに最大1000件サンプリング + const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); + + const hibernatedUsers = await this.usersRepository.find({ + where: { + id: In(samples.map(x => x.followerId)), + lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), + }, + select: ['id'], + }); + + if (hibernatedUsers.length > 0) { + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }); + + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }); + } + } + + @bindThis public dispose(): void { this.#shutdownController.abort(); } |