From ffb9646ce9c3d2326a3e922e58702674eb65646c Mon Sep 17 00:00:00 2001
From: nullobsi <me@nullob.si>
Date: Thu, 27 May 2021 17:38:09 -0700
Subject: [PATCH] Add image description support (#7518)

* recieve image descriptions under the name property

* fix other components

* use comment for alt and title

* allow editing of file comment

* allow editing of file comment in note dialog

* federate note comments

* use file instead of this

* backend should accept comment on update

* update now actually accepts comment

* allow multiline descriptions

* image should also have description attached

* Update locales/ja-JP.yml

Co-authored-by: rinsuki <428rinsuki+git@gmail.com>

* Use custom component with side-by-side image

* improve usability on mobile devices

* revert changes

* Update post-form-attaches.vue

* Update drive.file.vue

* Update media-caption.vue

Co-authored-by: rinsuki <428rinsuki+git@gmail.com>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 locales/ja-JP.yml                             |   3 +
 src/client/components/drive.file.vue          |  24 ++
 src/client/components/image-viewer.vue        |   2 +-
 src/client/components/media-caption.vue       | 238 ++++++++++++++++++
 src/client/components/media-image.vue         |   4 +-
 src/client/components/post-form-attaches.vue  |  25 ++
 src/remote/activitypub/models/image.ts        |   2 +-
 src/remote/activitypub/renderer/document.ts   |   3 +-
 src/remote/activitypub/renderer/image.ts      |   3 +-
 .../api/endpoints/drive/files/update.ts       |  11 +
 src/services/drive/upload-from-url.ts         |   6 +
 11 files changed, 315 insertions(+), 6 deletions(-)
 create mode 100644 src/client/components/media-caption.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index e869f5b015..23f3bf7296 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -279,6 +279,7 @@ emptyDrive: "ドライブは空です"
 emptyFolder: "フォルダーは空です"
 unableToDelete: "削除できません"
 inputNewFileName: "新しいファイル名を入力してください"
+inputNewDescription: "新しいキャプションを入力してください"
 inputNewFolderName: "新しいフォルダ名を入力してください"
 circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
 hasChildFilesOrFolders: "このフォルダは空でないため、削除できません。"
@@ -546,6 +547,8 @@ disablePlayer: "プレイヤーを閉じる"
 expandTweet: "ツイートを展開する"
 themeEditor: "テーマエディター"
 description: "説明"
+describeFile: "キャプションを付ける"
+enterFileDescription: "キャプションを入力"
 author: "作者"
 leaveConfirm: "未保存の変更があります。破棄しますか?"
 manage: "管理"
diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue
index 37b1afc1b3..3d20de23e9 100644
--- a/src/client/components/drive.file.vue
+++ b/src/client/components/drive.file.vue
@@ -87,6 +87,10 @@ export default defineComponent({
 				text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
 				icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash',
 				action: this.toggleSensitive
+			}, {
+				text: this.$ts.describeFile,
+				icon: 'fas fa-i-cursor',
+				action: this.describe
 			}, null, {
 				text: this.$ts.copyUrl,
 				icon: 'fas fa-link',
@@ -150,6 +154,26 @@ export default defineComponent({
 			});
 		},
 
+		describe() {
+			os.popup(import('@client/components/media-caption.vue'), {
+				title: this.$ts.describeFile,
+				input: {
+					placeholder: this.$ts.inputNewDescription,
+					default: this.file.comment !== null ? this.file.comment : '',
+				},
+				image: this.file
+			}, {
+				done: result => {
+					if (!result || result.canceled) return;
+					let comment = result.result;
+					os.api('drive/files/update', {
+						fileId: this.file.id,
+						comment: comment.length == 0 ? null : comment
+					});
+				}
+			}, 'closed');
+		},
+
 		toggleSensitive() {
 			os.api('drive/files/update', {
 				fileId: this.file.id,
diff --git a/src/client/components/image-viewer.vue b/src/client/components/image-viewer.vue
index ec22bd98ec..7701ae926f 100644
--- a/src/client/components/image-viewer.vue
+++ b/src/client/components/image-viewer.vue
@@ -2,7 +2,7 @@
 <MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
 	<div class="xubzgfga">
 		<header>{{ image.name }}</header>
-		<img :src="image.url" :alt="image.name" :title="image.name" @click="$refs.modal.close()"/>
+		<img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
 		<footer>
 			<span>{{ image.type }}</span>
 			<span>{{ bytes(image.size) }}</span>
diff --git a/src/client/components/media-caption.vue b/src/client/components/media-caption.vue
new file mode 100644
index 0000000000..690927d4c5
--- /dev/null
+++ b/src/client/components/media-caption.vue
@@ -0,0 +1,238 @@
+<template>
+	<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
+		<div class="container">
+			<div class="fullwidth top-caption">
+				<div class="mk-dialog">
+					<header v-if="title"><Mfm :text="title"/></header>
+					<textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea>
+					<div class="buttons" v-if="(showOkButton || showCancelButton)">
+						<MkButton inline @click="ok" primary>{{ $ts.ok }}</MkButton>
+						<MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton>
+					</div>
+				</div>
+			</div>
+			<div class="hdrwpsaf fullwidth">
+				<header>{{ image.name }}</header>
+				<img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
+				<footer>
+					<span>{{ image.type }}</span>
+					<span>{{ bytes(image.size) }}</span>
+					<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
+				</footer>
+			</div>
+		</div>
+	</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@client/components/ui/modal.vue';
+import MkButton from '@client/components/ui/button.vue';
+import bytes from '@client/filters/bytes';
+import number from '@client/filters/number';
+
+export default defineComponent({
+	components: {
+		MkModal,
+		MkButton,
+	},
+
+	props: {
+		image: {
+			type: Object,
+			required: true,
+		},
+		title: {
+			type: String,
+			required: false
+		},
+		input: {
+			required: true
+		},
+		showOkButton: {
+			type: Boolean,
+			default: true
+		},
+		showCancelButton: {
+			type: Boolean,
+			default: true
+		},
+		cancelableByBgClick: {
+			type: Boolean,
+			default: true
+		},
+	},
+
+	emits: ['done', 'closed'],
+
+	data() {
+		return {
+			inputValue: this.input.default ? this.input.default : null
+		};
+	},
+
+	mounted() {
+		document.addEventListener('keydown', this.onKeydown);
+	},
+
+	beforeUnmount() {
+		document.removeEventListener('keydown', this.onKeydown);
+	},
+
+	methods: {
+		bytes,
+		number,
+
+		done(canceled, result?) {
+			this.$emit('done', { canceled, result });
+			this.$refs.modal.close();
+		},
+
+		async ok() {
+			if (!this.showOkButton) return;
+
+			const result = this.inputValue;
+			this.done(false, result);
+		},
+
+		cancel() {
+			this.done(true);
+		},
+
+		onBgClick() {
+			if (this.cancelableByBgClick) {
+				this.cancel();
+			}
+		},
+
+		onKeydown(e) {
+			if (e.which === 27) { // ESC
+				this.cancel();
+			}
+		},
+
+		onInputKeydown(e) {
+			if (e.which === 13) { // Enter
+				if (e.ctrlKey) {
+					e.preventDefault();
+					e.stopPropagation();
+					this.ok();
+				}
+			}
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.container {
+	display: flex;
+	width: 100%;
+	height: 100%;
+	flex-direction: row;
+}
+@media (max-width: 850px) {
+	.container {
+		flex-direction: column;
+	}
+	.top-caption {
+		padding-bottom: 8px;
+	}
+}
+.fullwidth {
+	width: 100%;
+	margin: auto;
+}
+.mk-dialog {
+	position: relative;
+	padding: 32px;
+	min-width: 320px;
+	max-width: 480px;
+	box-sizing: border-box;
+	text-align: center;
+	background: var(--panel);
+	border-radius: var(--radius);
+	margin: auto;
+
+	> header {
+		margin: 0 0 8px 0;
+		font-weight: bold;
+		font-size: 20px;
+	}
+
+	> .buttons {
+		margin-top: 16px;
+
+		> * {
+			margin: 0 8px;
+		}
+	}
+
+	> textarea {
+		display: block;
+		box-sizing: border-box;
+		padding: 0 24px;
+		margin: 0;
+		width: 100%;
+		font-size: 16px;
+		border: none;
+		border-radius: 0;
+		background: transparent;
+		color: var(--fg);
+		font-family: inherit;
+		max-width: 100%;
+		min-width: 100%;
+		min-height: 90px;
+
+		&:focus {
+			outline: none;
+		}
+
+		&:disabled {
+			opacity: 0.5;
+		}
+	}
+}
+.hdrwpsaf {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+
+	> header,
+	> footer {
+		align-self: center;
+		display: inline-block;
+		padding: 6px 9px;
+		font-size: 90%;
+		background: rgba(0, 0, 0, 0.5);
+		border-radius: 6px;
+		color: #fff;
+	}
+
+	> header {
+		margin-bottom: 8px;
+		opacity: 0.9;
+	}
+
+	> img {
+		display: block;
+		flex: 1;
+		min-height: 0;
+		object-fit: contain;
+		width: 100%;
+		cursor: zoom-out;
+		image-orientation: from-image;
+	}
+
+	> footer {
+		margin-top: 8px;
+		opacity: 0.8;
+
+		> span + span {
+			margin-left: 0.5em;
+			padding-left: 0.5em;
+			border-left: solid 1px rgba(255, 255, 255, 0.5);
+		}
+	}
+}
+</style>
diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue
index 267e4debd2..863eb10272 100644
--- a/src/client/components/media-image.vue
+++ b/src/client/components/media-image.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="qjewsnkg" v-if="hide" @click="hide = false">
-	<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.name"/>
+	<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
 	<div class="text">
 		<div>
 			<b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
@@ -14,7 +14,7 @@
 		:title="image.name"
 		@click.prevent="onClick"
 	>
-		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name" :cover="false"/>
+		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
 		<div class="gif" v-if="image.type === 'image/gif'">GIF</div>
 	</a>
 	<i class="fas fa-eye-slash" @click="hide = true"></i>
diff --git a/src/client/components/post-form-attaches.vue b/src/client/components/post-form-attaches.vue
index f832ea87b5..27e20fdfa8 100644
--- a/src/client/components/post-form-attaches.vue
+++ b/src/client/components/post-form-attaches.vue
@@ -89,6 +89,27 @@ export default defineComponent({
 				file.name = result;
 			});
 		},
+
+		async describe(file) {
+			os.popup(import("@client/components/media-caption.vue"), {
+				title: this.$ts.describeFile,
+				input: {
+					placeholder: this.$ts.inputNewDescription,
+					default: file.comment !== null ? file.comment : "",
+				},
+				image: file
+			}, {
+				done: result => {
+					if (!result || result.canceled) return;
+					let comment = result.result;
+					os.api('drive/files/update', {
+						fileId: file.id,
+						comment: comment.length == 0 ? null : comment
+					});
+				}
+			}, 'closed');
+		},
+
 		showFileMenu(file, ev: MouseEvent) {
 			if (this.menu) return;
 			this.menu = os.modalMenu([{
@@ -99,6 +120,10 @@ export default defineComponent({
 				text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
 				icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye',
 				action: () => { this.toggleSensitive(file) }
+			}, {
+				text: this.$ts.describeFile,
+				icon: 'fas fa-i-cursor',
+				action: () => { this.describe(file) }
 			}, {
 				text: this.$ts.attachCancel,
 				icon: 'fas fa-times-circle',
diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts
index 79fc2bf4a6..7bec1d6030 100644
--- a/src/remote/activitypub/models/image.ts
+++ b/src/remote/activitypub/models/image.ts
@@ -28,7 +28,7 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<Drive
 	const instance = await fetchMeta();
 	const cache = instance.cacheRemoteFiles;
 
-	let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache);
+	let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache, image.name);
 
 	if (file.isLink) {
 		// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
diff --git a/src/remote/activitypub/renderer/document.ts b/src/remote/activitypub/renderer/document.ts
index 4f6ea8c4ee..f6e9dca45d 100644
--- a/src/remote/activitypub/renderer/document.ts
+++ b/src/remote/activitypub/renderer/document.ts
@@ -4,5 +4,6 @@ import { DriveFiles } from '../../../models';
 export default (file: DriveFile) => ({
 	type: 'Document',
 	mediaType: file.type,
-	url: DriveFiles.getPublicUrl(file)
+	url: DriveFiles.getPublicUrl(file),
+	name: file.comment,
 });
diff --git a/src/remote/activitypub/renderer/image.ts b/src/remote/activitypub/renderer/image.ts
index ce98f98c62..cbd4fbbe68 100644
--- a/src/remote/activitypub/renderer/image.ts
+++ b/src/remote/activitypub/renderer/image.ts
@@ -4,5 +4,6 @@ import { DriveFiles } from '../../../models';
 export default (file: DriveFile) => ({
 	type: 'Image',
 	url: DriveFiles.getPublicUrl(file),
-	sensitive: file.isSensitive
+	sensitive: file.isSensitive,
+	name: file.comment
 });
diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts
index 6eda83967b..f740fea67e 100644
--- a/src/server/api/endpoints/drive/files/update.ts
+++ b/src/server/api/endpoints/drive/files/update.ts
@@ -49,6 +49,14 @@ export const meta = {
 				'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか',
 				'en-US': 'Whether this media is NSFW'
 			}
+		},
+
+		comment: {
+			validator: $.optional.nullable.str,
+			default: undefined as any,
+			desc: {
+				'ja-JP': 'コメント'
+			}
 		}
 	},
 
@@ -92,6 +100,8 @@ export default define(meta, async (ps, user) => {
 
 	if (ps.name) file.name = ps.name;
 
+	if (ps.comment !== undefined) file.comment = ps.comment;
+
 	if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive;
 
 	if (ps.folderId !== undefined) {
@@ -113,6 +123,7 @@ export default define(meta, async (ps, user) => {
 
 	await DriveFiles.update(file.id, {
 		name: file.name,
+		comment: file.comment,
 		folderId: file.folderId,
 		isSensitive: file.isSensitive
 	});
diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts
index 2f4c5aeeaf..2f660d9035 100644
--- a/src/services/drive/upload-from-url.ts
+++ b/src/services/drive/upload-from-url.ts
@@ -25,6 +25,12 @@ export default async (
 		name = null;
 	}
 
+	// If the comment is same as the name, skip comment
+	// (image.name is passed in when receiving attachment)
+	if (comment !== null && name == comment) {
+		comment = null;
+	}
+
 	// Create temp file
 	const [path, cleanup] = await createTemp();