Merge remote-branch 'misskey/develop'

This commit is contained in:
NoriDev 2023-12-13 18:57:19 +09:00
commit 9df37ca79a
23 changed files with 218 additions and 267 deletions

View file

@ -19,6 +19,7 @@
- Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) - 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: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83)
- Feat: TL上からートが見えなくなるワードミュートであるハードミュートを追加 - Feat: TL上からートが見えなくなるワードミュートであるハードミュートを追加
- Enhance: アイコンデコレーションを複数設定できるように
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 - Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client ### Client

7
locales/index.d.ts vendored
View file

@ -4,8 +4,6 @@
export interface Locale { export interface Locale {
"_lang_": string; "_lang_": string;
"cannotBeUsedFunc": string; "cannotBeUsedFunc": string;
"maxinumLayerError": string;
"layer": string;
"Xcoordinate": string; "Xcoordinate": string;
"Ycoordinate": string; "Ycoordinate": string;
"scale": string; "scale": string;
@ -337,6 +335,7 @@ export interface Locale {
"removeAreYouSure": string; "removeAreYouSure": string;
"deleteAreYouSure": string; "deleteAreYouSure": string;
"resetAreYouSure": string; "resetAreYouSure": string;
"areYouSure": string;
"saved": string; "saved": string;
"messaging": string; "messaging": string;
"upload": string; "upload": string;
@ -1265,6 +1264,7 @@ export interface Locale {
"avatarDecorations": string; "avatarDecorations": string;
"attach": string; "attach": string;
"detach": string; "detach": string;
"detachAll": string;
"angle": string; "angle": string;
"flip": string; "flip": string;
"showAvatarDecorations": string; "showAvatarDecorations": string;
@ -1278,6 +1278,7 @@ export interface Locale {
"doReaction": string; "doReaction": string;
"code": string; "code": string;
"reloadRequiredToApplySettings": string; "reloadRequiredToApplySettings": string;
"remainingN": string;
"showUnreadNotificationsCount": string; "showUnreadNotificationsCount": string;
"showCatOnly": string; "showCatOnly": string;
"additionalPermissionsForFlash": string; "additionalPermissionsForFlash": string;
@ -1919,6 +1920,7 @@ export interface Locale {
"canHideAds": string; "canHideAds": string;
"canSearchNotes": string; "canSearchNotes": string;
"canUseTranslator": string; "canUseTranslator": string;
"avatarDecorationLimit": string;
}; };
"_condition": { "_condition": {
"isLocal": string; "isLocal": string;
@ -2500,6 +2502,7 @@ export interface Locale {
"changeAvatar": string; "changeAvatar": string;
"changeBanner": string; "changeBanner": string;
"verifiedLinkDescription": string; "verifiedLinkDescription": string;
"avatarDecorationMax": string;
}; };
"_exportOrImport": { "_exportOrImport": {
"allNotes": string; "allNotes": string;

View file

@ -332,6 +332,7 @@ removed: "削除しました"
removeAreYouSure: "「{x}」を削除しますか?" removeAreYouSure: "「{x}」を削除しますか?"
deleteAreYouSure: "「{x}」を削除しますか?" deleteAreYouSure: "「{x}」を削除しますか?"
resetAreYouSure: "リセットしますか?" resetAreYouSure: "リセットしますか?"
areYouSure: "よろしいですか?"
saved: "保存しました" saved: "保存しました"
messaging: "チャット" messaging: "チャット"
upload: "アップロード" upload: "アップロード"
@ -1260,6 +1261,7 @@ tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
avatarDecorations: "アイコンデコレーション" avatarDecorations: "アイコンデコレーション"
attach: "付ける" attach: "付ける"
detach: "外す" detach: "外す"
detachAll: "全て外す"
angle: "角度" angle: "角度"
flip: "反転" flip: "反転"
showAvatarDecorations: "アイコンのデコレーションを表示" showAvatarDecorations: "アイコンのデコレーションを表示"
@ -1273,6 +1275,7 @@ cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述
doReaction: "リアクションする" doReaction: "リアクションする"
code: "コード" code: "コード"
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。" reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
remainingN: "残り: {n}"
showUnreadNotificationsCount: "未読の通知の数を表示する" showUnreadNotificationsCount: "未読の通知の数を表示する"
showCatOnly: "キャット付きのみ" showCatOnly: "キャット付きのみ"
additionalPermissionsForFlash: "Playへの追加許可" additionalPermissionsForFlash: "Playへの追加許可"
@ -1824,6 +1827,7 @@ _role:
canHideAds: "広告の非表示" canHideAds: "広告の非表示"
canSearchNotes: "ノート検索の利用" canSearchNotes: "ノート検索の利用"
canUseTranslator: "翻訳機能の利用" canUseTranslator: "翻訳機能の利用"
avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
_condition: _condition:
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"
isRemote: "リモートユーザー" isRemote: "リモートユーザー"
@ -2397,6 +2401,7 @@ _profile:
changeAvatar: "アイコン画像を変更" changeAvatar: "アイコン画像を変更"
changeBanner: "バナー画像を変更" changeBanner: "バナー画像を変更"
verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。" verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。"
avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。"
_exportOrImport: _exportOrImport:
allNotes: "全てのノート" allNotes: "全てのノート"

View file

@ -48,6 +48,7 @@ export type RolePolicies = {
userListLimit: number; userListLimit: number;
userEachUserListsLimit: number; userEachUserListsLimit: number;
rateLimitFactor: number; rateLimitFactor: number;
avatarDecorationLimit: number;
}; };
export const DEFAULT_POLICIES: RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = {
@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
userListLimit: 10, userListLimit: 10,
userEachUserListsLimit: 50, userEachUserListsLimit: 50,
rateLimitFactor: 1, rateLimitFactor: 1,
avatarDecorationLimit: 1,
}; };
@Injectable() @Injectable()
@ -329,6 +331,7 @@ export class RoleService implements OnApplicationShutdown {
userListLimit: calc('userListLimit', vs => Math.max(...vs)), userListLimit: calc('userListLimit', vs => Math.max(...vs)),
userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)),
rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)),
avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)),
}; };
} }

