From 5472f4b934c8ca8c702152a4a927b4ac94cf3fdb Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 13 Dec 2023 16:56:19 +0900 Subject: [PATCH] =?UTF-8?q?enhance:=20=E3=82=A2=E3=82=A4=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=83=87=E3=82=B3=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E8=A4=87=E6=95=B0=E8=A8=AD=E5=AE=9A=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + locales/index.d.ts | 5 ++ locales/ja-JP.yml | 5 ++ packages/backend/src/core/RoleService.ts | 3 ++ .../backend/src/models/json-schema/role.ts | 1 + .../backend/src/models/json-schema/user.ts | 4 ++ .../src/server/api/endpoints/i/update.ts | 10 ++-- packages/frontend/src/account.ts | 2 +- .../src/components/MkDrive.folder.vue | 9 ++-- packages/frontend/src/components/MkWindow.vue | 2 +- .../frontend/src/components/global/MkA.vue | 2 +- .../src/components/global/MkAvatar.vue | 53 +++++++------------ packages/frontend/src/const.ts | 1 + .../frontend/src/pages/admin/roles.editor.vue | 22 +++++++- packages/frontend/src/pages/admin/roles.vue | 7 +++ .../profile.avatar-decoration-dialog.vue | 11 ++-- .../frontend/src/pages/settings/profile.vue | 39 ++++++++++---- 17 files changed, 115 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1434cde4..e5ff09edec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) - Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83) - Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加 +- Enhance: アイコンデコレーションを複数設定できるように - Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 ### Client diff --git a/locales/index.d.ts b/locales/index.d.ts index 846a6d503d..d32023f5ac 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -264,6 +264,7 @@ export interface Locale { "removeAreYouSure": string; "deleteAreYouSure": string; "resetAreYouSure": string; + "areYouSure": string; "saved": string; "messaging": string; "upload": string; @@ -1160,6 +1161,7 @@ export interface Locale { "avatarDecorations": string; "attach": string; "detach": string; + "detachAll": string; "angle": string; "flip": string; "showAvatarDecorations": string; @@ -1173,6 +1175,7 @@ export interface Locale { "doReaction": string; "code": string; "reloadRequiredToApplySettings": string; + "remainingN": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1701,6 +1704,7 @@ export interface Locale { "canHideAds": string; "canSearchNotes": string; "canUseTranslator": string; + "avatarDecorationLimit": string; }; "_condition": { "isLocal": string; @@ -2181,6 +2185,7 @@ export interface Locale { "changeAvatar": string; "changeBanner": string; "verifiedLinkDescription": string; + "avatarDecorationMax": string; }; "_exportOrImport": { "allNotes": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0d84440bc8..2ac57fd311 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -261,6 +261,7 @@ removed: "削除しました" removeAreYouSure: "「{x}」を削除しますか?" deleteAreYouSure: "「{x}」を削除しますか?" resetAreYouSure: "リセットしますか?" +areYouSure: "よろしいですか?" saved: "保存しました" messaging: "チャット" upload: "アップロード" @@ -1157,6 +1158,7 @@ tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" avatarDecorations: "アイコンデコレーション" attach: "付ける" detach: "外す" +detachAll: "全て外す" angle: "角度" flip: "反転" showAvatarDecorations: "アイコンのデコレーションを表示" @@ -1170,6 +1172,7 @@ cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述 doReaction: "リアクションする" code: "コード" reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。" +remainingN: "残り: {n}" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1610,6 +1613,7 @@ _role: canHideAds: "広告の非表示" canSearchNotes: "ノート検索の利用" canUseTranslator: "翻訳機能の利用" + avatarDecorationLimit: "アイコンデコレーションの最大取付個数" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -2084,6 +2088,7 @@ _profile: changeAvatar: "アイコン画像を変更" changeBanner: "バナー画像を変更" verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。" + avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。" _exportOrImport: allNotes: "全てのノート" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 29e48aa8ca..4de719d6a0 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -47,6 +47,7 @@ export type RolePolicies = { userListLimit: number; userEachUserListsLimit: number; rateLimitFactor: number; + avatarDecorationLimit: number; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -73,6 +74,7 @@ export const DEFAULT_POLICIES: RolePolicies = { userListLimit: 10, userEachUserListsLimit: 50, rateLimitFactor: 1, + avatarDecorationLimit: 1, }; @Injectable() @@ -326,6 +328,7 @@ export class RoleService implements OnApplicationShutdown { userListLimit: calc('userListLimit', vs => Math.max(...vs)), userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), + avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), }; } diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index dd2f32b14d..b0c6804bb8 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -145,6 +145,7 @@ export const packedRoleSchema = { userEachUserListsLimit: rolePolicyValue, canManageAvatarDecorations: rolePolicyValue, canUseTranslator: rolePolicyValue, + avatarDecorationLimit: rolePolicyValue, }, }, usersCount: { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index c6b2707b80..c6b96b85f0 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -672,6 +672,10 @@ export const packedMeDetailedOnlySchema = { type: 'number', nullable: false, optional: false, }, + avatarDecorationLimit: { + type: 'number', + nullable: false, optional: false, + }, }, }, //#region secrets diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b045c01189..399e6b88cb 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -125,7 +125,7 @@ export const meta = { const muteWords = { type: 'array', items: { oneOf: [ { type: 'array', items: { type: 'string' } }, - { type: 'string' } + { type: 'string' }, ] } } as const; export const paramDef = { @@ -137,7 +137,7 @@ export const paramDef = { birthday: { ...birthdaySchema, nullable: true }, 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: { + avatarDecorations: { type: 'array', maxItems: 16, items: { type: 'object', properties: { id: { type: 'string', format: 'misskey:id' }, @@ -251,7 +251,7 @@ export default class extends Endpoint { // eslint- function validateMuteWordRegex(mutedWords: (string[] | string)[]) { for (const mutedWord of mutedWords) { - if (typeof mutedWord !== "string") continue; + if (typeof mutedWord !== 'string') continue; const regexp = mutedWord.match(/^\/(.+)\/(.*)$/); if (!regexp) throw new ApiError(meta.errors.invalidRegexp); @@ -329,12 +329,14 @@ export default class extends Endpoint { // eslint- if (ps.avatarDecorations) { const decorations = await this.avatarDecorationService.getAll(true); - const myRoles = await this.roleService.getUserRoles(user.id); + const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); const allRoles = await this.roleService.getRoles(); const decorationIds = decorations .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); + if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); + updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ id: d.id, angle: d.angle ?? 0, diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 0e4e4b50ff..a6af298024 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -284,7 +284,7 @@ export async function openAccountMenu(opts: { text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, - }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 5322664664..b0c14d1f0b 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -39,6 +39,7 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { claimAchievement } from '@/scripts/achievements.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { MenuItem } from '@/types/menu.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -250,7 +251,7 @@ function setAsUploadFolder() { } function onContextmenu(ev: MouseEvent) { - let menu; + let menu: MenuItem[]; menu = [{ text: i18n.ts.openInWindow, icon: 'ti ti-app-window', @@ -260,18 +261,18 @@ function onContextmenu(ev: MouseEvent) { }, { }, 'closed'); }, - }, null, { + }, { type: 'divider' }, { text: i18n.ts.rename, icon: 'ti ti-forms', action: rename, - }, null, { + }, { type: 'divider' }, { text: i18n.ts.delete, icon: 'ti ti-trash', danger: true, action: deleteFolder, }]; if (defaultStore.state.devMode) { - menu = menu.concat([null, { + menu = menu.concat([{ type: 'divider' }, { icon: 'ti ti-id', text: i18n.ts.copyFolderId, action: () => { diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 1150a29e03..7c8ffcccf9 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue'; import contains from '@/scripts/contains.js'; import * as os from '@/os.js'; -import { MenuItem } from '@/types/menu'; +import { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 809dae421a..5552e96ee0 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -57,7 +57,7 @@ function onContextmenu(ev) { action: () => { router.push(props.to, 'forcePage'); }, - }, null, { + }, { type: 'divider' }, { icon: 'ti ti-external-link', text: i18n.ts.openInNewTab, action: () => { diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index c7e50e275a..6aa9a42037 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -23,16 +23,18 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -57,19 +59,14 @@ const props = withDefaults(defineProps<{ link?: boolean; preview?: boolean; indicator?: boolean; - decoration?: { - url: string; - angle?: number; - flipH?: boolean; - flipV?: boolean; - }; + decorations?: Misskey.entities.UserDetailed['avatarDecorations'][number][]; forceShowDecoration?: boolean; }>(), { target: null, link: false, preview: false, indicator: false, - decoration: undefined, + decorations: undefined, forceShowDecoration: false, }); @@ -92,27 +89,13 @@ 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; - } +function getDecorationAngle(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) { + const angle = decoration.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; - } +function getDecorationScale(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) { + const scaleX = decoration.flipH ? -1 : 1; return scaleX === 1 ? undefined : `${scaleX} 1`; } diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 397f804822..f016b7aa02 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -81,6 +81,7 @@ export const ROLE_POLICIES = [ 'userListLimit', 'userEachUserListsLimit', 'rateLimitFactor', + 'avatarDecorationLimit', ] as const; // なんか動かない diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index a8e0e8bbd1..5ded8d6931 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -531,6 +531,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ + + + + + + + + +
+
@@ -549,7 +569,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRange from '@/components/MkRange.vue'; import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; -import { ROLE_POLICIES } from '@/const'; +import { ROLE_POLICIES } from '@/const.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/scripts/clone.js'; diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index db4595b150..1bb91a0a5b 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -192,6 +192,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + {{ i18n.ts.save }} diff --git a/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue index 4d571bc9ba..c27a21217b 100644 --- a/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue +++ b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ decoration.name }}
- +
@@ -54,6 +54,7 @@ const props = defineProps<{ decoration: { id: string; url: string; + name: string; } }>(); @@ -77,18 +78,18 @@ async function attach() { flipH: flipH.value, }; await os.apiWithDialog('i/update', { - avatarDecorations: [decoration], + avatarDecorations: [...$i.avatarDecorations, decoration], }); - $i.avatarDecorations = [decoration]; + $i.avatarDecorations = [...$i.avatarDecorations, decoration]; dialog.value.close(); } async function detach() { await os.apiWithDialog('i/update', { - avatarDecorations: [], + avatarDecorations: $i.avatarDecorations.filter(x => x.id !== props.decoration.id), }); - $i.avatarDecorations = []; + $i.avatarDecorations = $i.avatarDecorations.filter(x => x.id !== props.decoration.id); dialog.value.close(); } diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ba75b539e1..a5d3835b93 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -87,16 +87,22 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
-
{{ avatarDecoration.name }}
- - +
+ {{ i18n.t('_profile.avatarDecorationMax', { max: $i?.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i?.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }}) + + {{ i18n.ts.detachAll }} + +
+
+
{{ avatarDecoration.name }}
+ + +
@@ -273,6 +279,19 @@ function openDecoration(avatarDecoration) { }, {}, 'closed'); } +function detachAllDecorations() { + os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }).then(async ({ canceled }) => { + if (canceled) return; + await os.apiWithDialog('i/update', { + avatarDecorations: [], + }); + $i.avatarDecorations = []; + }); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []);