From 4eaa02d25f83eff38cecd6db1724c8626dc3af2e Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 22 Oct 2023 13:02:24 +0900
Subject: [PATCH] enhance: improve avatar decoration

---
 locales/index.d.ts                            |   4 +
 locales/ja-JP.yml                             |   4 +
 .../1697941908548-avatar-decoration2.js       |  18 +++
 .../src/core/entities/UserEntityService.ts    |   8 +-
 packages/backend/src/models/User.ts           |  10 +-
 .../backend/src/models/json-schema/user.ts    |   8 ++
 .../src/server/api/endpoints/i/update.ts      |  14 ++-
 packages/frontend/src/components/MkRange.vue  |   5 +
 .../src/components/global/MkAvatar.vue        |  43 ++++++-
 .../profile.avatar-decoration-dialog.vue      | 114 ++++++++++++++++++
 .../frontend/src/pages/settings/profile.vue   |  20 +--
 packages/misskey-js/etc/misskey-js.api.md     |   6 +-
 packages/misskey-js/src/entities.ts           |   2 +
 13 files changed, 230 insertions(+), 26 deletions(-)
 create mode 100644 packages/backend/migration/1697941908548-avatar-decoration2.js
 create mode 100644 packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue

diff --git a/locales/index.d.ts b/locales/index.d.ts
index bb9b4b3dc4..562ed2fc7b 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1147,6 +1147,10 @@ export interface Locale {
     "privacyPolicyUrl": string;
     "tosAndPrivacyPolicy": string;
     "avatarDecorations": string;
+    "attach": string;
+    "detach": string;
+    "angle": string;
+    "flip": string;
     "_announcement": {
         "forExistingUsers": string;
         "forExistingUsersDescription": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d3d6a80b1f..0a12487c5e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1144,6 +1144,10 @@ privacyPolicy: "プライバシーポリシー"
 privacyPolicyUrl: "プライバシーポリシーURL"
 tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
 avatarDecorations: "アイコンデコレーション"
+attach: "付ける"
+detach: "外す"
+angle: "角度"
+flip: "反転"
 
 _announcement:
   forExistingUsers: "既存ユーザーのみ"
diff --git a/packages/backend/migration/1697941908548-avatar-decoration2.js b/packages/backend/migration/1697941908548-avatar-decoration2.js
new file mode 100644
index 0000000000..9d15c1c3d0
--- /dev/null
+++ b/packages/backend/migration/1697941908548-avatar-decoration2.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class AvatarDecoration21697941908548 {
+    name = 'AvatarDecoration21697941908548'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" jsonb NOT NULL DEFAULT '[]'`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`);
+    }
+}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 66facce4c2..09a7e579f0 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -338,9 +338,11 @@ export class UserEntityService implements OnModuleInit {
 			host: user.host,
 			avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
 			avatarBlurhash: user.avatarBlurhash,
-			avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({
-				id: decoration.id,
-				url: decoration.url,
+			avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
+				id: ud.id,
+				angle: ud.angle || undefined,
+				flipH: ud.flipH || undefined,
+				url: decorations.find(d => d.id === ud.id)!.url,
 			}))) : [],
 			isBot: user.isBot,
 			isCat: user.isCat,
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index c98426a7b6..c3762fcd3e 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -138,10 +138,14 @@ export class MiUser {
 	})
 	public bannerBlurhash: string | null;
 