View file

@ -145,6 +145,7 @@ export const packedRoleSchema = {
userEachUserListsLimit: rolePolicyValue, userEachUserListsLimit: rolePolicyValue,
canManageAvatarDecorations: rolePolicyValue, canManageAvatarDecorations: rolePolicyValue,
canUseTranslator: rolePolicyValue, canUseTranslator: rolePolicyValue,
avatarDecorationLimit: rolePolicyValue,
}, },
}, },
usersCount: { usersCount: {

View file

@ -693,6 +693,10 @@ export const packedMeDetailedOnlySchema = {
type: 'number', type: 'number',
nullable: false, optional: false, nullable: false, optional: false,
}, },
avatarDecorationLimit: {
type: 'number',
nullable: false, optional: false,
},
}, },
}, },
//#region secrets //#region secrets

View file

@ -137,7 +137,7 @@ export const paramDef = {
birthday: { ...birthdaySchema, nullable: true }, birthday: { ...birthdaySchema, nullable: true },
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
avatarId: { type: 'string', format: 'misskey:id', nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true },
avatarDecorations: { type: 'array', maxItems: 1, items: { avatarDecorations: { type: 'array', maxItems: 16, items: {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string', format: 'misskey:id' }, id: { type: 'string', format: 'misskey:id' },
@ -333,12 +333,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.avatarDecorations) { if (ps.avatarDecorations) {
const decorations = await this.avatarDecorationService.getAll(true); 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 allRoles = await this.roleService.getRoles();
const decorationIds = decorations 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))) .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); .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 => ({ updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
id: d.id, id: d.id,
angle: d.angle ?? 0, angle: d.angle ?? 0,

View file

@ -1,7 +1,7 @@
/* /*
* version: 4.6.0-beta.2 * version: 4.6.0-beta.2
* basedMisskeyVersion: 2023.12.0-beta.3 * basedMisskeyVersion: 2023.12.0-beta.3
* generatedAt: 2023-12-13T07:55:59.676Z * generatedAt: 2023-12-13T08:44:16.948Z
*/ */
import type { SwitchCaseResponseType } from '../api.js'; import type { SwitchCaseResponseType } from '../api.js';

View file

@ -1,7 +1,7 @@
/* /*
* version: 4.6.0-beta.2 * version: 4.6.0-beta.2
* basedMisskeyVersion: 2023.12.0-beta.3 * basedMisskeyVersion: 2023.12.0-beta.3
* generatedAt: 2023-12-13T07:55:59.674Z * generatedAt: 2023-12-13T08:44:16.944Z
*/ */
import type { import type {

View file

@ -1,7 +1,7 @@
/* /*
* version: 4.6.0-beta.2 * version: 4.6.0-beta.2
* basedMisskeyVersion: 2023.12.0-beta.3 * basedMisskeyVersion: 2023.12.0-beta.3
* generatedAt: 2023-12-13T07:55:59.673Z * generatedAt: 2023-12-13T08:44:16.943Z
*/ */
import { operations } from './types.js'; import { operations } from './types.js';

View file

@ -1,7 +1,7 @@
/* /*
* version: 4.6.0-beta.2 * version: 4.6.0-beta.2
* basedMisskeyVersion: 2023.12.0-beta.3 * basedMisskeyVersion: 2023.12.0-beta.3
* generatedAt: 2023-12-13T07:55:59.672Z * generatedAt: 2023-12-13T08:44:16.941Z
*/ */
import { components } from './types.js'; import { components } from './types.js';

View file

@ -4,7 +4,7 @@
/* /*
* version: 4.6.0-beta.2 * version: 4.6.0-beta.2
* basedMisskeyVersion: 2023.12.0-beta.3 * basedMisskeyVersion: 2023.12.0-beta.3
* generatedAt: 2023-12-13T07:55:59.592Z * generatedAt: 2023-12-13T08:44:16.816Z
*/ */
/** /**
@ -3960,6 +3960,7 @@ export type components = {
userListLimit: number; userListLimit: number;
userEachUserListsLimit: number; userEachUserListsLimit: number;
rateLimitFactor: number; rateLimitFactor: number;
avatarDecorationLimit: number;
}; };
email?: string | null; email?: string | null;
emailVerified?: boolean | null; emailVerified?: boolean | null;
@ -4675,6 +4676,11 @@ export type components = {
priority: number; priority: number;
useDefault: boolean; useDefault: boolean;
}; };
avatarDecorationLimit: {
value: number | boolean;
priority: number;
useDefault: boolean;
};
}; };
usersCount: number; usersCount: number;
}); });

View file

@ -323,7 +323,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile, text: i18n.ts.profile,
to: `/@${ $i.username }`, to: `/@${ $i.username }`,
avatar: $i, avatar: $i,
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const, type: 'parent' as const,
icon: 'ti ti-plus', icon: 'ti ti-plus',
text: i18n.ts.addAccount, text: i18n.ts.addAccount,

View file

@ -39,6 +39,7 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder; folder: Misskey.entities.DriveFolder;
@ -250,7 +251,7 @@ function setAsUploadFolder() {
} }
function onContextmenu(ev: MouseEvent) { function onContextmenu(ev: MouseEvent) {
let menu; let menu: MenuItem[];
menu = [{ menu = [{
text: i18n.ts.openInWindow, text: i18n.ts.openInWindow,
icon: 'ti ti-app-window', icon: 'ti ti-app-window',
@ -260,18 +261,18 @@ function onContextmenu(ev: MouseEvent) {
}, { }, {
}, 'closed'); }, 'closed');
}, },
}, null, { }, { type: 'divider' }, {
text: i18n.ts.rename, text: i18n.ts.rename,
icon: 'ti ti-forms', icon: 'ti ti-forms',
action: rename, action: rename,
}, null, { }, { type: 'divider' }, {
text: i18n.ts.delete, text: i18n.ts.delete,
icon: 'ti ti-trash', icon: 'ti ti-trash',
danger: true, danger: true,
action: deleteFolder, action: deleteFolder,
}]; }];
if (defaultStore.state.devMode) { if (defaultStore.state.devMode) {
menu = menu.concat([null, { menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-id', icon: 'ti ti-id',
text: i18n.ts.copyFolderId, text: i18n.ts.copyFolderId,
action: () => { action: () => {

View file

@ -4,32 +4,34 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="$style.root" :style="{ color }" :title="acct(user)" @click="onClick"> <component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="$style.root" :style="{ color }" :title="acct(user)" @click.stop="onClick">
<MkImgWithBlurhash <MkImgWithBlurhash
:class="[$style.inner, { [$style.reduceBlurEffect]: !defaultStore.state.useBlurEffect, [$style.noDrag]: noDrag }]" :class="[$style.inner, { [$style.reduceBlurEffect]: !defaultStore.state.useBlurEffect, [$style.noDrag]: noDrag }]"
:src="url" :src="url"
:hash="user.avatarBlurhash" :hash="user.avatarBlurhash"
:cover="true" :cover="true"
:onlyAvgColor="true" :onlyAvgColor="true"
:noDrag="true" :noDrag="true"
@mouseover="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''" @mouseover="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''"
@mouseout="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''" @mouseout="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''"
@touchstart="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''" @touchstart="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''"
@touchend="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''" @touchend="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''"
/> />
<img <template v-if="showDecoration && defaultStore.state.friendlyShowAvatarDecorationsInNavBtn">
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0) && defaultStore.state.friendlyShowAvatarDecorationsInNavBtn" <img
:class="[$style.decoration]" v-for="decoration in decorations ?? user.avatarDecorations"
:src="decoration?.url ?? user.avatarDecorations[0].url" :class="[$style.decoration]"
:style="{ :src="decoration.url"
rotate: getDecorationAngle(), :style="{
scale: getDecorationScale(), rotate: getDecorationAngle(decoration),
transform: getDecorationTransform(), scale: getDecorationScale(decoration),
opacity: getDecorationOpacity(), transform: getDecorationTransform(decoration),
}" opacity: getDecorationOpacity(decoration),
alt="" }"
> alt=""
</component> >
</template>
</component>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -47,22 +49,13 @@ const props = withDefaults(defineProps<{
target?: string | null; target?: string | null;
link?: boolean; link?: boolean;
preview?: boolean; preview?: boolean;
decoration?: { decorations?: Misskey.entities.UserDetailed['avatarDecorations'][number][];
url: string;
angle?: number;
flipH?: boolean;
flipV?: boolean;
scale?: number;
moveX?: number;
moveY?: number;
opacity?: number;
};
forceShowDecoration?: boolean; forceShowDecoration?: boolean;
}>(), { }>(), {
target: null, target: null,
link: false, link: false,
preview: false, preview: false,
decoration: undefined, decorations: undefined,
forceShowDecoration: false, forceShowDecoration: false,
}); });
@ -88,59 +81,25 @@ function onClick(ev: MouseEvent): void {
emit('click', ev); emit('click', ev);
} }
function getDecorationAngle() { function getDecorationAngle(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let angle; const angle = decoration.angle ?? 0;
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`; return angle === 0 ? undefined : `${angle * 360}deg`;
} }
function getDecorationScale() { function getDecorationScale(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let scaleX; const scaleX = decoration.flipH ? -1 : 1;
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`; return scaleX === 1 ? undefined : `${scaleX} 1`;
} }
function getDecorationTransform() { function getDecorationTransform(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let scale; const scale = decoration.scale ?? 1;
let moveX; const moveX = decoration.moveX ?? 0;
let moveY; const moveY = decoration.moveY ?? 0;
if (props.decoration) {
scale = props.decoration.scale ?? 1;
moveX = props.decoration.moveX ?? 0;
moveY = props.decoration.moveY ?? 0;
} else if (props.user.avatarDecorations.length > 0) {
scale = props.user.avatarDecorations[0].scale ?? 1;
moveX = props.user.avatarDecorations[0].moveX ?? 0;
moveY = props.user.avatarDecorations[0].moveY ?? 0;
} else {
scale = 1;
moveX = 0;
moveY = 0;
}
return `${scale === 1 ? '' : `scale(${scale})`} ${moveX === 0 && moveY === 0 ? '' : `translate(${moveX}%, ${moveY}%)`}`; return `${scale === 1 ? '' : `scale(${scale})`} ${moveX === 0 && moveY === 0 ? '' : `translate(${moveX}%, ${moveY}%)`}`;
} }
function getDecorationOpacity() { function getDecorationOpacity(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let opacity; const opacity = decoration.opacity ?? 1;
if (props.decoration) {
opacity = props.decoration.opacity ?? 1;
} else if (props.user.avatarDecorations.length > 0) {
opacity = props.user.avatarDecorations[0].opacity ?? 1;
} else {
opacity = 1;
}
return opacity === 1 ? undefined : opacity; return opacity === 1 ? undefined : opacity;
} }

View file

@ -58,7 +58,7 @@ function onContextmenu(ev) {
action: () => { action: () => {
router.push(props.to, 'forcePage'); router.push(props.to, 'forcePage');
}, },
}, null, { }, { type: 'divider' }, {
icon: 'ti ti-external-link', icon: 'ti ti-external-link',
text: i18n.ts.openInNewTab, text: i18n.ts.openInNewTab,
action: () => { action: () => {

View file

@ -33,18 +33,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<img <template v-if="showDecoration">
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)" <img
:class="[$style.decoration]" v-for="decoration in decorations ?? user.avatarDecorations"
:src="decoration?.url ?? user.avatarDecorations[0].url" :class="[$style.decoration]"
:style="{ :src="decoration.url"
rotate: getDecorationAngle(), :style="{
scale: getDecorationScale(), rotate: getDecorationAngle(decoration),
transform: getDecorationTransform(), scale: getDecorationScale(decoration),
opacity: getDecorationOpacity(), transform: getDecorationTransform(decoration),
}" opacity: getDecorationOpacity(decoration),
alt="" }"
> alt=""
>
</template>
</component> </component>
</template> </template>
@ -69,23 +71,14 @@ const props = withDefaults(defineProps<{
link?: boolean; link?: boolean;
preview?: boolean; preview?: boolean;
indicator?: boolean; indicator?: boolean;
decoration?: { decorations?: Misskey.entities.UserDetailed['avatarDecorations'][number][];
url: string;
angle?: number;
flipH?: boolean;
flipV?: boolean;
scale?: number;
moveX?: number;
moveY?: number;
opacity?: number;
};
forceShowDecoration?: boolean; forceShowDecoration?: boolean;
}>(), { }>(), {
target: null, target: null,
link: false, link: false,
preview: false, preview: false,
indicator: false, indicator: false,
decoration: undefined, decorations: undefined,
forceShowDecoration: false, forceShowDecoration: false,
}); });
@ -111,59 +104,25 @@ function onClick(ev: MouseEvent): void {
emit('click', ev); emit('click', ev);
} }
function getDecorationAngle() { function getDecorationAngle(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let angle; const angle = decoration.angle ?? 0;
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`; return angle === 0 ? undefined : `${angle * 360}deg`;
} }
function getDecorationScale() { function getDecorationScale(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let scaleX; const scaleX = decoration.flipH ? -1 : 1;
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`; return scaleX === 1 ? undefined : `${scaleX} 1`;
} }
function getDecorationTransform() { function getDecorationTransform(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let scale; const scale = decoration.scale ?? 1;
let moveX; const moveX = decoration.moveX ?? 0;
let moveY; const moveY = decoration.moveY ?? 0;
if (props.decoration) {
scale = props.decoration.scale ?? 1;
moveX = props.decoration.moveX ?? 0;
moveY = props.decoration.moveY ?? 0;
} else if (props.user.avatarDecorations.length > 0) {
scale = props.user.avatarDecorations[0].scale ?? 1;
moveX = props.user.avatarDecorations[0].moveX ?? 0;
moveY = props.user.avatarDecorations[0].moveY ?? 0;
} else {
scale = 1;
moveX = 0;
moveY = 0;
}
return `${scale === 1 ? '' : `scale(${scale})`} ${moveX === 0 && moveY === 0 ? '' : `translate(${moveX}%, ${moveY}%)`}`; return `${scale === 1 ? '' : `scale(${scale})`} ${moveX === 0 && moveY === 0 ? '' : `translate(${moveX}%, ${moveY}%)`}`;
} }
function getDecorationOpacity() { function getDecorationOpacity(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let opacity; const opacity = decoration.opacity ?? 1;
if (props.decoration) {
opacity = props.decoration.opacity ?? 1;
} else if (props.user.avatarDecorations.length > 0) {
opacity = props.user.avatarDecorations[0].opacity ?? 1;
} else {
opacity = 1;
}
return opacity === 1 ? undefined : opacity; return opacity === 1 ? undefined : opacity;
} }

View file

@ -4,35 +4,37 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="$style.root" :style="{ color }" :title="acct(user)" @click="onClick"> <component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="$style.root" :style="{ color }" :title="acct(user)" @click.stop="onClick">
<MkImgWithBlurhash <MkImgWithBlurhash
:class="$style.inner" :class="$style.inner"
:src="url" :src="url"
:hash="user.avatarBlurhash" :hash="user.avatarBlurhash"
:cover="true" :cover="true"
:onlyAvgColor="true" :onlyAvgColor="true"
@mouseover="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''" @mouseover="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''"
@mouseout="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''" @mouseout="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''"
@touchstart="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''" @touchstart="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = true : ''"
@touchend="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''" @touchend="defaultStore.state.showingAnimatedImages === 'interaction' ? playAnimation = false : ''"
/> />
<img <template v-if="showDecoration">
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)" <img
:class="[$style.decoration]" v-for="decoration in decorations ?? user.avatarDecorations"
:src="decoration?.url ?? user.avatarDecorations[0].url" :class="[$style.decoration]"
:style="{ :src="decoration.url"
rotate: getDecorationAngle(decoration), :style="{
scale: getDecorationScale(decoration), rotate: getDecorationAngle(decoration),
transform: getDecorationTransform(decoration), scale: getDecorationScale(decoration),
opacity: getDecorationOpacity(decoration), transform: getDecorationTransform(decoration),
}" opacity: getDecorationOpacity(decoration),
alt="" }"
> alt=""
</component> >
</template>
</component>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { onMounted, onUnmounted, watch, ref, computed } from 'vue';
import * as Misskey from 'cherrypick-js'; import * as Misskey from 'cherrypick-js';
import MkA from '@/components/global/MkA.vue'; import MkA from '@/components/global/MkA.vue';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
@ -46,22 +48,13 @@ const props = withDefaults(defineProps<{
target?: string | null; target?: string | null;
link?: boolean; link?: boolean;
preview?: boolean; preview?: boolean;
decoration?: { decorations?: Misskey.entities.UserDetailed['avatarDecorations'][number][];
url: string;
angle?: number;
flipH?: boolean;
flipV?: boolean;
scale?: number;
moveX?: number;
moveY?: number;
opacity?: number;
};
forceShowDecoration?: boolean; forceShowDecoration?: boolean;
}>(), { }>(), {
target: null, target: null,
link: false, link: false,
preview: false, preview: false,
decoration: undefined, decorations: undefined,
forceShowDecoration: false, forceShowDecoration: false,
}); });
@ -87,59 +80,25 @@ function onClick(ev: MouseEvent): void {
emit('click', ev); emit('click', ev);
} }
function getDecorationAngle() { function getDecorationAngle(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let angle; const angle = decoration.angle ?? 0;
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`; return angle === 0 ? undefined : `${angle * 360}deg`;
} }
function getDecorationScale() { function getDecorationScale(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let scaleX; const scaleX = decoration.flipH ? -1 : 1;
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`; return scaleX === 1 ? undefined : `${scaleX} 1`;
} }
function getDecorationTransform() { function getDecorationTransform(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let scale; const scale = decoration.scale ?? 1;
let moveX; const moveX = decoration.moveX ?? 0;
let moveY; const moveY = decoration.moveY ?? 0;
if (props.decoration) {
scale = props.decoration.scale ?? 1;
moveX = props.decoration.moveX ?? 0;
moveY = props.decoration.moveY ?? 0;
} else if (props.user.avatarDecorations.length > 0) {
scale = props.user.avatarDecorations[0].scale ?? 1;
moveX = props.user.avatarDecorations[0].moveX ?? 0;
moveY = props.user.avatarDecorations[0].moveY ?? 0;
} else {
scale = 1;
moveX = 0;
moveY = 0;
}
return `${scale === 1 ? '' : `scale(${scale})`} ${moveX === 0 && moveY === 0 ? '' : `translate(${moveX}%, ${moveY}%)`}`; return `${scale === 1 ? '' : `scale(${scale})`} ${moveX === 0 && moveY === 0 ? '' : `translate(${moveX}%, ${moveY}%)`}`;
} }
function getDecorationOpacity() { function getDecorationOpacity(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
let opacity; const opacity = decoration.opacity ?? 1;
if (props.decoration) {
opacity = props.decoration.opacity ?? 1;
} else if (props.user.avatarDecorations.length > 0) {
opacity = props.user.avatarDecorations[0].opacity ?? 1;
} else {
opacity = 1;
}
return opacity === 1 ? undefined : opacity; return opacity === 1 ? undefined : opacity;
} }
@ -199,11 +158,11 @@ onUnmounted(() => {
} }
.decoration { .decoration {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: -50%; top: -50%;
left: -50%; left: -50%;
width: 200%; width: 200%;
pointer-events: none; pointer-events: none;
} }
</style> </style>

View file

@ -82,6 +82,7 @@ export const ROLE_POLICIES = [
'userListLimit', 'userListLimit',
'userEachUserListsLimit', 'userEachUserListsLimit',
'rateLimitFactor', 'rateLimitFactor',
'avatarDecorationLimit',
] as const; ] as const;
// なんか動かない // なんか動かない

View file

@ -551,6 +551,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange> </MkRange>
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
<template #suffix>
<span v-if="role.policies.avatarDecorationLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.avatarDecorationLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.avatarDecorationLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
</MkInput>
<MkRange v-model="role.policies.avatarDecorationLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div> </div>
</FormSlot> </FormSlot>
</div> </div>
@ -569,7 +589,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue'; import FormSlot from '@/components/form/slot.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ROLE_POLICIES } from '@/const'; import { ROLE_POLICIES } from '@/const.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';

View file

@ -200,6 +200,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
<template #suffix>{{ policies.avatarDecorationLimit }}</template>
<MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0">
</MkInput>
</MkFolder>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton> <MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div> </div>
</MkFolder> </MkFolder>

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="400" :width="400"
:height="700" :height="450"
@close="cancel" @close="cancel"
@closed="emit('closed')" @closed="emit('closed')"
> >
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="28"> <MkSpacer :marginMin="20" :marginMax="28">
<div style="text-align: center;"> <div style="text-align: center;">
<div :class="$style.name">{{ decoration.name }}</div> <div :class="$style.name">{{ decoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH, scale, moveX, moveY, opacity }" forceShowDecoration/> <MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decorations="[...$i.avatarDecorations, { url: decoration.url, angle, flipH, scale, moveX, moveY, opacity }]" forceShowDecoration/>
</div> </div>
<div class="_gaps_s"> <div class="_gaps_s">
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`"> <MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
@ -66,6 +66,7 @@ const props = defineProps<{
decoration: { decoration: {
id: string; id: string;
url: string; url: string;
name: string;
} }
}>(); }>();
@ -97,18 +98,18 @@ async function attach() {
opacity: opacity.value, opacity: opacity.value,
}; };
await os.apiWithDialog('i/update', { await os.apiWithDialog('i/update', {
avatarDecorations: [decoration], avatarDecorations: [...$i.avatarDecorations, decoration],
}); });
$i.avatarDecorations = [decoration]; $i.avatarDecorations = [...$i.avatarDecorations, decoration];
dialog.value.close(); dialog.value.close();
} }
async function detach() { async function detach() {
await os.apiWithDialog('i/update', { 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(); dialog.value.close();
} }

View file

@ -87,16 +87,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-sparkles"></i></template> <template #icon><i class="ti ti-sparkles"></i></template>
<template #label>{{ i18n.ts.avatarDecorations }}</template> <template #label>{{ i18n.ts.avatarDecorations }}</template>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;"> <div class="_gaps">
<div <MkInfo>{{ i18n.t('_profile.avatarDecorationMax', { max: $i?.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i?.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo>
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id" <MkButton v-if="$i.avatarDecorations.length > 0" danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton>
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="openDecoration(avatarDecoration)" <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
> <div
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div> v-for="avatarDecoration in avatarDecorations"
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/> :key="avatarDecoration.id"
<i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ti ti-lock"></i> :class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="openDecoration(avatarDecoration)"
>
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/>
<i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ti ti-lock"></i>
</div>
</div> </div>
</div> </div>
</MkFolder> </MkFolder>
@ -285,6 +291,19 @@ function openDecoration(avatarDecoration) {
}, {}, 'closed'); }, {}, '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 = [];
});
}
async function reloadAsk() { async function reloadAsk() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'info', type: 'info',