From d27ce442eab9ae8e2f4ddff1e8ca0d0412e73046 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Thu, 23 May 2024 21:56:28 +0000
Subject: [PATCH] more timeline filters - #228

---
 .../server/api/endpoints/channels/timeline.ts | 31 +++++++++++++---
 .../src/server/api/stream/channels/channel.ts |  8 +++++
 .../frontend/src/components/MkTimeline.vue    |  4 +++
 packages/frontend/src/pages/channel.vue       | 35 ++++++++++++++++--
 .../frontend/src/pages/user-list-timeline.vue | 36 ++++++++++++++++++-
 .../frontend/src/ui/deck/channel-column.vue   | 26 ++++++++++++--
 packages/frontend/src/ui/deck/list-column.vue | 14 +++++++-
 7 files changed, 144 insertions(+), 10 deletions(-)

diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 8c55673590..295fc5686c 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<typeof meta, typeof paramDef> { // 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,9 +106,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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);
+					return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me);
 				},
 			});
 		});
@@ -112,7 +119,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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 90ee1ecda5..61bb4bfae2 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 1c14174a37..0f7eb3b86c 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 881acd0197..ee081d07ee 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
 				<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
 				<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
 
-				<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
+				<MkTimeline :key="channelId + withRenotes + onlyFiles" src="channel" :channel="channelId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
 			</div>
 			<div v-else-if="tab === 'featured'" key="featured">
 				<MkNotes :pagination="featuredPagination"/>
@@ -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<boolean>({
+	get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
+	set: (x) => saveTlFilter('withRenotes', x),
+});
+
+const onlyFiles = computed<boolean>({
+	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/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index dd0b7fb675..b2d52b013c 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -11,10 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
 			<div :class="$style.tl">
 				<MkTimeline
-					ref="tlEl" :key="listId"
+					ref="tlEl" :key="listId + withRenotes + onlyFiles"
 					src="list"
 					:list="listId"
 					:sound="true"
+					:withRenotes="withRenotes"
+					:onlyFiles="onlyFiles"
 					@queue="queueUpdated"
 				/>
 			</div>
@@ -32,6 +34,9 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
+import { defaultStore } from '@/store.js';
+import { deepMerge } from '@/scripts/merge.js';
+import * as os from '@/os.js';
 
 const router = useRouter();
 
@@ -43,6 +48,21 @@ const list = ref<Misskey.entities.UserList | null>(null);
 const queue = ref(0);
 const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
 const rootEl = shallowRef<HTMLElement>();
+const withRenotes = computed<boolean>({
+	get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
+	set: (x) => saveTlFilter('withRenotes', x),
+});
+const onlyFiles = computed<boolean>({
+	get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles,
+	set: (x) => saveTlFilter('onlyFiles', x),
+});
+
+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);
+	}
+}
 
 watch(() => props.listId, async () => {
 	list.value = await misskeyApi('users/lists/show', {
@@ -63,6 +83,20 @@ function settings() {
 }
 
 const headerActions = computed(() => list.value ? [{
+	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);
+	},
+}, {
 	icon: 'ph-gear ph-bold ph-lg',
 	text: i18n.ts.settings,
 	handler: settings,
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 984de82c3f..993be46910 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
 		<div style="padding: 8px; text-align: center;">
 			<MkButton primary gradate rounded inline small @click="post"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkButton>
 		</div>
-		<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/>
+		<MkTimeline ref="timeline" src="channel" :channel="column.channelId" :key="column.channelId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/>
 	</template>
 </XColumn>
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { watch, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XColumn from './column.vue';
 import { updateColumn, Column } from './deck-store.js';
@@ -36,6 +36,20 @@ const props = defineProps<{
 
 const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
 const channel = shallowRef<Misskey.entities.Channel>();
+const withRenotes = ref(props.column.withRenotes ?? true);
+const onlyFiles = ref(props.column.onlyFiles ?? false);
+
+watch(withRenotes, v => {
+	updateColumn(props.column.id, {
+		withRenotes: v,
+	});
+});
+
+watch(onlyFiles, v => {
+	updateColumn(props.column.id, {
+		onlyFiles: v,
+	});
+});
 
 if (props.column.channelId == null) {
 	setChannel();
@@ -75,5 +89,13 @@ const menu = [{
 	icon: 'ph-pencil-simple ph-bold ph-lg',
 	text: i18n.ts.selectChannel,
 	action: setChannel,
+}, {
+	type: 'switch',
+	text: i18n.ts.showRenotes,
+	ref: withRenotes,
+}, {
+	type: 'switch',
+	text: i18n.ts.fileAttachedOnly,
+	ref: onlyFiles,
 }];
 </script>
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 128562823b..f7988ed1b7 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<i class="ph-list ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span>
 	</template>
 
-	<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
+	<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :key="column.listId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/>
 </XColumn>
 </template>
 
@@ -29,6 +29,7 @@ const props = defineProps<{
 
 const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
 const withRenotes = ref(props.column.withRenotes ?? true);
+const onlyFiles = ref(props.column.onlyFiles ?? false);
 
 if (props.column.listId == null) {
 	setList();
@@ -40,6 +41,12 @@ watch(withRenotes, v => {
 	});
 });
 
+watch(onlyFiles, v => {
+	updateColumn(props.column.id, {
+		onlyFiles: v,
+	});
+});
+
 async function setList() {
 	const lists = await misskeyApi('users/lists/list');
 	const { canceled, result: list } = await os.select({
@@ -75,5 +82,10 @@ const menu = [
 		text: i18n.ts.showRenotes,
 		ref: withRenotes,
 	},
+	{
+		type: 'switch',
+		text: i18n.ts.fileAttachedOnly,
+		ref: onlyFiles,
+	},
 ];
 </script>