From 4eaa02d25f83eff38cecd6db1724c8626dc3af2e Mon Sep 17 00:00:00 2001 From: syuilo 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 { // 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 - + @@ -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(); 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 @@ + + + + + + + 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)" >
{{ avatarDecoration.name }}
- + @@ -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;