From 40f8b5e7f574ef24a9674297ae5912e3961dac2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 14 Jan 2025 21:05:34 +0900
Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E3=83=A6=E3=83=BC?=
 =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AB=E3=80=8C?=
 =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=80=8D=E3=82=BF=E3=83=96?=
 =?UTF-8?q?=E3=82=92=E6=96=B0=E8=A8=AD=20(#15130)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* 投稿したファイルの一覧をプロフィールページ内のタブで見れるようにしてみた (Otaku-Social#14)

* ギャラリー(ノート)の取得方法を変更、ページネーションに対応

* ギャラリー(ノート)が動作しない問題を修正

* ギャラリー(ノート)の名称変更

* styles

* GalleryFromPosts -> Files

* fix

* enhance: 既存のファイルコンテナの「もっと見る」をクリックしたらファイルタブに飛べるように

* Update Changelog

* 共通化

* spdx

* その他のメディアがちゃんとプレビューされるように

* fix(frontend): リストがセンシティブ設定を考慮するように

* arrayをsetに変更

* remove unused imports

* :art:

* :art:

* 画像以外のファイルのプレビューに対応したのでコメントを削除

* サムネイルをMkDriveFileThumbnailに統一

* v-panelに置き換え

* lint

---------

Co-authored-by: tmorio <morikapusan@morikapu-denki.com>
Co-authored-by: tmorio <20278135+tmorio@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 CHANGELOG.md                                  |  2 +
 .../frontend/src/components/MkContainer.vue   | 10 +-
 .../src/components/MkDriveFileThumbnail.vue   | 28 ++++--
 .../src/components/MkNoteMediaGrid.vue        | 99 +++++++++++++++++++
 packages/frontend/src/pages/user/files.vue    | 56 +++++++++++
 packages/frontend/src/pages/user/home.vue     |  8 +-
 .../frontend/src/pages/user/index.files.vue   | 58 +++--------
 packages/frontend/src/pages/user/index.vue    |  8 +-
 8 files changed, 216 insertions(+), 53 deletions(-)
 create mode 100644 packages/frontend/src/components/MkNoteMediaGrid.vue
 create mode 100644 packages/frontend/src/pages/user/files.vue

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e44c15005e..90b0611af3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@
 - Enhance: 照会に失敗した場合、その理由を表示するように
 - Enhance: 連合がホワイトリスト化・無効化されているサーバー向けのデザイン修正
 - Enhance: AiScriptのセーブデータを明示的に削除する関数`Mk:remove`を追加
+- Enhance: ノートの添付ファイルを一覧で遡れる「ファイル」タブを追加  
+  (Based on https://github.com/Otaku-Social/maniakey/pull/14)
 - Enhance: AiScriptの拡張API関数において引数の型チェックをより厳格に
 - Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正
 - Fix: サーバー情報メニューに区切り線が不足していたのを修正
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index f513795c56..6a278250fa 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	>
 		<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
 			<slot></slot>
-			<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
+			<button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
 				<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
 			</button>
 		</div>
@@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
 	thin?: boolean;
 	naked?: boolean;
 	foldable?: boolean;
+	onUnfold?: () => boolean; // return false to prevent unfolding
 	scrollable?: boolean;
 	expanded?: boolean;
 	maxHeight?: number | null;
@@ -101,6 +102,13 @@ const omitObserver = new ResizeObserver((entries, observer) => {
 	calcOmit();
 });
 
+function showMore() {
+	if (props.onUnfold && !props.onUnfold()) return;
+
+	ignoreOmit.value = true;
+	omitted.value = false;
+}
+
 onMounted(() => {
 	watch(showBody, v => {
 		if (!rootEl.value) return;
diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue
index 3410a915c3..874d9b04cf 100644
--- a/packages/frontend/src/components/MkDriveFileThumbnail.vue
+++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue
@@ -5,13 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div
-	ref="thumbnail"
-	:class="[
-		$style.root,
-		{ [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive },
-	]"
+	v-panel
+	:class="[$style.root, {
+		[$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive,
+		[$style.large]: large,
+	}]"
 >
-	<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
+	<ImgWithBlurhash
+		v-if="isThumbnailAvailable"
+		:hash="file.blurhash"
+		:src="file.thumbnailUrl"
+		:alt="file.name"
+		:title="file.name"
+		:cover="fit !== 'contain'"
+		:forceBlurHash="forceBlurhash"
+	/>
 	<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
 	<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
 	<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
@@ -34,6 +42,8 @@ const props = defineProps<{
 	file: Misskey.entities.DriveFile;
 	fit: 'cover' | 'contain';
 	highlightWhenSensitive?: boolean;
+	forceBlurhash?: boolean;
+	large?: boolean;
 }>();
 
 const is = computed(() => {
@@ -60,7 +70,7 @@ const is = computed(() => {
 
 const isThumbnailAvailable = computed(() => {
 	return props.file.thumbnailUrl
-		? (is.value === 'image' as const || is.value === 'video')
+		? (is.value === 'image' || is.value === 'video')
 		: false;
 });
 </script>
@@ -101,4 +111,8 @@ const isThumbnailAvailable = computed(() => {
 	font-size: 32px;
 	color: #777;
 }
+
+.large .icon {
+	font-size: 40px;
+}
 </style>
diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue
new file mode 100644
index 0000000000..520421bfb7
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteMediaGrid.vue
@@ -0,0 +1,99 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+	<template v-for="file in note.files">
+		<div
+			v-if="(defaultStore.state.nsfw === 'force' || file.isSensitive) && defaultStore.state.nsfw !== 'ignore' && !showingFiles.has(file.id)"
+			:class="[$style.filePreview, { [$style.square]: square }]"
+			@click="showingFiles.add(file.id)"
+		>
+			<MkDriveFileThumbnail
+				:file="file"
+				fit="cover"
+				:highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
+				:forceBlurhash="true"
+				:large="true"
+				:class="$style.file"
+			/>
+			<div :class="$style.sensitive">
+				<div>
+					<div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div>
+					<div>{{ i18n.ts.clickToShow }}</div>
+				</div>
+			</div>
+		</div>
+		<MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)">
+			<MkDriveFileThumbnail
+				:file="file"
+				fit="cover"
+				:highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
+				:large="true"
+				:class="$style.file"
+			/>
+		</MkA>
+	</template>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { notePage } from '@/filters/note.js';
+import { i18n } from '@/i18n.js';
+import * as Misskey from 'misskey-js';
+import { defaultStore } from '@/store.js';
+
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+
+defineProps<{
+	note: Misskey.entities.Note;
+	square?: boolean;
+}>();
+
+const showingFiles = ref<Set<string>>(new Set());
+</script>
+
+<style lang="scss" module>
+.square {
+	width: 100%;
+	height: auto;
+	aspect-ratio: 1;
+}
+
+.filePreview {
+	position: relative;
+	height: 128px;
+	border-radius: calc(var(--MI-radius) / 2);
+	overflow: clip;
+
+	&:hover {
+		text-decoration: none;
+	}
+
+	&.square {
+		height: 100%;
+	}
+}
+
+.file {
+	width: 100%;
+	height: 100%;
+	border-radius: calc(var(--MI-radius) / 2);
+}
+
+.sensitive {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	display: grid;
+  place-items: center;
+	font-size: 0.8em;
+	color: #fff;
+	background: rgba(0, 0, 0, 0.5);
+	backdrop-filter: blur(5px);
+	cursor: pointer;
+}
+</style>
diff --git a/packages/frontend/src/pages/user/files.vue b/packages/frontend/src/pages/user/files.vue
new file mode 100644
index 0000000000..b6c7c1c777
--- /dev/null
+++ b/packages/frontend/src/pages/user/files.vue
@@ -0,0 +1,56 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+	<MkSpacer :contentMax="1100">
+		<div :class="$style.root">
+			<MkPagination v-slot="{items}" :pagination="pagination">
+				<div :class="$style.stream">
+					<MkNoteMediaGrid v-for="note in items" :note="note" square/>
+				</div>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+
+import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
+import MkPagination from '@/components/MkPagination.vue';
+
+const props = defineProps<{
+	user: Misskey.entities.UserDetailed;
+}>();
+
+const pagination = {
+	endpoint: 'users/notes' as const,
+	limit: 15,
+	params: computed(() => ({
+		userId: props.user.id,
+		withFiles: true,
+	})),
+};
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 8px;
+}
+
+.stream {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+	gap: var(--MI-marginHalf);
+}
+
+@media screen and (min-width: 600px) {
+	.stream {
+		grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+	}
+
+}
+</style>
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 2794db2821..a6a49f0ab9 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -136,7 +136,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
 				<template v-if="narrow">
 					<MkLazy>
-						<XFiles :key="user.id" :user="user"/>
+						<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
 					</MkLazy>
 					<MkLazy>
 						<XActivity :key="user.id" :user="user"/>
@@ -150,7 +150,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</div>
 		<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
-			<XFiles :key="user.id" :user="user"/>
+			<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
 			<XActivity :key="user.id" :user="user"/>
 		</div>
 	</div>
@@ -212,6 +212,10 @@ const props = withDefaults(defineProps<{
 	disableNotes: false,
 });
 
+const emit = defineEmits<{
+	(ev: 'unfoldFiles'): void;
+}>();
+
 const router = useRouter();
 
 const user = ref(props.user);
diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue
index ce4d113cad..b5e5f29ade 100644
--- a/packages/frontend/src/pages/user/index.files.vue
+++ b/packages/frontend/src/pages/user/index.files.vue
@@ -4,30 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkContainer :max-height="300" :foldable="true">
+<MkContainer :max-height="300" :foldable="true" :onUnfold="unfoldContainer">
 	<template #icon><i class="ti ti-photo"></i></template>
 	<template #header>{{ i18n.ts.files }}</template>
 	<div :class="$style.root">
 		<MkLoading v-if="fetching"/>
-		<div v-if="!fetching && files.length > 0" :class="$style.stream">
-			<template v-for="file in files" :key="file.note.id + file.file.id">
-				<div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.img" @click="showingFiles.push(file.file.id)">
-					<!-- TODO: 画像以外のファイルに対応 -->
-					<ImgWithBlurhash :class="$style.sensitiveImg" :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name" :forceBlurhash="true"/>
-					<div :class="$style.sensitive">
-						<div>
-							<div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div>
-							<div>{{ i18n.ts.clickToShow }}</div>
-						</div>
-					</div>
-				</div>
-				<MkA v-else :class="$style.img" :to="notePage(file.note)">
-					<!-- TODO: 画像以外のファイルに対応 -->
-					<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/>
-				</MkA>
-			</template>
+		<div v-if="!fetching && notes.length > 0" :class="$style.stream">
+			<MkNoteMediaGrid v-for="note in notes" :note="note"/>
 		</div>
-		<p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
+		<p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
 	</div>
 </MkContainer>
 </template>
@@ -35,45 +20,34 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
-import { notePage } from '@/filters/note.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
+import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
 
 const props = defineProps<{
 	user: Misskey.entities.UserDetailed;
 }>();
 
+const emit = defineEmits<{
+	(ev: 'unfold'): void;
+}>();
+
 const fetching = ref(true);
-const files = ref<{
-	note: Misskey.entities.Note;
-	file: Misskey.entities.DriveFile;
-}[]>([]);
-const showingFiles = ref<string[]>([]);
+const notes = ref<Misskey.entities.Note[]>([]);
 
-function thumbnail(image: Misskey.entities.DriveFile): string {
-	return defaultStore.state.disableShowingAnimatedImages
-		? getStaticImageUrl(image.url)
-		: image.thumbnailUrl;
+function unfoldContainer(): boolean {
+	emit('unfold');
+	return false;
 }
 
 onMounted(() => {
 	misskeyApi('users/notes', {
 		userId: props.user.id,
 		withFiles: true,
-		limit: 15,
-	}).then(notes => {
-		for (const note of notes) {
-			for (const file of note.files) {
-				files.value.push({
-					note,
-					file,
-				});
-			}
-		}
+		limit: 10,
+	}).then(_notes => {
+		notes.value = _notes;
 		fetching.value = false;
 	});
 });
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index 4787db5dbe..c43f6c76d9 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -9,10 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div>
 		<div v-if="user">
 			<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
-				<XHome v-if="tab === 'home'" key="home" :user="user"/>
+				<XHome v-if="tab === 'home'" key="home" :user="user" @unfoldFiles="() => { tab = 'files'; }"/>
 				<MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0">
 					<XTimeline :user="user"/>
 				</MkSpacer>
+				<XFiles v-else-if="tab === 'files'" :user="user"/>
 				<XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/>
 				<XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/>
 				<XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/>
@@ -43,6 +44,7 @@ import { serverContext, assertServerContext } from '@/server-context.js';
 
 const XHome = defineAsyncComponent(() => import('./home.vue'));
 const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
+const XFiles = defineAsyncComponent(() => import('./files.vue'));
 const XActivity = defineAsyncComponent(() => import('./activity.vue'));
 const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
 const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
@@ -103,6 +105,10 @@ const headerTabs = computed(() => user.value ? [{
 	key: 'notes',
 	title: i18n.ts.notes,
 	icon: 'ti ti-pencil',
+}, {
+	key: 'files',
+	title: i18n.ts.files,
+	icon: 'ti ti-photo',
 }, {
 	key: 'activity',
 	title: i18n.ts.activity,
-- 
GitLab