Feat:絵文字申請機能の追加

This commit is contained in:
tar_bin 2023-05-17 00:44:19 +09:00 committed by mattyatea
parent 1966876320
commit 08311ece41
No known key found for this signature in database
GPG Key ID: 068E54E2C33BEF9A
24 changed files with 339 additions and 31 deletions

3
locales/index.d.ts vendored
View File

@ -978,6 +978,7 @@ export interface Locale {
"unassign": string;
"color": string;
"manageCustomEmojis": string;
"requestCustomEmojis": string;
"youCannotCreateAnymore": string;
"cannotPerformTemporary": string;
"cannotPerformTemporaryDescription": string;
@ -1020,6 +1021,7 @@ export interface Locale {
"sensitiveWordsDescription2": string;
"notesSearchNotAvailable": string;
"license": string;
"draft": string;
"unfavoriteConfirm": string;
"myClips": string;
"drivecleaner": string;
@ -1556,6 +1558,7 @@ export interface Locale {
"inviteLimitCycle": string;
"inviteExpirationTime": string;
"canManageCustomEmojis": string;
"canRequestCustomEmojis": string;
"driveCapacity": string;
"alwaysMarkNsfw": string;
"pinMax": string;

View File

@ -975,6 +975,7 @@ assign: "アサイン"
unassign: "アサインを解除"
color: "色"
manageCustomEmojis: "カスタム絵文字の管理"
requestCustomEmojis: "カスタム絵文字のリクエスト"
youCannotCreateAnymore: "これ以上作成することはできません。"
cannotPerformTemporary: "一時的に利用できません"
cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
@ -1017,6 +1018,7 @@ sensitiveWordsDescription: "設定したワードが含まれるノートの公
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス"
draft: "ドラフト"
unfavoriteConfirm: "お気に入り解除しますか?"
myClips: "自分のクリップ"
drivecleaner: "ドライブクリーナー"
@ -1477,6 +1479,7 @@ _role:
inviteLimitCycle: "招待コードの発行間隔"
inviteExpirationTime: "招待コードの有効期限"
canManageCustomEmojis: "カスタム絵文字の管理"
canRequestCustomEmojis: "カスタム絵文字のリクエスト"
driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与"
pinMax: "ノートのピン留めの最大数"

View File

@ -0,0 +1,11 @@
export class AddEmojiDraftFlag1684236161625 {
name = 'AddEmojiDraftFlag1684236161625'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "emoji" ADD "draft" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "draft"`);
}
}

View File

@ -13,10 +13,10 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiEmoji } from '@/models/Emoji.js';
import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@ -34,6 +34,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private utilityService: UtilityService,
private idService: IdService,
private emojiEntityService: EmojiEntityService,
@ -66,6 +69,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license: string | null;
isSensitive: boolean;
localOnly: boolean;
draft: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][];
}, moderator?: MiUser): Promise<MiEmoji> {
const emoji = await this.emojisRepository.insert({
@ -82,6 +86,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
isSensitive: data.isSensitive,
localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: data.draft,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) {
@ -109,27 +114,44 @@ export class CustomEmojiService implements OnApplicationShutdown {
category?: string | null;
aliases?: string[];
license?: string | null;
fileId?: string | null;
isSensitive?: boolean;
localOnly?: boolean;
draft: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
}, moderator?: MiUser): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const driveFile = data.fileId !== null ? await this.driveFilesRepository.findOneBy({ id: data.fileId }) : null;
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
name: data.name,
category: data.category,
aliases: data.aliases,
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
});
if (driveFile !== null) {
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
name: data.name,
category: data.category,
aliases: data.aliases,
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
draft: data.draft,
});
} else {
await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(),
name: data.name,
category: data.category,
aliases: data.aliases,
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
draft: data.draft,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
});
}
this.localEmojisCache.refresh();

View File

@ -32,6 +32,7 @@ export type RolePolicies = {
inviteLimitCycle: number;
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canRequestCustomEmojis: boolean;
canSearchNotes: boolean;
canUseTranslator: boolean;
canHideAds: boolean;
@ -57,6 +58,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canRequestCustomEmojis: false,
canSearchNotes: false,
canUseTranslator: true,
canHideAds: false,
@ -300,6 +302,7 @@ export class RoleService implements OnApplicationShutdown {
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),

View File

@ -33,6 +33,7 @@ export class EmojiEntityService {
url: emoji.publicUrl || emoji.originalUrl,
isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
draft: emoji.draft,
};
}
@ -61,6 +62,7 @@ export class EmojiEntityService {
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: emoji.draft,
};
}

View File

@ -81,4 +81,10 @@ export class MiEmoji {
array: true, length: 128, default: '{}',
})
public roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
@Column('boolean', {
default: false,
nullable: false,
})
public draft: boolean;
}

View File

@ -40,6 +40,10 @@ export const packedEmojiSimpleSchema = {
format: 'id',
},
},
draft: {
type: 'boolean',
optional: false, nullable: true,
},
},
} as const;
@ -81,6 +85,10 @@ export const packedEmojiDetailedSchema = {
type: 'string',
optional: false, nullable: true,
},
draft: {
type: 'boolean',
optional: false, nullable: true,
},
isSensitive: {
type: 'boolean',
optional: false, nullable: false,

View File

@ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService {
isSensitive: emojiInfo.isSensitive,
localOnly: emojiInfo.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
draft: false,
});
}

View File

@ -25,6 +25,7 @@ import * as ep___admin_drive_files from './endpoints/admin/drive/files.js';
import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js';
import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js';
import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js';
import * as ep___admin_emoji_addDraft from './endpoints/admin/emoji/add-draft.js';
import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js';
import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js';
import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js';
@ -375,6 +376,7 @@ const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass
const $admin_drive_showFile: Provider = { provide: 'ep:admin/drive/show-file', useClass: ep___admin_drive_showFile.default };
const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-aliases-bulk', useClass: ep___admin_emoji_addAliasesBulk.default };
const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default };
const $admin_emoji_addDraft: Provider = { provide: 'ep:admin/emoji/add-draft', useClass: ep___admin_emoji_addDraft.default };
const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default };
const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default };
const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default };
@ -729,6 +731,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_drive_showFile,
$admin_emoji_addAliasesBulk,
$admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_copy,
$admin_emoji_deleteBulk,
$admin_emoji_delete,
@ -1077,6 +1080,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_drive_showFile,
$admin_emoji_addAliasesBulk,
$admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_copy,
$admin_emoji_deleteBulk,
$admin_emoji_delete,

View File

@ -25,6 +25,7 @@ import * as ep___admin_drive_files from './endpoints/admin/drive/files.js';
import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js';
import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js';
import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js';
import * as ep___admin_emoji_addDraft from './endpoints/admin/emoji/add-draft.js';
import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js';
import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js';
import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js';
@ -373,6 +374,7 @@ const eps = [
['admin/drive/show-file', ep___admin_drive_showFile],
['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk],
['admin/emoji/add', ep___admin_emoji_add],
['admin/emoji/add-draft', ep___admin_emoji_addDraft],
['admin/emoji/copy', ep___admin_emoji_copy],
['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk],
['admin/emoji/delete', ep___admin_emoji_delete],

View File

@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import rndstr from 'rndstr';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireRolePolicy: 'canRequestCustomEmojis',
errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
category: {
type: 'string',
nullable: true,
description: 'Use `null` to reset the category.',
},
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['name', 'fileId'],
} as const;
// TODO: ロジックをサービスに切り出す
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private customEmojiService: CustomEmojiService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const emoji = await this.customEmojiService.add({
driveFile,
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
license: ps.license ?? null,
host: null,
draft: true,
});
this.moderationLogService.insertModerationLog(me, 'addEmoji', {
emojiId: emoji.id,
});
return {
id: emoji.id,
};
});
}
}

View File

@ -51,7 +51,7 @@ export const paramDef = {
type: 'string',
} },
},
required: ['name', 'fileId'],
required: ['name','fileId', 'draft'],
} as const;
// TODO: ロジックをサービスに切り出す
@ -68,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
@ -81,6 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
draft: false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
}, me);

View File

@ -55,8 +55,9 @@ export const paramDef = {
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
draft: { type: 'boolean' },
},
required: ['id', 'name', 'aliases'],
required: ['id', 'name', 'aliases', 'draft'],
} as const;
@Injectable()
@ -93,6 +94,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isSensitive: ps.isSensitive,
localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
fileId: ps.fileId ?? null,
draft: ps.draft,
}, me);
});
}

View File

@ -96,6 +96,10 @@ const emojiDb = computed(() => {
const customEmojiDB: EmojiDef[] = [];
for (const x of customEmojis.value) {
if (x.draft) {
continue;
}
customEmojiDB.push({
name: x.name,
emoji: `:${x.name}:`,

View File

@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="category in customEmojiCategories"
:key="`custom:${category}`"
:initialShown="false"
:emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))"
:emojis="computed(() => customEmojis.filter(emoji => !emoji.draft).filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))"
@chosen="chosen"
>
{{ category || i18n.ts.other }}
@ -157,7 +157,7 @@ watch(q, () => {
const searchCustom = () => {
const max = 100;
const emojis = customEmojis.value;
const emojis = customEmojis.value.filter(emoji => !emoji.draft);
const matches = new Set<Misskey.entities.CustomEmoji>();
const exactMatch = emojis.find(emoji => emoji.name === newQ);

View File

@ -66,6 +66,7 @@ export const ROLE_POLICIES = [
'inviteLimitCycle',
'inviteExpirationTime',
'canManageCustomEmojis',
'canRequestCustomEmojis',
'canSearchNotes',
'canUseTranslator',
'canHideAds',

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
<MkButton v-if="$i && (!$i.isModerator && !$i.policies.canManageCustomEmojis && $i.policies.canRequestCustomEmojis)" primary @click="edit">{{ i18n.ts.requestCustomEmojis }}</MkButton>
<div class="query">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search">
@ -22,21 +23,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection v-if="searchEmojis">
<template #header>{{ i18n.ts.searchResult }}</template>
<div :class="$style.emojis">
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/>
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
</div>
</MkFoldableSection>
<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category">
<template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.emojis">
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/>
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
</div>
</MkFoldableSection>
</div>
</template>
<script lang="ts" setup>
import { watch } from 'vue';
import { watch, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import XEmoji from './emojis.emoji.vue';
import MkButton from '@/components/MkButton.vue';
@ -44,6 +45,7 @@ import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os';
import { $i } from '@/account.js';
const customEmojiTags = getCustomEmojiTags();
@ -80,6 +82,24 @@ function toggleTag(tag) {
}
}
const edit = () => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
emoji: {
name: '',
category: null,
aliases: [],
license: '',
url: '',
draft: true,
},
isRequest: true,
}, {
done: result => {
window.location.reload();
},
}, 'closed');
};
watch($$(q), () => {
search();
});

View File

@ -259,6 +259,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template>
<template #suffix>
<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :text-converter="(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>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>

View File

@ -87,6 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template>
<template #suffix>{{ policies.canRequestCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canRequestCustomEmojis">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@ -30,13 +30,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
<div v-for="emoji in items" :key="emoji.id">
<button v-if="emoji.draft" class="emoji _panel _button emoji-draft" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
<button v-else class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
</div>
</div>
</template>
</MkPagination>
@ -57,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{items}">
<div class="ldhfsamy">
<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/>
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
@ -141,6 +150,7 @@ const add = async (ev: MouseEvent) => {
const edit = (emoji) => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
emoji: emoji,
isRequest: false,
}, {
done: result => {
if (result.updated) {
@ -323,12 +333,13 @@ definePageMetadata(computed(() => ({
grid-gap: 12px;
margin: var(--margin) 0;
> .emoji {
div > .emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
width: 100%;
&:hover {
border-color: var(--inputBorderHover);
@ -410,4 +421,10 @@ definePageMetadata(computed(() => ({
}
}
}
.emoji-draft {
--c: rgb(255 196 0 / 15%);;
background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%);
background-size: 16px 16px;
}
</style>

View File

@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow
ref="dialog"
:width="400"
:withOkButton="true"
@close="dialog.close()"
@closed="$emit('closed')"
>
@ -68,6 +69,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer>
<div :class="$style.footer">
<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton>
<MkSwitch v-if="!isRequest" v-model="draft" :disabled="isRequest">
{{ i18n.ts.draft }}
</MkSwitch>
<MkButton v-if="!isRequest" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</MkModalWindow>
@ -90,6 +95,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{
emoji?: any,
isRequest: boolean,
}>();
let dialog = $ref(null);
@ -102,18 +108,55 @@ let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref<Misskey.entities.DriveFile>();
let chooseFile: DriveFile|null = $ref(null);
let draft = $ref(props.emoji.draft);
let isRequest = $ref(props.isRequest);
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
let draft = $ref(props.emoji.draft);
let isRequest = $ref(props.isRequest);
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
function ok() {
if (isRequest) {
if (chooseFile !== null && name.match(/^[a-zA-Z0-9_]+$/)) {
add();
}
} else {
update();
}
}
async function add() {
const ret = await os.api('admin/emoji/add-draft', {
name: name,
category: category,
aliases: aliases.split(' '),
license: license === '' ? null : license,
fileId: chooseFile.id,
});
emit('done', {
updated: {
id: ret.id,
name,
category,
aliases: aliases.split(' '),
license: license === '' ? null : license,
draft: true,
},
});
dialog.close();
}
async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, '');
@ -137,7 +180,30 @@ async function addRole() {
async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id);
}
async function update() {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
name,
category,
aliases: aliases.split(' '),
license: license === '' ? null : license,
fileId: chooseFile?.id,
draft: draft,
});
emit('done', {
updated: {
id: props.emoji.id,
name,
category,
aliases: aliases.split(' '),
license: license === '' ? null : license,
draft: draft,
},
});
dialog.close();
}
async function done() {
const params = {
name,
@ -178,6 +244,13 @@ async function done() {
}
}
function chooseFileFrom(ev) {
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
chooseFile = files_[0];
url = chooseFile.url;
});
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',

View File

@ -4,7 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<button class="_button" :class="$style.root" @click="menu">
<button v-if="emoji.draft" class="zuvgdzyu _button emoji-draft" @click="menu">
<img :src="emoji.url" class="img" loading="lazy"/>
<div class="body">
<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div>
</div>
</button>
<button v-else class="_button" :class="$style.root" @click="menu">
<img :src="emoji.url" :class="$style.img" loading="lazy"/>
<div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
@ -25,6 +32,7 @@ const props = defineProps<{
aliases: string[];
category: string;
url: string;
draft: boolean;
};
}>();
@ -91,4 +99,10 @@ function menu(ev) {
text-overflow: ellipsis;
overflow: hidden;
}
.emoji-draft {
--c: rgb(255 196 0 / 15%);;
background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%);
background-size: 16px 16px;
}
</style>

View File

@ -311,6 +311,7 @@ export type CustomEmoji = {
url: string;
category: string;
aliases: string[];
draft: boolean;
};
export type LiteInstanceMetadata = {