-	@Column('varchar', {
-		length: 512, array: true, default: '{}',
+	@Column('jsonb', {
+		default: [],
 	})
-	public avatarDecorations: string[];
+	public avatarDecorations: {
+		id: string;
+		angle: number;
+		flipH: boolean;
+	}[];
 
 	@Index()
 	@Column('varchar', {
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index bf283fbeb2..75f3286eff 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -54,6 +54,14 @@ export const packedUserLiteSchema = {
 						format: 'url',
 						nullable: false, optional: false,
 					},
+					angle: {
+						type: 'number',
+						nullable: false, optional: true,
+					},
+					flipH: {
+						type: 'boolean',
+						nullable: false, optional: true,
+					},
 				},
 			},
 		},
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 79ead57a66..b03381a3f3 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -133,7 +133,13 @@ export const paramDef = {
 		lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
 		avatarId: { type: 'string', format: 'misskey:id', nullable: true },
 		avatarDecorations: { type: 'array', maxItems: 1, items: {
-			type: 'string',
+			type: 'object',
+			properties: {
+				id: { type: 'string', format: 'misskey:id' },
+				angle: { type: 'number', nullable: true, maximum: 0.5, minimum: -0.5 },
+				flipH: { type: 'boolean', nullable: true },
+			},
+			required: ['id'],
 		} },
 		bannerId: { type: 'string', format: 'misskey:id', nullable: true },
 		fields: {
@@ -309,7 +315,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
 					.map(d => d.id);
 
-				updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id));
+				updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
+					id: d.id,
+					angle: d.angle ?? 0,
+					flipH: d.flipH ?? false,
+				}));
 			}
 
 			if (ps.pinnedPageId) {
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 2cfc27ceee..04390c6f0c 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -34,6 +34,7 @@ const props = withDefaults(defineProps<{
 	textConverter?: (value: number) => string,
 	showTicks?: boolean;
 	easing?: boolean;
+	continuousUpdate?: boolean;
 }>(), {
 	step: 1,
 	textConverter: (v) => v.toString(),
@@ -123,6 +124,10 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
 		const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
 		const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
 		rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
+
+		if (props.continuousUpdate) {
+			emit('update:modelValue', finalValue.value);
+		}
 	};
 
 	let beforeValue = finalValue.value;
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index de684425a2..e22ed29b7e 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -23,7 +23,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</div>
 	</div>
-	<img v-if="decoration || user.avatarDecorations.length > 0" :class="[$style.decoration]" :src="decoration ?? user.avatarDecorations[0].url" alt="">
+	<img
+		v-if="decoration || user.avatarDecorations.length > 0"
+		:class="[$style.decoration]"
+		:src="decoration?.url ?? user.avatarDecorations[0].url"
+		:style="{
+			rotate: getDecorationAngle(),
+			scale: getDecorationScale(),
+		}"
+		alt=""
+	>
 </component>
 </template>
 
@@ -48,12 +57,18 @@ const props = withDefaults(defineProps<{
 	link?: boolean;
 	preview?: boolean;
 	indicator?: boolean;
-	decoration?: string;
+	decoration?: {
+		url: string;
+		angle?: number;
+		flipH?: boolean;
+		flipV?: boolean;
+	};
 }>(), {
 	target: null,
 	link: false,
 	preview: false,
 	indicator: false,
+	decoration: undefined,
 });
 
 const emit = defineEmits<{
@@ -73,6 +88,30 @@ function onClick(ev: MouseEvent): void {
 	emit('click', ev);
 }
 
+function getDecorationAngle() {
+	let angle;
+	if (props.decoration) {
+		angle = props.decoration.angle ?? 0;
+	} else if (props.user.avatarDecorations.length > 0) {
+		angle = props.user.avatarDecorations[0].angle ?? 0;
+	} else {
+		angle = 0;
+	}
+	return angle === 0 ? undefined : `${angle * 360}deg`;
+}
+
+function getDecorationScale() {
+	let scaleX;
+	if (props.decoration) {
+		scaleX = props.decoration.flipH ? -1 : 1;
+	} else if (props.user.avatarDecorations.length > 0) {
+		scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1;
+	} else {
+		scaleX = 1;
+	}
+	return scaleX === 1 ? undefined : `${scaleX} 1`;
+}
+
 let color = $ref<string | undefined>();
 
 watch(() => props.user.avatarBlurhash, () => {
diff --git a/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue
new file mode 100644
index 0000000000..c4bdf4a49b
--- /dev/null
+++ b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue
@@ -0,0 +1,114 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+	ref="dialog"
+	:width="400"
+	:height="450"
+	@close="cancel"
+	@closed="emit('closed')"
+>
+	<template #header>{{ i18n.ts.avatarDecorations }}</template>
+
+	<div>
+		<MkSpacer :marginMin="20" :marginMax="28">
+			<div style="text-align: center;">
+				<div :class="$style.name">{{ decoration.name }}</div>
+				<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH }"/>
+			</div>
+			<div class="_gaps_s">
+				<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
+					<template #label>{{ i18n.ts.angle }}</template>
+				</MkRange>
+				<MkSwitch v-model="flipH">
+					<template #label>{{ i18n.ts.flip }}</template>
+				</MkSwitch>
+			</div>
+		</MkSpacer>
+
+		<div :class="$style.footer" class="_buttonsCenter">
+			<MkButton v-if="using" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
+			<MkButton v-if="using" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
+			<MkButton v-else primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
+		</div>
+	</div>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { shallowRef, ref, computed } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkRange from '@/components/MkRange.vue';
+import { $i } from '@/account.js';
+
+const props = defineProps<{
+	decoration: {
+		id: string;
+		url: string;
+	}
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const using = computed(() => $i.avatarDecorations.some(x => x.id === props.decoration.id));
+const angle = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).angle ?? 0 : 0);
+const flipH = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).flipH ?? false : false);
+
+function cancel() {
+	dialog.value.close();
+}
+
+async function attach() {
+	const decoration = {
+		id: props.decoration.id,
+		angle: angle.value,
+		flipH: flipH.value,
+	};
+	await os.apiWithDialog('i/update', {
+		avatarDecorations: [decoration],
+	});
+	$i.avatarDecorations = [decoration];
+
+	dialog.value.close();
+}
+
+async function detach() {
+	await os.apiWithDialog('i/update', {
+		avatarDecorations: [],
+	});
+	$i.avatarDecorations = [];
+
+	dialog.value.close();
+}
+</script>
+
+<style lang="scss" module>
+.name {
+	position: relative;
+	z-index: 10;
+	font-weight: bold;
+	margin-bottom: 28px;
+}
+
+.footer {
+	position: sticky;
+	bottom: 0;
+	left: 0;
+	padding: 12px;
+	border-top: solid 0.5px var(--divider);
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index f3d0c12dce..8d9c3cf730 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -92,10 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 				v-for="avatarDecoration in avatarDecorations"
 				:key="avatarDecoration.id"
 				:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
-				@click="toggleDecoration(avatarDecoration)"
+				@click="openDecoration(avatarDecoration)"
 			>
 				<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
-				<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="avatarDecoration.url"/>
+				<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="{ url: avatarDecoration.url }"/>
 			</div>
 		</div>
 	</MkFolder>
@@ -266,18 +266,10 @@ function changeBanner(ev) {
 	});
 }
 
