From e1b22165db643f49363b4acebc87ebf9892feac2 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sun, 3 Sep 2023 19:42:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E8=80=85=E6=A8=A9?= =?UTF-8?q?=E9=99=90=E3=82=92=E6=8C=81=E3=81=A3=E3=81=A6=E3=82=8B=E4=BA=BA?= =?UTF-8?q?=E3=81=AF=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=AB=E3=81=AE=E9=9D=9E?= =?UTF-8?q?=E5=85=AC=E9=96=8B=E6=8A=95=E7=A8=BF(=E3=83=9B=E3=83=BC?= =?UTF-8?q?=E3=83=A0)=E3=82=82=E8=A6=8B=E3=82=8C=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AATL=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/server/ServerModule.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../endpoints/notes/hybrid-all-timeline.ts | 148 ++++++++++++++++++ .../src/server/api/stream/ChannelsService.ts | 4 +- .../stream/channels/hybrid-all-timeline.ts | 140 +++++++++++++++++ .../frontend/src/components/MkTimeline.vue | 9 ++ packages/frontend/src/pages/timeline.vue | 8 +- 8 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/notes/hybrid-all-timeline.ts create mode 100644 packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index fa81380f01..ed2c981136 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -34,6 +34,7 @@ import { GlobalTimelineChannelService } from './api/stream/channels/global-timel import { HashtagChannelService } from './api/stream/channels/hashtag.js'; import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; +import { HybridAllTimelineChannelService } from './api/stream/channels/hybrid-all-timeline.js'; import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; @@ -79,6 +80,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; RoleTimelineChannelService, HomeTimelineChannelService, HybridTimelineChannelService, + HybridAllTimelineChannelService, LocalTimelineChannelService, QueueStatsChannelService, ServerStatsChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 799ba4498d..e4b6ad9c4f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -262,6 +262,7 @@ import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; +import * as ep___notes_hybrid_All_Timeline from './endpoints/notes/hybrid-all-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; @@ -608,6 +609,7 @@ const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete' const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; +const $notes_hybridAllTimeline: Provider = { provide: 'ep:notes/hybrid-all-timeline', useClass: ep___notes_hybrid_All_Timeline.default }; const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; @@ -958,6 +960,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_featured, $notes_globalTimeline, $notes_hybridTimeline, + $notes_hybridAllTimeline, $notes_localTimeline, $notes_mentions, $notes_polls_recommendation, @@ -1302,6 +1305,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_featured, $notes_globalTimeline, $notes_hybridTimeline, + $notes_hybridAllTimeline, $notes_localTimeline, $notes_mentions, $notes_polls_recommendation, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3924b43d16..347b6973a8 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -262,6 +262,7 @@ import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; +import * as ep___notes_hybrid_All_Timeline from './endpoints/notes/hybrid-all-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; @@ -606,6 +607,7 @@ const eps = [ ['notes/featured', ep___notes_featured], ['notes/global-timeline', ep___notes_globalTimeline], ['notes/hybrid-timeline', ep___notes_hybridTimeline], + ['notes/hybrid-all-timeline', ep___notes_hybrid_All_Timeline], ['notes/local-timeline', ep___notes_localTimeline], ['notes/mentions', ep___notes_mentions], ['notes/polls/recommendation', ep___notes_polls_recommendation], diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-all-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-all-timeline.ts new file mode 100644 index 0000000000..b00df57043 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-all-timeline.ts @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Brackets } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, FollowingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + stlDisabled: { + message: 'Hybrid All timeline has been disabled.', + code: 'STL_DISABLED', + id: '620763f4-f621-4533-ab33-0577a1a3c342', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + includeMyRenotes: { type: 'boolean', default: true }, + includeRenotedMyNotes: { type: 'boolean', default: true }, + includeLocalRenotes: { type: 'boolean', default: true }, + withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + if (!me?.isRoot) throw new Error('access denied'); + + //#region Construct query + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) + .orWhere('(note.visibility = \'public\' OR note.visibility = \'home\') 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') + .setParameters(followingQuery.getParameters()); + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(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); + }); + } +} diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 8fd106c10c..c418d314a6 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -19,7 +19,7 @@ import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js'; - +import { HybridAllTimelineChannelService } from './channels/hybrid-all-timeline.js'; @Injectable() export class ChannelsService { constructor( @@ -27,6 +27,7 @@ export class ChannelsService { private homeTimelineChannelService: HomeTimelineChannelService, private localTimelineChannelService: LocalTimelineChannelService, private hybridTimelineChannelService: HybridTimelineChannelService, + private hybridAllTimelineChannelService: HybridAllTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, @@ -47,6 +48,7 @@ export class ChannelsService { case 'homeTimeline': return this.homeTimelineChannelService; case 'localTimeline': return this.localTimelineChannelService; case 'hybridTimeline': return this.hybridTimelineChannelService; + case 'hybridAllTimeline': return this.hybridAllTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts new file mode 100644 index 0000000000..20c99bfcbf --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hybrid-all-timeline.ts @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import Channel from '../channel.js'; + +class HybridAllTimelineChannel extends Channel { + public readonly chName = 'hybridAllTimeline'; + public static shouldShare = true; + public static requireCredential = true; + private withReplies: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any): Promise { + if (this.user == null || !this.user.isRoot ) return; + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のローカルの投稿 または + // チャンネルの投稿ではなく、ホームのローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if (!( + (note.channelId == null && this.user!.id === note.userId) || + (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || + (note.channelId == null && (note.user.host == null && note.visibility === 'home')) || + (note.channelId != null && this.followingChannels.has(note.channelId)) + )) return; + + if (['followers', 'specified'].includes(note.visibility)) { + note = await this.noteEntityService.pack(note.id, this.user!, { + detail: true, + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await this.noteEntityService.pack(note.replyId, this.user!, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user!, { + detail: true, + }); + } + } + + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose(): void { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class HybridAllTimelineChannelService { + public readonly shouldShare = HybridAllTimelineChannel.shouldShare; + public readonly requireCredential = HybridAllTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): HybridAllTimelineChannel { + return new HybridAllTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index cb0aaf085c..2e09a75626 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -88,6 +88,15 @@ if (props.src === 'antenna') { withReplies: defaultStore.state.showTimelineReplies, }); connection.on('note', prepend); +} else if (props.src === 'all') { + endpoint = 'notes/hybrid-all-timeline'; + query = { + withReplies: defaultStore.state.showTimelineReplies, + }; + connection = stream.useChannel(' ', { + withReplies: defaultStore.state.showTimelineReplies, + }); + connection.on('note', prepend); } else if (props.src === 'global') { endpoint = 'notes/global-timeline'; query = { diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index f5fadb3899..860f732b08 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -45,6 +45,7 @@ const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); +const isAdmin = ($i != null && $i.isAdmin); const keymap = { 't': focus, }; @@ -100,7 +101,7 @@ async function chooseChannel(ev: MouseEvent): Promise { os.popupMenu(items, ev.currentTarget ?? ev.target); } -function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void { +function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | 'all'): void { defaultStore.set('tl', { ...defaultStore.state.tl, src: newSrc, @@ -143,6 +144,11 @@ const headerTabs = $computed(() => [{ title: i18n.ts._timelines.global, icon: 'ti ti-whirl', iconOnly: true, +}] : []), ...(isAdmin ? [{ + key: 'all', + title: 'all', + icon: 'ti ti-whirl', + iconOnly: true, }] : []), { icon: 'ti ti-list', title: i18n.ts.lists,