diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1434cde..e5ff09ede 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 846a6d503..d32023f5a 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 0d84440bc..2ac57fd31 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 29e48aa8c..4de719d6a 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 dd2f32b14..b0c6804bb 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 c6b2707b8..c6b96b85f 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 b045c0118..399e6b88c 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 0e4e4b50f..a6af29802 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 532266466..b0c14d1f0 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 1150a29e0..7c8ffcccf 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 809dae421..5552e96ee 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 c7e50e275..6aa9a4203 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 397f80482..f016b7aa0 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 a8e0e8bbd..5ded8d693 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 db4595b15..1bb91a0a5 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 4d571bc9b..c27a21217 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 ba75b539e..a5d3835b9 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(() => []);