-function toggleDecoration(avatarDecoration) {
-	if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) {
-		os.apiWithDialog('i/update', {
-			avatarDecorations: [],
-		});
-		$i.avatarDecorations = [];
-	} else {
-		os.apiWithDialog('i/update', {
-			avatarDecorations: [avatarDecoration.id],
-		});
-		$i.avatarDecorations.push(avatarDecoration);
-	}
+function openDecoration(avatarDecoration) {
+	os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
+		decoration: avatarDecoration,
+	}, {}, 'closed');
 }
 
 const headerActions = $computed(() => []);
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 208fe5b16d..0a6806ae60 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2996,6 +2996,8 @@ type UserLite = {
     avatarDecorations: {
         id: ID;
         url: string;
+        angle?: number;
+        flipH?: boolean;
     }[];
     emojis: {
         name: string;
@@ -3021,8 +3023,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
 // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
 // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
 // src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
-// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
-// src/entities.ts:609:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
+// src/entities.ts:115:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
+// src/entities.ts:611:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
 // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
 
 // (No @packageDocumentation comment for this package)
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index a2a283d234..38bac3b7c3 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -19,6 +19,8 @@ export type UserLite = {
 	avatarDecorations: {
 		id: ID;
 		url: string;
+		angle?: number;
+		flipH?: boolean;
 	}[];
 	emojis: {
 		name: string;