diff --git a/CHANGELOG.md b/CHANGELOG.md index 523c9f1035cb..ac4bded2e132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ --> +## 2024.3.1-kinel.3 + +### General +- Enhance: ぶいみみリレーソーシャルタイムラインを追加しました + - ぶいみみリレーソーシャルタイムラインは、ぶいみみリレータイムラインとホームタイムラインのノートが流れます +- Feat: ぶいみみリレータイムラインの`TLに他の人への返信を含める`の動作をローカルタイムラインに揃えました + - `TLに他の人への返信を含める`を有効にすると、ぶいみみリレーに参加しているサーバーのユーザーが他の誰かにリプライしたノートが表示されます。無効にするとこれらが含まれなくなります。 + +### Client + +### Server +- Feat: ぶいみみリレータイムラインがFFTを用いて再実装されました。 + ## 2024.3.1-kinel.2 ### General diff --git a/locales/index.d.ts b/locales/index.d.ts index a41642e36e79..190431624bb4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -8601,6 +8601,10 @@ export interface Locale extends ILocale { * ぶいみみリレー */ "vmimiRelay": string; + /** + * ぶいみみリレーソーシャル + */ + "vmimiRelaySocial": string; }; "_play": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e7e17da47c3d..77219b6eae60 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2270,6 +2270,7 @@ _timelines: social: "ソーシャル" global: "グローバル" vmimiRelay: "ぶいみみリレー" + vmimiRelaySocial: "ぶいみみリレーソーシャル" _play: new: "Playの作成" diff --git a/packages/backend/migration/1713168415416-VmimiRelayTimeline.js b/packages/backend/migration/1713168415416-VmimiRelayTimeline.js new file mode 100644 index 000000000000..edc309aee481 --- /dev/null +++ b/packages/backend/migration/1713168415416-VmimiRelayTimeline.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: anatawa12 and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class VmimiRelayTimeline1713168415416 { + name = 'VmimiRelayTimeline1713168415416' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "vmimiRelayTimelineCacheMax" integer NOT NULL DEFAULT '300'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "vmimiRelayTimelineCacheMax"`); + } +} diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 461ba9e21e67..d0e21aec9c66 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -38,6 +38,11 @@ export type FanoutTimelineName = // role timelines | `roleTimeline:${string}` // any notes are included + // vmimi relay timelines + | 'vmimiRelayTimeline' // replies are not included + | 'vmimiRelayTimelineWithFiles' // only non-reply notes with files are included + | 'vmimiRelayTimelineWithReplies' // only replies are included + @Injectable() export class FanoutTimelineService { constructor( diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index c01535abea57..4e84313d2c75 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -50,6 +50,7 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; @@ -196,6 +197,7 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + private vmimiRelayTimelineService: VmimiRelayTimelineService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private idService: IdService, @@ -958,6 +960,9 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); } } + if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost)) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithReplies', note.id, meta.vmimiRelayTimelineCacheMax, r); + } } else { this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { @@ -970,6 +975,12 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); } } + if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost)) { + this.fanoutTimelineService.push('vmimiRelayTimeline', note.id, meta.vmimiRelayTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithFiles', note.id, meta.vmimiRelayTimelineCacheMax / 2, r); + } + } } if (Math.random() < 0.1) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 09f309711445..2aaa66561f40 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -32,6 +32,7 @@ import { NotificationService } from '@/core/NotificationService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; export type RolePolicies = { + vrtlAvailable: boolean; gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; @@ -60,6 +61,7 @@ export type RolePolicies = { }; export const DEFAULT_POLICIES: RolePolicies = { + vrtlAvailable: true, gtlAvailable: true, ltlAvailable: true, canPublicNote: true, @@ -327,6 +329,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } return { + vrtlAvailable: calc('vrtlAvailable', vs => vs.some(v => v === true)), gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/VmimiRelayTimelineService.ts b/packages/backend/src/core/VmimiRelayTimelineService.ts index 64604a19eeaa..6dd9c5efcaa6 100644 --- a/packages/backend/src/core/VmimiRelayTimelineService.ts +++ b/packages/backend/src/core/VmimiRelayTimelineService.ts @@ -74,23 +74,4 @@ export class VmimiRelayTimelineService { this.checkForUpdateInstanceList(); return this.instanceHostsArray; } - - @bindThis - generateFilterQuery(query: SelectQueryBuilder, excludeReplies: boolean) { - const names = this.hostNames; - query.andWhere(new Brackets(qb => { - qb.where('note.userHost IS NULL'); - if (names.length !== 0) { - qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances: names }); - } - })); - if (excludeReplies) { - query.andWhere(new Brackets(qb => { - qb.where('note.replyUserHost IS NULL'); - if (names.length !== 0) { - qb.orWhere('note.replyUserHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances: names }); - } - })); - } - } } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 66f19ce1975c..65c1c6eb7ec8 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -554,6 +554,11 @@ export class MiMeta { }) public preservedUsernames: string[]; + @Column('integer', { + default: 300, + }) + public vmimiRelayTimelineCacheMax: number; + @Column('boolean', { default: true, }) diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index c7702505039d..ceecbe79b699 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -151,6 +151,10 @@ export const packedRolePoliciesSchema = { type: 'object', optional: false, nullable: false, properties: { + vrtlAvailable: { + type: 'boolean', + optional: false, nullable: false, + }, gtlAvailable: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 711b28e50746..8f0336a84e44 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -36,6 +36,7 @@ import { ChannelChannelService } from './api/stream/channels/channel.js'; import { DriveChannelService } from './api/stream/channels/drive.js'; import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; import { VmimiRelayTimelineChannelService } from './api/stream/channels/vmimi-relay-timeline.js'; +import { VmimiRelayHybridTimelineChannelService } from './api/stream/channels/vmimi-relay-hybrid-timeline.js'; 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'; @@ -80,6 +81,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js DriveChannelService, GlobalTimelineChannelService, VmimiRelayTimelineChannelService, + VmimiRelayHybridTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, ReversiChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index b70bdc19bac9..5a5826506988 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -275,6 +275,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_vmimiRelayTimeline from './endpoints/notes/vmimi-relay-timeline.js'; +import * as ep___notes_vmimiRelayHybridTimeline from './endpoints/notes/vmimi-relay-hybrid-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; @@ -650,6 +651,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_vmimiRelayTimeline: Provider = { provide: 'ep:notes/vmimi-relay-timeline', useClass: ep___notes_vmimiRelayTimeline.default }; +const $notes_vmimiRelayHybridTimeline: Provider = { provide: 'ep:notes/vmimi-relay-hybrid-timeline', useClass: ep___notes_vmimiRelayHybridTimeline.default }; const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.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 }; @@ -1029,6 +1031,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_featured, $notes_globalTimeline, $notes_vmimiRelayTimeline, + $notes_vmimiRelayHybridTimeline, $notes_hybridTimeline, $notes_localTimeline, $notes_mentions, @@ -1402,6 +1405,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_featured, $notes_globalTimeline, $notes_vmimiRelayTimeline, + $notes_vmimiRelayHybridTimeline, $notes_hybridTimeline, $notes_localTimeline, $notes_mentions, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d09aa5f5445b..34f0ec0119a8 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -275,6 +275,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_vmimiRelayTimeline from './endpoints/notes/vmimi-relay-timeline.js'; +import * as ep___notes_vmimiRelayHybridTimeline from './endpoints/notes/vmimi-relay-hybrid-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; @@ -648,6 +649,7 @@ const eps = [ ['notes/featured', ep___notes_featured], ['notes/global-timeline', ep___notes_globalTimeline], ['notes/vmimi-relay-timeline', ep___notes_vmimiRelayTimeline], + ['notes/vmimi-relay-hybrid-timeline', ep___notes_vmimiRelayHybridTimeline], ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], ['notes/mentions', ep___notes_mentions], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 88c5907bcc80..4be463a1fa0d 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -570,6 +570,7 @@ export default class extends Endpoint { // eslint- bannedEmailDomains: instance.bannedEmailDomains, policies: { ...DEFAULT_POLICIES, ...instance.policies }, manifestJsonOverride: instance.manifestJsonOverride, + vmimiRelayTimelineCacheMax: instance.vmimiRelayTimelineCacheMax, enableFanoutTimeline: instance.enableFanoutTimeline, enableFanoutTimelineDbFallback: instance.enableFanoutTimelineDbFallback, perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index bffceef8151d..2be40d781590 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -138,6 +138,7 @@ export const paramDef = { manifestJsonOverride: { type: 'string' }, enableFanoutTimeline: { type: 'boolean' }, enableFanoutTimelineDbFallback: { type: 'boolean' }, + vmimiRelayTimelineCacheMax: { type: 'integer' }, perLocalUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, @@ -561,6 +562,10 @@ export default class extends Endpoint { // eslint- set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; } + if (ps.vmimiRelayTimelineCacheMax !== undefined) { + set.vmimiRelayTimelineCacheMax = ps.vmimiRelayTimelineCacheMax; + } + if (ps.perRemoteUserUserTimelineCacheMax !== undefined) { set.perRemoteUserUserTimelineCacheMax = ps.perRemoteUserUserTimelineCacheMax; } diff --git a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts new file mode 100644 index 000000000000..027e16a3470f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: anatawa12 + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { ChannelFollowingsRepository, NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; +import { IdService } from '@/core/IdService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'read:account', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + vmimiRelaySocialDisabled: { + message: 'Vmimi Relay Hybrid timeline has been disabled.', + code: 'VMIMI_RELAY_DISABLED', + id: 'e7496627-8086-4294-b488-63323eb80145', + }, + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: '8222638e-a5a9-495d-ae72-e825793e0a63', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + withFiles: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, + withReplies: { type: 'boolean', default: false }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + allowPartial: { type: 'boolean', default: true }, // this timeline is new so true by default + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + 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.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private idService: IdService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + private userFollowingService: UserFollowingService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + 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 policies = await this.roleService.getUserPolicies(me.id); + if (!policies.vrtlAvailable) { + throw new ApiError(meta.errors.vmimiRelaySocialDisabled); + } + + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + + const serverSettings = await this.metaService.fetch(); + + if (!serverSettings.enableFanoutTimeline) { + const timeline = await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + } + + let timelineConfig: FanoutTimelineName[]; + + if (ps.withFiles) { + timelineConfig = [ + `homeTimelineWithFiles:${me.id}`, + 'vmimiRelayTimelineWithFiles', + ]; + } else if (ps.withReplies) { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'vmimiRelayTimeline', + 'vmimiRelayTimelineWithReplies', + ]; + } else { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'vmimiRelayTimeline', + ]; + } + + const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + redisTimelines: timelineConfig, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me), + }); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return redisTimeline; + }); + } + + private async getFromDb(ps: { + untilId: string | null, + sinceId: string | null, + limit: number, + withFiles: boolean, + withReplies: boolean, + }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); + const vmimiRelayInstances = this.vmimiRelayTimelineService.hostNames; + + 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(new Brackets(qb => { + qb.where('note.visibility = \'public\''); + qb.andWhere(new Brackets(qb => { + qb.where('note.userHost IS NULL'); + if (vmimiRelayInstances.length !== 0) { + qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances }); + } + })); + })); + } else { + qb.where('note.userId = :meId', { meId: me.id }); + qb.orWhere(new Brackets(qb => { + qb.where('note.visibility = \'public\''); + qb.andWhere(new Brackets(qb => { + qb.where('note.userHost IS NULL'); + if (vmimiRelayInstances.length !== 0) { + qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances }); + } + })); + })); + } + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followingChannels.length > 0) { + const followingChannelIds = followingChannels.map(x => x.followeeId); + + query.andWhere(new Brackets(qb => { + qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + qb.orWhere('note.channelId IS NULL'); + })); + } else { + query.andWhere('note.channelId IS NULL'); + } + + 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.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + return await query.limit(ps.limit).getMany(); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts index de2453577abd..032eaf06f714 100644 --- a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts @@ -13,6 +13,10 @@ import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -29,10 +33,15 @@ export const meta = { }, errors: { - gtlDisabled: { - message: 'Global timeline has been disabled.', - code: 'GTL_DISABLED', - id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b', + vmimiRelayDisabled: { + message: 'Vmimi Relay timeline has been disabled.', + code: 'VMIMI_RELAY_DISABLED', + id: '7f0064c3-59a0-4154-8c37-a8898c128ccc', + }, + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: 'dd9c8400-1cb5-4eef-8a31-200c5f933793', }, }, } as const; @@ -44,6 +53,7 @@ export const paramDef = { withRenotes: { type: 'boolean', default: true }, withReplies: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + allowPartial: { type: 'boolean', default: true }, // this timeline is new so true by default sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, @@ -62,49 +72,65 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, private vmimiRelayTimelineService: VmimiRelayTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + 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 policies = await this.roleService.getUserPolicies(me ? me.id : null); - if (!policies.gtlAvailable) { - throw new ApiError(meta.errors.gtlDisabled); + if (!policies.vrtlAvailable) { + throw new ApiError(meta.errors.vmimiRelayDisabled); } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.visibility = \'public\'') - .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'); - - this.vmimiRelayTimelineService.generateFilterQuery(query, !ps.withReplies); - - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - } + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } + const serverSettings = await this.metaService.fetch(); - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.where('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.where('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); + if (!serverSettings.enableFanoutTimeline) { + const timeline = await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + withReplies: ps.withReplies, + }, me); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(timeline, me); } - //#endregion - const timeline = await query.limit(ps.limit).getMany(); + const timeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + redisTimelines: + ps.withFiles ? ['vmimiRelayTimelineWithFiles'] + : ps.withReplies ? ['vmimiRelayTimeline', 'vmimiRelayTimelineWithReplies'] + : ['vmimiRelayTimeline'], + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + withReplies: ps.withReplies, + }, me), + }); process.nextTick(() => { if (me) { @@ -112,7 +138,69 @@ export default class extends Endpoint { // eslint- } }); - return await this.noteEntityService.packMany(timeline, me); + return timeline; }); } + + private async getFromDb(ps: { + sinceId: string | null, + untilId: string | null, + limit: number, + withFiles: boolean, + withRenotes: boolean, + withReplies: boolean, + }, me: MiLocalUser | null) { + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.visibility = \'public\'') + .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'); + + const vmimiRelayInstances = this.vmimiRelayTimelineService.hostNames; + query.andWhere(new Brackets(qb => { + qb.where('note.userHost IS NULL'); + if (vmimiRelayInstances.length !== 0) { + qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances }); + } + })); + + 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'); + })); + })); + } + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (!ps.withRenotes) { + query.andWhere(new Brackets(qb => { + qb.where('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.where('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion + + return await query.limit(ps.limit).getMany(); + } } diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 4223a296767a..39008a0cd9a5 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -10,6 +10,7 @@ import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; import { GlobalTimelineChannelService } from './channels/global-timeline.js'; import { VmimiRelayTimelineChannelService } from './channels/vmimi-relay-timeline.js'; +import { VmimiRelayHybridTimelineChannelService } from './channels/vmimi-relay-hybrid-timeline.js'; import { MainChannelService } from './channels/main.js'; import { ChannelChannelService } from './channels/channel.js'; import { AdminChannelService } from './channels/admin.js'; @@ -33,6 +34,7 @@ export class ChannelsService { private hybridTimelineChannelService: HybridTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService, private vmimiRelayTimelineChannelService: VmimiRelayTimelineChannelService, + private vmimiRelayHybridTimelineChannelService: VmimiRelayHybridTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, private roleTimelineChannelService: RoleTimelineChannelService, @@ -56,6 +58,7 @@ export class ChannelsService { case 'hybridTimeline': return this.hybridTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService; case 'vmimiRelayTimeline': return this.vmimiRelayTimelineChannelService; + case 'vmimiRelayHybridTimeline': return this.vmimiRelayHybridTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; case 'roleTimeline': return this.roleTimelineChannelService; diff --git a/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts new file mode 100644 index 000000000000..95cc3235fe84 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +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 { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class VmimiRelayHybridTimelineChannel extends Channel { + public readonly chName = 'vmimiRelayHybridTimeline'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:account'; + private withRenotes: boolean; + private withReplies: boolean; + private withFiles: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any): Promise { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.vrtlAvailable) return; + + this.withRenotes = params.withRenotes ?? true; + this.withReplies = params.withReplies ?? false; + this.withFiles = params.withFiles ?? false; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + const isMe = this.user!.id === note.userId; + + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; + + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のぶいみみリレーまたはローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if (!( + (note.channelId == null && isMe) || + (note.channelId == null && Object.hasOwn(this.following, note.userId)) || + (note.channelId == null && (this.vmimiRelayTimelineService.isRelayedInstance(note.user.host) && note.visibility === 'public')) || + (note.channelId != null && this.followingChannels.has(note.channelId)) + )) return; + + if (note.visibility === 'followers') { + if (!isMe && !Object.hasOwn(this.following, note.userId)) return; + } else if (note.visibility === 'specified') { + if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; + } + + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances))) return; + + if (note.reply) { + const reply = note.reply; + 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; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + if (this.user && note.renoteId && !note.text) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + console.log(note.renote.reactionAndUserPairCache); + const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); + note.renote.myReaction = myRenoteReaction; + } + } + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose(): void { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class VmimiRelayHybridTimelineChannelService implements MiChannelService { + public readonly shouldShare = VmimiRelayHybridTimelineChannel.shouldShare; + public readonly requireCredential = VmimiRelayHybridTimelineChannel.requireCredential; + public readonly kind = VmimiRelayHybridTimelineChannel.kind; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): VmimiRelayHybridTimelineChannel { + return new VmimiRelayHybridTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + this.vmimiRelayTimelineService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts b/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts index 6ba8558dbaac..38fd9bd7b878 100644 --- a/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts @@ -37,7 +37,7 @@ class VmimiRelayTimelineChannel extends Channel { @bindThis public async init(params: any) { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); - if (!policies.gtlAvailable) return; + if (!policies.vrtlAvailable) return; this.withRenotes = params.withRenotes ?? true; this.withFiles = params.withFiles ?? false; @@ -56,11 +56,15 @@ class VmimiRelayTimelineChannel extends Channel { if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + // 関係ない返信は除外 + if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; + } + // Ignore notes from non-vmimi relay if (!this.vmimiRelayTimelineService.isRelayedInstance(note.user.host ?? null)) return; - if (!this.withReplies && note.reply) { - if (!this.vmimiRelayTimelineService.isRelayedInstance(note.reply.user.host ?? null)) return; - } // Ignore notes from instances the user has muted if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index c7871ba96e82..24e43ff5b0d0 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -29,7 +29,7 @@ import { defaultStore } from '@/store.js'; import { Paging } from '@/components/MkPagination.vue'; const props = withDefaults(defineProps<{ - src: 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; + src: 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | 'vmimi-relay-social' | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; list?: string; antenna?: string; channel?: string; @@ -127,6 +127,12 @@ function connectChannel() { withFiles: props.onlyFiles ? true : undefined, withReplies: props.withReplies, }); + } else if (props.src === 'vmimi-relay-social') { + connection = stream.useChannel('vmimiRelayHybridTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + }); } else if (props.src === 'mentions') { connection = stream.useChannel('main'); connection.on('mention', prepend); @@ -206,6 +212,13 @@ function updatePaginationQuery() { withFiles: props.onlyFiles ? true : undefined, withReplies: props.withReplies, }; + } else if (props.src === 'vmimi-relay-social') { + endpoint = 'notes/vmimi-relay-hybrid-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + }; } else if (props.src === 'mentions') { endpoint = 'notes/mentions'; query = null; diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 5003e25a2c81..7d4911f4833c 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -57,6 +57,7 @@ import { miLocalStorage } from '@/local-storage.js'; provide('shouldOmitHeaderTitle', true); const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); +const isVmimiRelayTimelineAvailable = ($i == null && instance.policies.vrtlAvailable) || ($i != null && $i.policies.vrtlAvailable); const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); const keymap = { 't': focus, @@ -67,7 +68,7 @@ const rootEl = shallowRef(); const queue = ref(0); const srcWhenNotSignin = ref<'local' | 'global'>(isLocalTimelineAvailable ? 'local' : 'global'); -const src = computed<'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | `list:${string}`>({ +const src = computed<'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | 'vmimi-relay-social' | `list:${string}`>({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x), }); @@ -82,7 +83,7 @@ const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>( const withReplies = computed({ get: () => { if (!$i) return false; - if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { + if (['local', 'social', 'vmimi-relay', 'vmimi-relay-social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { return false; } else { return defaultStore.reactiveState.tl.value.filter.withReplies; @@ -92,7 +93,7 @@ const withReplies = computed({ }); const onlyFiles = computed({ get: () => { - if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { + if (['local', 'social', 'vmimi-relay', 'vmimi-relay-social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { return false; } else { return defaultStore.reactiveState.tl.value.filter.onlyFiles; @@ -199,7 +200,7 @@ async function chooseChannel(ev: MouseEvent): Promise { os.popupMenu(items, ev.currentTarget ?? ev.target); } -function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | `list:${string}`): void { +function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | 'vmimi-relay-social' | `list:${string}`): void { const out = deepMerge({ src: newSrc }, defaultStore.state.tl); if (newSrc.startsWith('userList:')) { @@ -250,7 +251,7 @@ const headerActions = computed(() => { type: 'switch', text: i18n.ts.showRenotes, ref: withRenotes, - }, src.value === 'local' || src.value === 'social' || src.value === 'vmimi-relay' ? { + }, src.value === 'local' || src.value === 'social' || src.value === 'vmimi-relay' || src.value === 'vmimi-relay-social' ? { type: 'switch', text: i18n.ts.showRepliesToOthersInTimeline, ref: withReplies, @@ -263,7 +264,7 @@ const headerActions = computed(() => { type: 'switch', text: i18n.ts.fileAttachedOnly, ref: onlyFiles, - disabled: src.value === 'local' || src.value === 'social' ? withReplies : false, + disabled: src.value === 'local' || src.value === 'social' || src.value === 'vmimi-relay' || src.value === 'vmimi-relay-social' ? withReplies : false, }], ev.currentTarget ?? ev.target); }, }, @@ -305,11 +306,16 @@ const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserList title: i18n.ts._timelines.global, icon: 'ti ti-whirl', iconOnly: true, -}, { +}] : []), ...(isVmimiRelayTimelineAvailable ? [{ key: 'vmimi-relay', title: i18n.ts._timelines.vmimiRelay, icon: 'ti ti-circles-relation', iconOnly: true, +}, { + key: 'vmimi-relay-social', + title: i18n.ts._timelines.vmimiRelaySocial, + icon: 'ti ti-topology-full', + iconOnly: true, }] : []), { icon: 'ti ti-list', title: i18n.ts.lists, @@ -344,7 +350,7 @@ const headerTabsWhenNotLogin = computed(() => [ definePageMetadata(() => ({ title: i18n.ts.timeline, - icon: src.value === 'local' ? 'ti ti-planet' : src.value === 'social' ? 'ti ti-universe' : src.value === 'global' ? 'ti ti-whirl' : src.value === 'vmimi-relay' ? 'ti ti-circles-relation' : 'ti ti-home', + icon: src.value === 'local' ? 'ti ti-planet' : src.value === 'social' ? 'ti ti-universe' : src.value === 'global' ? 'ti ti-whirl' : src.value === 'vmimi-relay' ? 'ti ti-circles-relation' : src.value === 'vmimi-relay-social' ? 'ti ti-topology-full' : 'ti ti-home', })); diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 1bcf2b969941..12d744579a5f 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -188,7 +188,7 @@ export const defaultStore = markRaw(new Storage('base', { tl: { where: 'deviceAccount', default: { - src: 'home' as 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | `list:${string}`, + src: 'home' as 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | 'vmimi-relay-social' | `list:${string}`, userList: null as Misskey.entities.UserList | null, filter: { withReplies: true, diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index a41b5ecb28c5..bb61e754feac 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -29,7 +29,7 @@ export type Column = { channelId?: string; roleId?: string; excludeTypes?: typeof notificationTypes[number][]; - tl?: 'home' | 'local' | 'social' | 'global' | 'vmimi-relay'; + tl?: 'home' | 'local' | 'social' | 'global' | 'vmimi-relay' | 'vmimi-relay-social'; withRenotes?: boolean; withReplies?: boolean; onlyFiles?: boolean; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 0090f4fd694f..a930a8c2dcef 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only + {{ column.name }} @@ -98,6 +99,8 @@ async function setType() { value: 'global' as const, text: i18n.ts._timelines.global, }, { value: 'vmimi-relay' as const, text: i18n.ts._timelines.vmimiRelay, + }, { + value: 'vmimi-relay-social' as const, text: i18n.ts._timelines.vmimiRelaySocial, }], }); if (canceled) { @@ -119,7 +122,7 @@ const menu = [{ type: 'switch', text: i18n.ts.showRenotes, ref: withRenotes, -}, props.column.tl === 'local' || props.column.tl === 'social' || props.column.tl === 'vmimi-relay' ? { +}, props.column.tl === 'local' || props.column.tl === 'social' || props.column.tl === 'vmimi-relay-social' || props.column.tl === 'vmimi-relay' ? { type: 'switch', text: i18n.ts.showRepliesToOthersInTimeline, ref: withReplies, @@ -128,7 +131,7 @@ const menu = [{ type: 'switch', text: i18n.ts.fileAttachedOnly, ref: onlyFiles, - disabled: props.column.tl === 'local' || props.column.tl === 'social' ? withReplies : false, + disabled: props.column.tl === 'local' || props.column.tl === 'social' || props.column.tl === 'vmimi-relay-social' || props.column.tl === 'vmimi-relay' ? withReplies : false, }]; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 30e215603294..afb36559e51f 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -612,6 +612,17 @@ export type Channels = { }; receives: null; }; + vmimiRelayHybridTimeline: { + params: { + withRenotes?: boolean; + withReplies?: boolean; + withFiles?: boolean; + }; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; userList: { params: { listId: string; @@ -1543,6 +1554,8 @@ declare namespace entities { NotesGlobalTimelineResponse, NotesVmimiRelayTimelineRequest, NotesVmimiRelayTimelineResponse, + NotesVmimiRelayHybridTimelineRequest, + NotesVmimiRelayHybridTimelineResponse, NotesHybridTimelineRequest, NotesHybridTimelineResponse, NotesLocalTimelineRequest, @@ -2592,6 +2605,12 @@ type NotesUserListTimelineRequest = operations['notes/user-list-timeline']['requ // @public (undocumented) type NotesUserListTimelineResponse = operations['notes/user-list-timeline']['responses']['200']['content']['application/json']; +// @public (undocumented) +type NotesVmimiRelayHybridTimelineRequest = operations['notes/vmimi-relay-hybrid-timeline']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesVmimiRelayHybridTimelineResponse = operations['notes/vmimi-relay-hybrid-timeline']['responses']['200']['content']['application/json']; + // @public (undocumented) type NotesVmimiRelayTimelineRequest = operations['notes/vmimi-relay-timeline']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index fb6e1c9231f7..3d5ed84616df 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -2997,6 +2997,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 35511287cbbd..63e168e5e42e 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -400,6 +400,8 @@ import type { NotesGlobalTimelineResponse, NotesVmimiRelayTimelineRequest, NotesVmimiRelayTimelineResponse, + NotesVmimiRelayHybridTimelineRequest, + NotesVmimiRelayHybridTimelineResponse, NotesHybridTimelineRequest, NotesHybridTimelineResponse, NotesLocalTimelineRequest, @@ -828,6 +830,7 @@ export type Endpoints = { 'notes/featured': { req: NotesFeaturedRequest; res: NotesFeaturedResponse }; 'notes/global-timeline': { req: NotesGlobalTimelineRequest; res: NotesGlobalTimelineResponse }; 'notes/vmimi-relay-timeline': { req: NotesVmimiRelayTimelineRequest; res: NotesVmimiRelayTimelineResponse }; + 'notes/vmimi-relay-hybrid-timeline': { req: NotesVmimiRelayHybridTimelineRequest; res: NotesVmimiRelayHybridTimelineResponse }; 'notes/hybrid-timeline': { req: NotesHybridTimelineRequest; res: NotesHybridTimelineResponse }; 'notes/local-timeline': { req: NotesLocalTimelineRequest; res: NotesLocalTimelineResponse }; 'notes/mentions': { req: NotesMentionsRequest; res: NotesMentionsResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 7c7af9fd474a..93e0c44c13a2 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -402,6 +402,8 @@ export type NotesGlobalTimelineRequest = operations['notes/global-timeline']['re export type NotesGlobalTimelineResponse = operations['notes/global-timeline']['responses']['200']['content']['application/json']; export type NotesVmimiRelayTimelineRequest = operations['notes/vmimi-relay-timeline']['requestBody']['content']['application/json']; export type NotesVmimiRelayTimelineResponse = operations['notes/vmimi-relay-timeline']['responses']['200']['content']['application/json']; +export type NotesVmimiRelayHybridTimelineRequest = operations['notes/vmimi-relay-hybrid-timeline']['requestBody']['content']['application/json']; +export type NotesVmimiRelayHybridTimelineResponse = operations['notes/vmimi-relay-hybrid-timeline']['responses']['200']['content']['application/json']; export type NotesHybridTimelineRequest = operations['notes/hybrid-timeline']['requestBody']['content']['application/json']; export type NotesHybridTimelineResponse = operations['notes/hybrid-timeline']['responses']['200']['content']['application/json']; export type NotesLocalTimelineRequest = operations['notes/local-timeline']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index d8479dd44262..f84b81beba72 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2601,6 +2601,15 @@ export type paths = { */ post: operations['notes/vmimi-relay-timeline']; }; + '/notes/vmimi-relay-hybrid-timeline': { + /** + * notes/vmimi-relay-hybrid-timeline + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['notes/vmimi-relay-hybrid-timeline']; + }; '/notes/hybrid-timeline': { /** * notes/hybrid-timeline @@ -4677,6 +4686,7 @@ export type components = { usersCount: number; }); RolePolicies: { + vrtlAvailable: boolean; gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; @@ -9048,6 +9058,7 @@ export type operations = { manifestJsonOverride?: string; enableFanoutTimeline?: boolean; enableFanoutTimelineDbFallback?: boolean; + vmimiRelayTimelineCacheMax?: number; perLocalUserUserTimelineCacheMax?: number; perRemoteUserUserTimelineCacheMax?: number; perUserHomeTimelineCacheMax?: number; @@ -20932,6 +20943,76 @@ export type operations = { withReplies?: boolean; /** @default 10 */ limit?: number; + /** @default true */ + allowPartial?: boolean; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + sinceDate?: number; + untilDate?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Note'][]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * notes/vmimi-relay-hybrid-timeline + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + 'notes/vmimi-relay-hybrid-timeline': { + requestBody: { + content: { + 'application/json': { + /** @default false */ + withFiles?: boolean; + /** @default true */ + withRenotes?: boolean; + /** @default false */ + withReplies?: boolean; + /** @default 10 */ + limit?: number; + /** @default true */ + allowPartial?: boolean; /** Format: misskey:id */ sinceId?: string; /** Format: misskey:id */ diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 5374d8e1a5a7..50f6d46a2cad 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -113,6 +113,17 @@ export type Channels = { }; receives: null; }; + vmimiRelayHybridTimeline: { + params: { + withRenotes?: boolean; + withReplies?: boolean; + withFiles?: boolean; + }; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; userList: { params: { listId: string;