diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 8c5567359..cac76a764 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -51,6 +51,12 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default + withRenotes: { type: 'boolean', default: true }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, }, required: ['channelId'], } as const; @@ -89,7 +95,7 @@ export default class extends Endpoint { // eslint- if (me) this.activeUsersChart.read(me); if (!serverSettings.enableFanoutTimeline) { - return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); + return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me); } return await this.fanoutTimelineEndpointService.timeline({ @@ -100,7 +106,8 @@ export default class extends Endpoint { // eslint- me, useDbFallback: true, redisTimelines: [`channelTimeline:${channel.id}`], - excludePureRenotes: false, + excludePureRenotes: !ps.withRenotes, + excludeNoFiles: ps.withFiles, dbFallback: async (untilId, sinceId, limit) => { return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); }, @@ -112,7 +119,9 @@ export default class extends Endpoint { // eslint- untilId: string | null, sinceId: string | null, limit: number, - channelId: string + channelId: string, + withFiles: boolean, + withRenotes: boolean, }, me: MiLocalUser | null) { //#region fallback to database const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -128,6 +137,20 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 90ee1ecda..61bb4bfae 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -15,6 +15,8 @@ class ChannelChannel extends Channel { public static shouldShare = false; public static requireCredential = false as const; private channelId: string; + private withFiles: boolean; + private withRenotes: boolean; constructor( private noteEntityService: NoteEntityService, @@ -29,6 +31,8 @@ class ChannelChannel extends Channel { @bindThis public async init(params: any) { this.channelId = params.channelId as string; + this.withFiles = params.withFiles ?? false; + this.withRenotes = params.withRenotes ?? true; // Subscribe stream this.subscriber.on('notesStream', this.onNote); @@ -38,6 +42,10 @@ class ChannelChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (note.channelId !== this.channelId) return; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) 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がブロックされているユーザーが関わるものだったら無視する diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 1c14174a3..0f7eb3b86 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -154,6 +154,8 @@ function connectChannel() { } else if (props.src === 'channel') { if (props.channel == null) return; connection = stream.useChannel('channel', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, channelId: props.channel, }); } else if (props.src === 'role') { @@ -234,6 +236,8 @@ function updatePaginationQuery() { } else if (props.src === 'channel') { endpoint = 'channels/timeline'; query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, channelId: props.channel, }; } else if (props.src === 'role') { diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 881acd019..ee081d07e 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -95,6 +95,7 @@ import { isSupportShare } from '@/scripts/navigator.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { miLocalStorage } from '@/local-storage.js'; import { useRouter } from '@/router/supplier.js'; +import { deepMerge } from '@/scripts/merge.js'; const router = useRouter(); @@ -116,6 +117,15 @@ const featuredPagination = computed(() => ({ channelId: props.channelId, }, })); +const withRenotes = computed({ + get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, + set: (x) => saveTlFilter('withRenotes', x), +}); + +const onlyFiles = computed({ + get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles, + set: (x) => saveTlFilter('onlyFiles', x), +}); watch(() => props.channelId, async () => { channel.value = await misskeyApi('channels/show', { @@ -136,6 +146,13 @@ watch(() => props.channelId, async () => { } }, { immediate: true }); +function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) { + if (key !== 'withReplies' || $i) { + const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl); + defaultStore.set('tl', out); + } +} + function edit() { router.push(`/channels/${channel.value?.id}/edit`); } @@ -192,7 +209,21 @@ async function search() { const headerActions = computed(() => { if (channel.value && channel.value.userId) { - const headerItems: PageHeaderItem[] = []; + const headerItems: PageHeaderItem[] = [{ + icon: 'ph-dots-three ph-bold ph-lg', + text: i18n.ts.options, + handler: (ev) => { + os.popupMenu([{ + type: 'switch', + text: i18n.ts.showRenotes, + ref: withRenotes, + }, { + type: 'switch', + text: i18n.ts.fileAttachedOnly, + ref: onlyFiles, + }], ev.currentTarget ?? ev.target); + }, + }]; headerItems.push({ icon: 'ph-share-network ph-bold ph-lg', diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 984de82c3..993be4691 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
- +