From 7b6796d3163d3a90fb958919db19254a856867bb Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sun, 16 Feb 2025 03:57:24 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=E3=83=9B=E3=83=BC=E3=83=A0=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=A8=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=AB=E3=81=8F=E3=81=A3=E3=81=A4=E3=81=91?= =?UTF-8?q?=E3=81=9F=E3=81=BF=E3=81=9F=E3=81=84=E3=81=AATL=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/FanoutTimelineService.ts | 7 +- .../backend/src/core/NoteCreateService.ts | 10 + packages/backend/src/server/ServerModule.ts | 2 + .../backend/src/server/api/endpoint-list.ts | 1 + .../endpoints/notes/home-local-timeline.ts | 247 ++++++++++++++++++ .../src/server/api/stream/ChannelsService.ts | 3 + .../stream/channels/home-local-timeline.ts | 120 +++++++++ .../frontend/src/components/MkTimeline.vue | 7 + packages/frontend/src/pages/timeline.vue | 16 +- packages/frontend/src/store.ts | 3 +- packages/frontend/src/timeline-header.ts | 8 +- packages/frontend/src/timelines.ts | 3 + 12 files changed, 410 insertions(+), 17 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/notes/home-local-timeline.ts create mode 100644 packages/backend/src/server/api/stream/channels/home-local-timeline.ts diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 289138ab6f8..5dafbcc344d 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -13,12 +13,17 @@ export type FanoutTimelineName = ( // home timeline | `homeTimeline:${string}` | `homeTimelineWithFiles:${string}` // only notes with files are included + // local timeline | 'localTimeline' // replies are not included | 'localTimelineWithFiles' // only non-reply notes with files are included | 'localTimelineWithReplies' // only replies are included | `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. + // local home timeline + | `localHomeTimeline:${string}` + | `localHomeTimelineWithFiles:${string}` // only notes with files are included + // antenna | `antennaTimeline:${string}` @@ -37,9 +42,9 @@ export type FanoutTimelineName = ( // role timelines | `roleTimeline:${string}` // any notes are included -); | `remoteLocalTimeline:${string}` +); @Injectable() export class FanoutTimelineService { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 94e30c12009..f07aea24d9b 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -861,8 +861,11 @@ export class NoteCreateService implements OnApplicationShutdown { for (const channelFollowing of channelFollowings) { this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`localHomeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); } } } else { @@ -901,8 +904,10 @@ export class NoteCreateService implements OnApplicationShutdown { } this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + if (user.host === null) this.fanoutTimelineService.push(`localHomeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); + if (user.host === null) this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); } } @@ -929,8 +934,10 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.userHost == null) { if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`localHomeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); } } } @@ -953,10 +960,13 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.visibility === 'public' && note.userHost == null) { this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); + this.fanoutTimelineService.push(`localHomeTimeline:${user.id}`, note.id, 1000, r); if (note.fileIds.length > 0) { this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); + this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${user.id}`, note.id, 1000, r); } } + if (note.visibility === 'public' && note.userHost !== null) { this.fanoutTimelineService.push(`remoteLocalTimeline:${note.userHost}`, note.id, 1000, r); } diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 612e75586ca..dbc3684377c 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -38,6 +38,7 @@ import { DriveChannelService } from './api/stream/channels/drive.js'; import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; import { HashtagChannelService } from './api/stream/channels/hashtag.js'; import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; +import { HomeLocalTimelineChannelService } from './api/stream/channels/home-local-timeline.js'; import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; @@ -87,6 +88,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ReversiChannelService, ReversiGameChannelService, HomeTimelineChannelService, + HomeLocalTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, QueueStatsChannelService, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 8b340997a98..93c692bafd7 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -415,3 +415,4 @@ export * as 'admin/inbox-rule/set' from './endpoints/admin/inbox-rule/set.js'; export * as 'admin/inbox-rule/delete' from './endpoints/admin/inbox-rule/delete.js'; export * as 'admin/inbox-rule/list' from './endpoints/admin/inbox-rule/list.js'; export * as 'users/lists/list-favorite' from './endpoints/users/lists/list-favorite.js'; +export * as 'notes/home-local-timeline' from './endpoints/notes/home-local-timeline.js' diff --git a/packages/backend/src/server/api/endpoints/notes/home-local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/home-local-timeline.ts new file mode 100644 index 00000000000..36f354e629a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/home-local-timeline.ts @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Brackets } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.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', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default + includeMyRenotes: { type: 'boolean', default: true }, + includeRenotedMyNotes: { type: 'boolean', default: true }, + includeLocalRenotes: { type: 'boolean', default: true }, + withFiles: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private noteEntityService: NoteEntityService, + private activeUsersChart: ActiveUsersChart, + private idService: IdService, + private cacheService: CacheService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + private userFollowingService: UserFollowingService, + private queryService: QueryService, + ) { + 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); + + if (!this.serverSettings.enableFanoutTimeline) { + const timeline = await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + }, me); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + } + + const [ + followings, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + ]); + + const timeline = this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, + redisTimelines: ps.withFiles ? [`localHomeTimelineWithFiles:${me.id}`] : [`localHomeTimeline:${me.id}`], + 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, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + }, me), + }); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return timeline; + }); + } + + private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); + + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followees.length > 0 && followingChannels.length > 0) { + // ユーザー・チャンネルともにフォローあり + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + const followingChannelIds = followingChannels.map(x => x.followeeId); + query.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb2 => { + qb2 + .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) + .andWhere('note.channelId IS NULL'); + })) + .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + })); + } else if (followees.length > 0) { + // ユーザーフォローのみ(チャンネルフォローなし) + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + query + .andWhere('note.channelId IS NULL') + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + } else if (followingChannels.length > 0) { + // チャンネルフォローのみ(ユーザーフォローなし) + const followingChannelIds = followingChannels.map(x => x.followeeId); + query.andWhere(new Brackets(qb => { + qb + .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) + .orWhere('note.userId = :meId', { meId: me.id }); + })); + } else { + // フォローなし + query + .andWhere('note.channelId IS NULL') + .andWhere('note.userId = :meId', { meId: me.id }); + } + + 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.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.withRenotes === false) { + query.andWhere('note.renoteId IS NULL'); + } + //#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 97597ffe19d..0796a853901 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -21,6 +21,7 @@ import { HashtagChannelService } from './channels/hashtag.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js'; import { ReversiChannelService } from './channels/reversi.js'; import { ReversiGameChannelService } from './channels/reversi-game.js'; +import { HomeLocalTimelineChannelService } from './channels/home-local-timeline.js'; import { type MiChannelService } from './channel.js'; @Injectable() @@ -42,6 +43,7 @@ export class ChannelsService { private adminChannelService: AdminChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, + private homeLocalTimelineChannel: HomeLocalTimelineChannelService, ) { } @@ -64,6 +66,7 @@ export class ChannelsService { case 'admin': return this.adminChannelService; case 'reversi': return this.reversiChannelService; case 'reversiGame': return this.reversiGameChannelService; + case 'homeLocalTimeline': return this.homeLocalTimelineChannel; default: throw new Error(`no such channel: ${name}`); diff --git a/packages/backend/src/server/api/stream/channels/home-local-timeline.ts b/packages/backend/src/server/api/stream/channels/home-local-timeline.ts new file mode 100644 index 00000000000..28b7a343004 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/home-local-timeline.ts @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import type { Packed } from '@/misc/json-schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class HomeLocalTimelineChannel extends Channel { + public readonly chName = 'homeLocalTimeline'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:account'; + private withRenotes: boolean; + private withFiles: boolean; + + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: JsonObject) { + this.withRenotes = !!(params.withRenotes ?? true); + this.withFiles = !!(params.withFiles ?? false); + + 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) { + if (!this.followingChannels.has(note.channelId)) return; + } else { + // その投稿のユーザーをフォローしていなかったら弾く + if (!isMe && !Object.hasOwn(this.following, note.userId)) 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 (note.reply) { + const reply = note.reply; + if (this.following[note.userId]?.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.isNoteMutedOrBlocked(note)) return; + + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + 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() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class HomeLocalTimelineChannelService implements MiChannelService { + public readonly shouldShare = HomeLocalTimelineChannel.shouldShare; + public readonly requireCredential = HomeLocalTimelineChannel.requireCredential; + public readonly kind = HomeLocalTimelineChannel.kind; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): HomeLocalTimelineChannel { + return new HomeLocalTimelineChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index f88bd91e338..38c354452ba 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -145,6 +145,11 @@ function connectChannel() { connection = stream.useChannel('channel', { channelId: props.channel }); } else if (props.src === 'role' && props.role) { connection = stream.useChannel('roleTimeline', { roleId: props.role }); + } else if (props.src === 'homeLocal') { + connection = stream.useChannel('homeLocalTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }); } if (props.src !== 'directs' && props.src !== 'mentions') { @@ -170,6 +175,7 @@ function updatePaginationQuery() { list: 'notes/user-list-timeline', channel: 'channels/timeline', role: 'roles/notes', + homeLocal: 'notes/home-local-timeline', }; const queries = { @@ -184,6 +190,7 @@ function updatePaginationQuery() { list: { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list }, channel: { channelId: props.channel }, role: { roleId: props.role }, + homeLocal: { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined }, }; if (props.src.startsWith('remoteLocalTimeline')) { paginationQuery = { diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index f09927517ea..af63b75da8d 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -236,19 +236,6 @@ function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: } } -async function timetravel(): Promise { - const { canceled, result: date } = await os.inputDate({ - title: i18n.ts.date, - }); - if (canceled) return; - - tlComponent.value.timetravel(date); -} - -function focus(): void { - tlComponent.value.focus(); -} - function closeTutorial(): void { if (!isBasicTimeline(src.value)) return; const before = defaultStore.state.timelineTutorials; @@ -322,8 +309,9 @@ const headerActions = computed(() => { } return tmp; }); + const headerTabs = computed(() => defaultStore.reactiveState.timelineHeader.value.map(tab => { - if ((tab === 'local' || tab === 'social') && !isLocalTimelineAvailable) { + if ((tab === 'local' || tab === 'social' || tab === 'homeLocal') && !isLocalTimelineAvailable) { return {}; } else if (tab === 'global' && !isGlobalTimelineAvailable) { return {}; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 7e813e14816..4b8c145fe1d 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -16,6 +16,7 @@ import { miLocalStorage } from '@/local-storage.js'; import { Storage } from '@/pizzax.js'; import { isGlobalTimelineAvailable, isLocalTimelineAvailable } from '@/scripts/get-timeline-available.js'; import { instance } from '@/instance.js'; +import type {TimelineHeaderItem} from "@/timeline-header"; interface PostFormAction { title: string; @@ -251,7 +252,7 @@ export const defaultStore = markRaw( where: 'deviceAccount', default: [ 'home', - ...(isLocalTimelineAvailable ? ['local', 'social'] : []), + ...(isLocalTimelineAvailable ? ['local', 'social', 'homeLocal'] : []), ...(isGlobalTimelineAvailable ? ['global'] : []), 'lists', 'antennas', diff --git a/packages/frontend/src/timeline-header.ts b/packages/frontend/src/timeline-header.ts index aa4af791365..ab55954fa7f 100644 --- a/packages/frontend/src/timeline-header.ts +++ b/packages/frontend/src/timeline-header.ts @@ -28,7 +28,8 @@ export type TimelineHeaderItem = `channel:${string}` | `antenna:${string}` | 'media' | - `customTimeline:${string}`; + `customTimeline:${string}` | + 'homeLocal'; type TimelineHeaderItemsDef = { title: string; @@ -54,6 +55,11 @@ export const timelineHeaderItemDef = reactive