diff --git a/locales/index.d.ts b/locales/index.d.ts index bd2e6ea798d9..c768b1e351df 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4988,6 +4988,10 @@ export interface Locale extends ILocale { * センシティブなファイルを含むノートを表示 */ readonly "withSensitive": string; + /** + * ローカルのみのノートを表示 + */ + readonly "showLocalOnlyInTimeline": string; /** * {name}のセンシティブなファイルを含む投稿 */ @@ -5374,6 +5378,10 @@ export interface Locale extends ILocale { * VirtualKemomimiリレーに参加しているサーバーからの投稿を見られます。 */ readonly "vmimi": string; + /** + * Vみみタイムラインの投稿とホームタイムラインの投稿が両方表示されます。 + */ + readonly "vmimiHybrid": string; /** * それぞれのタイムラインは、画面上部でいつでも切り替えられます。 */ @@ -5512,9 +5520,13 @@ export interface Locale extends ILocale { */ readonly "global": string; /** - * Vミミタイムラインでは、VirtualKemomimiリレーに参加しているサーバーからの投稿を見られます。 + * Vみみタイムラインでは、VirtualKemomimiリレーに参加しているサーバーからの投稿を見られます。 */ readonly "vmimi": string; + /** + * Vみみソーシャルタイムラインでは、Vみみタイムラインとホームタイムラインの両方の投稿を表示することができます。 + */ + readonly "vmimiHybrid": string; }; readonly "_serverRules": { /** @@ -8802,9 +8814,13 @@ export interface Locale extends ILocale { */ readonly "global": string; /** - * Vミミ + * Vみみ */ readonly "vmimi": string; + /** + * Vみみソーシャル + */ + readonly "vmimiHybrid": string; }; readonly "_play": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 43ca527c626b..4a7bda3efb97 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1243,6 +1243,7 @@ lastNDays: "直近{n}日" backToTitle: "タイトルへ" hemisphere: "お住まいの地域" withSensitive: "センシティブなファイルを含むノートを表示" +showLocalOnlyInTimeline: "ローカルのみのノートを表示" userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿" enableHorizontalSwipe: "スワイプしてタブを切り替える" loading: "読み込み中" @@ -1351,6 +1352,7 @@ _initialTutorial: social: "ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" global: "接続している他のすべてのサーバーからの投稿を見られます。" vmimi: "VirtualKemomimiリレーに参加しているサーバーからの投稿を見られます。" + vmimiHybrid: "Vみみタイムラインの投稿とホームタイムラインの投稿が両方表示されます。" description2: "それぞれのタイムラインは、画面上部でいつでも切り替えられます。" description3: "その他にも、リストタイムラインやチャンネルタイムラインなどがあります。詳しくは{link}をご覧ください。" _postNote: @@ -1390,7 +1392,8 @@ _timelineDescription: local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。" social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" - vmimi: "Vミミタイムラインでは、VirtualKemomimiリレーに参加しているサーバーからの投稿を見られます。" + vmimi: "Vみみタイムラインでは、VirtualKemomimiリレーに参加しているサーバーからの投稿を見られます。" + vmimiHybrid: "Vみみソーシャルタイムラインでは、Vみみタイムラインとホームタイムラインの両方の投稿を表示することができます。" _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" @@ -2322,7 +2325,8 @@ _timelines: local: "ローカル" social: "ソーシャル" global: "グローバル" - vmimi: "Vミミ" + vmimi: "Vみみ" + vmimiHybrid: "Vみみソーシャル" _play: new: "Playの作成" diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index f6dabfadcd6d..3cb942da5a2c 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -38,6 +38,12 @@ export type FanoutTimelineName = // role timelines | `roleTimeline:${string}` // any notes are included + // vmimi relay timeline + | 'vmimiRelayTimeline' // replies not included + | 'vmimiRelayTimelineWithFiles' // only notes with files are included + | 'vmimiRelayTimelineWithReplies' // only replies are included + | `vmimiRelayTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. + @Injectable() export class FanoutTimelineService { constructor( diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1d8d2483228a..23c6b4a0b6d5 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -60,6 +60,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { VmimiRelayService } from '@/core/VmimiRelayService.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -218,6 +219,7 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, + private vmimiRelayService: VmimiRelayService, ) { } @bindThis @@ -956,6 +958,13 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); } } + + if (note.visibility === 'public' && this.vmimiRelayService.isRelayedInstance(note.userHost) && !note.localOnly) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithReplies', note.id, 300 / 10, r); + if (note.replyUserHost == null) { + this.fanoutTimelineService.push(`vmimiRelayTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); + } + } } else { this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { @@ -968,6 +977,13 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); } } + + if (note.visibility === 'public' && this.vmimiRelayService.isRelayedInstance(note.userHost) && !note.localOnly) { + this.fanoutTimelineService.push('vmimiRelayTimeline', note.id, 1000, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithFiles', note.id, 500, r); + } + } } if (Math.random() < 0.1) { diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 0bd0c71bb8c4..11dd5a462756 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -41,6 +41,8 @@ import { HomeTimelineChannelService } from './api/stream/channels/home-timeline. import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; import { VmimiRelayTimelineChannelService } from './api/stream/channels/vmimi-relay-timeline.js'; +import { VmimiHybridTimelineChannelService } from './api/stream/channels/vmimi-hybrid-timeline.js'; + import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; @@ -89,6 +91,8 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js HybridTimelineChannelService, LocalTimelineChannelService, VmimiRelayTimelineChannelService, + VmimiHybridTimelineChannelService, + QueueStatsChannelService, ServerStatsChannelService, UserListChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 67a324346c0a..33a8768816c4 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -286,6 +286,7 @@ 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_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_vmimiRelayTimeline from './endpoints/notes/vmimi-relay-timeline.js'; +import * as ep___notes_vmimiHybridTimeline from './endpoints/notes/vmimi-hybrid-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; @@ -668,6 +669,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_vmimiHybridTimeline: Provider = { provide: 'ep:notes/vmimi-hybrid-timeline', useClass: ep___notes_vmimiHybridTimeline.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 }; @@ -1058,6 +1060,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_hybridTimeline, $notes_localTimeline, $notes_vmimiRelayTimeline, + $notes_vmimiHybridTimeline, $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, @@ -1440,6 +1443,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_hybridTimeline, $notes_localTimeline, $notes_vmimiRelayTimeline, + $notes_vmimiHybridTimeline, $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 08a20849cb70..f0b3c219d893 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -292,6 +292,7 @@ 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_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_vmimiRelayTimeline from './endpoints/notes/vmimi-relay-timeline.js'; +import * as ep___notes_vmimiHybridTimeline from './endpoints/notes/vmimi-hybrid-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; @@ -674,6 +675,7 @@ const eps = [ ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], ['notes/vmimi-relay-timeline', ep___notes_vmimiRelayTimeline], + ['notes/vmimi-hybrid-timeline', ep___notes_vmimiHybridTimeline], ['notes/mentions', ep___notes_mentions], ['notes/polls/recommendation', ep___notes_polls_recommendation], ['notes/polls/vote', ep___notes_polls_vote], diff --git a/packages/backend/src/server/api/endpoints/notes/vmimi-hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/vmimi-hybrid-timeline.ts new file mode 100644 index 000000000000..ada16cd64d6e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/vmimi-hybrid-timeline.ts @@ -0,0 +1,273 @@ +/* + * 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 { VmimiRelayService } from '@/core/VmimiRelayService.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 { CacheService } from '@/core/CacheService.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 }, + withLocalOnly: { type: 'boolean', default: true }, + 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 cacheService: CacheService, + private vmimiRelayService: VmimiRelayService, + 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, + withLocalOnly: ps.withLocalOnly, + }, 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', + ]; + if (ps.withLocalOnly) timelineConfig = [...timelineConfig, 'localTimelineWithFiles']; + } else if (ps.withReplies) { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'vmimiRelayTimeline', + 'vmimiRelayTimelineWithReplies', + ]; + if (ps.withLocalOnly) timelineConfig = [...timelineConfig, 'localTimeline', 'localTimelineWithReplies']; + } else { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'vmimiRelayTimeline', + `vmimiRelayTimelineWithReplyTo:${me.id}`, + ]; + if (ps.withLocalOnly) timelineConfig = [...timelineConfig, 'localTimeline', `localTimelineWithReplyTo:${me.id}`]; + } + + const [ + followings, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + ]); + + 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, + noteFilter: note => { + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; + } + + return true; + }, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withLocalOnly: ps.withLocalOnly, + }, 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, + withLocalOnly: boolean, + }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); + const vmimiRelayInstances = this.vmimiRelayService.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\''); + if (!ps.withLocalOnly) qb.andWhere('note.localOnly = FALSE'); + 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\''); + if (!ps.withLocalOnly) qb.andWhere('note.localOnly = FALSE'); + 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/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 4223a296767a..d57cf303a89f 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 { VmimiHybridTimelineChannelService } from './channels/vmimi-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 vmimiHybridTimelineChannelService: VmimiHybridTimelineChannelService, 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 'vmimiHybridTimeline': return this.vmimiHybridTimelineChannelService; 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-hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/vmimi-hybrid-timeline.ts new file mode 100644 index 000000000000..d2ce0095ec15 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/vmimi-hybrid-timeline.ts @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import type { Packed } from '@/misc/json-schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { VmimiRelayService } from '@/core/VmimiRelayService.js'; +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 type { JsonObject } from '@/misc/json-value.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class VmimiHybridTimelineChannel extends Channel { + public readonly chName = 'vmimiHybridTimeline'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:account'; + private withRenotes: boolean; + private withReplies: boolean; + private withFiles: boolean; + private withLocalOnly: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayService: VmimiRelayService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: JsonObject): Promise { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.ltlAvailable) return; + + this.withRenotes = !!(params.withRenotes ?? true); + this.withReplies = !!(params.withReplies ?? false); + this.withFiles = !!(params.withFiles ?? false); + this.withLocalOnly = !!(params.withLocalOnly ?? true); + + // 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.vmimiRelayService.isRelayedInstance(note.user.host) && note.visibility === 'public') && (this.withLocalOnly || !note.localOnly)) || + (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; + } + + if (this.isNoteMutedOrBlocked(note)) 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) && reply.userId !== this.user!.id) return; + } else { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) 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 VmimiHybridTimelineChannelService implements MiChannelService { + public readonly shouldShare = VmimiHybridTimelineChannel.shouldShare; + public readonly requireCredential = VmimiHybridTimelineChannel.requireCredential; + public readonly kind = VmimiHybridTimelineChannel.kind; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayService: VmimiRelayService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): VmimiHybridTimelineChannel { + return new VmimiHybridTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + this.vmimiRelayService, + 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 61932573153d..5d93f9ed56eb 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 @@ -19,6 +19,7 @@ class VmimiRelayTimelineChannel extends Channel { public static shouldShare = false; public static requireCredential = false as const; private withRenotes: boolean; + private withReplies: boolean; private withFiles: boolean; constructor( @@ -40,6 +41,7 @@ class VmimiRelayTimelineChannel extends Channel { if (!policies.gtlAvailable) return; this.withRenotes = !!(params.withRenotes ?? true); + this.withReplies = !!(params.withReplies ?? true); this.withFiles = !!(params.withFiles ?? false); // Subscribe events @@ -65,6 +67,11 @@ class VmimiRelayTimelineChannel extends Channel { } } + 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; + } + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index f67399c056b0..b54264e6856b 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -39,10 +39,12 @@ const props = withDefaults(defineProps<{ withRenotes?: boolean; withReplies?: boolean; onlyFiles?: boolean; + withLocalOnly?: boolean; }>(), { withRenotes: true, withReplies: false, onlyFiles: false, + withLocalOnly: true, }); const emit = defineEmits<{ @@ -58,6 +60,7 @@ type TimelineQueryType = { withRenotes?: boolean; withReplies?: boolean; withFiles?: boolean; + withLocalOnly?: boolean; visibility?: string; listId?: string; channelId?: string; @@ -128,6 +131,13 @@ function connectChannel() { withFiles: props.onlyFiles ? true : undefined, withReplies: props.withReplies, }); + } else if (props.src === 'vmimiHybrid') { + connection = stream.useChannel('vmimiHybridTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + withLocalOnly: props.withLocalOnly, + }); } else if (props.src === 'mentions') { connection = stream.useChannel('main'); connection.on('mention', prepend); @@ -201,6 +211,14 @@ function updatePaginationQuery() { withFiles: props.onlyFiles ? true : undefined, withReplies: props.withReplies, }; + } else if (props.src === 'vmimiHybrid') { + endpoint = 'notes/vmimi-hybrid-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + withLocalOnly: props.withLocalOnly, + }; } 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 3c0e79613ec8..fb70da16b03b 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -17,12 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -52,7 +53,7 @@ import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.j import { deviceKind } from '@/scripts/device-kind.js'; import { deepMerge } from '@/scripts/merge.js'; import { miLocalStorage } from '@/local-storage.js'; -import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; +import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass, isVrTimeline } from '@/timelines.js'; provide('shouldOmitHeaderTitle', true); @@ -71,6 +72,10 @@ const withRenotes = computed({ get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, set: (x) => saveTlFilter('withRenotes', x), }); +const withLocalOnly = computed({ + get: () => defaultStore.reactiveState.tl.value.filter.withLocalOnly, + set: (x) => saveTlFilter('withLocalOnly', x), +}); // computed内での無限ループを防ぐためのフラグ const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>( @@ -82,7 +87,7 @@ const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>( const withReplies = computed({ get: () => { if (!$i) return false; - if (['local', 'social', 'vmimi'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { + if (['local', 'social', 'vmimi', 'vmimiHybrid'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { return false; } else { return defaultStore.reactiveState.tl.value.filter.withReplies; @@ -92,7 +97,7 @@ const withReplies = computed({ }); const onlyFiles = computed({ get: () => { - if (['local', 'social', 'vmimi'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { + if (['local', 'social', 'vmimi', 'vmimiHybrid'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { return false; } else { return defaultStore.reactiveState.tl.value.filter.onlyFiles; @@ -275,7 +280,11 @@ const headerActions = computed(() => { text: i18n.ts.fileAttachedOnly, ref: onlyFiles, disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false, - }], ev.currentTarget ?? ev.target); + }, isVrTimeline(src.value) ? { + type: 'switch', + text: i18n.ts.showLocalOnlyInTimeline, + ref: withLocalOnly, + } : undefined], ev.currentTarget ?? ev.target); }, }, ]; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index a5c92c1de6fc..5530c66a15dd 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -183,13 +183,14 @@ export const defaultStore = markRaw(new Storage('base', { tl: { where: 'deviceAccount', default: { - src: 'home' as 'home' | 'local' | 'social' | 'global' | `list:${string}`, + src: 'home' as 'home' | 'local' | 'social' | 'global' | 'vmimi' | 'vmimiHybrid' | `list:${string}`, userList: null as Misskey.entities.UserList | null, filter: { withReplies: true, withRenotes: true, withSensitive: true, onlyFiles: false, + withLocalOnly: true, }, }, }, diff --git a/packages/frontend/src/timelines.ts b/packages/frontend/src/timelines.ts index 88650796dd4e..f65fe7cf38ef 100644 --- a/packages/frontend/src/timelines.ts +++ b/packages/frontend/src/timelines.ts @@ -12,6 +12,7 @@ export const basicTimelineTypes = [ 'social', 'global', 'vmimi', + 'vmimiHybrid', ] as const; export type BasicTimelineType = typeof basicTimelineTypes[number]; @@ -31,7 +32,9 @@ export function basicTimelineIconClass(timeline: BasicTimelineType): string { case 'global': return 'ti ti-whirl'; case 'vmimi': - return 'ti ti-whirl'; + return 'ti ti-circles-relation'; + case 'vmimiHybrid': + return 'ti ti-topology-full'; } } @@ -47,6 +50,8 @@ export function isAvailableBasicTimeline(timeline: BasicTimelineType | undefined return ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); case 'vmimi': return ($i == null && instance.policies.vrtlAvailable) || ($i != null && $i.policies.vrtlAvailable); + case 'vmimiHybrid': + return ($i == null && instance.policies.vrtlAvailable) || ($i != null && $i.policies.vrtlAvailable); default: return false; } @@ -57,5 +62,9 @@ export function availableBasicTimelines(): BasicTimelineType[] { } export function hasWithReplies(timeline: BasicTimelineType | undefined | null): boolean { - return timeline === 'local' || timeline === 'social' || timeline === 'vmimi'; + return timeline === 'local' || timeline === 'social' || timeline === 'vmimi' || timeline === 'vmimiHybrid'; +} + +export function isVrTimeline(timeline: BasicTimelineType | undefined | null): boolean { + return timeline === 'vmimi' || timeline === 'vmimiHybrid'; } diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index eb587554b97c..d68cf0ea054c 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -50,6 +50,7 @@ export type Column = { withRenotes?: boolean; withReplies?: boolean; onlyFiles?: boolean; + withLocalOnly?: boolean; soundSetting: SoundStore; }; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index e210ee7b7a2c..b719998e4754 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -38,7 +38,7 @@ import type { MenuItem } from '@/types/menu.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; +import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass, isVrTimeline } from '@/timelines.js'; import { instance } from '@/instance.js'; import { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; @@ -55,6 +55,7 @@ const soundSetting = ref(props.column.soundSetting ?? { type: null, const withRenotes = ref(props.column.withRenotes ?? true); const withReplies = ref(props.column.withReplies ?? false); const onlyFiles = ref(props.column.onlyFiles ?? false); +const withLocalOnly = ref(props.column.withLocalOnly ?? true); watch(withRenotes, v => { updateColumn(props.column.id, { @@ -74,6 +75,12 @@ watch(onlyFiles, v => { }); }); +watch(withLocalOnly, v => { + updateColumn(props.column.id, { + onlyFiles: v, + }); +}); + watch(soundSetting, v => { updateColumn(props.column.id, { soundSetting: v }); }); @@ -95,6 +102,10 @@ async function setType() { value: 'social' as const, text: i18n.ts._timelines.social, }, { value: 'global' as const, text: i18n.ts._timelines.global, + }, { + value: 'vmimi' as const, text: i18n.ts._timelines.vmimi, + }, { + value: 'vmimiHybrid' as const, text: i18n.ts._timelines.vmimiHybrid, }], }); if (canceled) { @@ -135,7 +146,11 @@ const menu = computed(() => [{ text: i18n.ts.fileAttachedOnly, ref: onlyFiles, disabled: hasWithReplies(props.column.tl) ? withReplies : false, -}]); +}, isVrTimeline(props.column.tl) ? { + type: 'switch', + text: i18n.ts.showLocalOnlyInTimeline, + ref: withLocalOnly, +} : undefined]);