Feat:絵文字申請機能の追加
This commit is contained in:
parent
1966876320
commit
08311ece41
3
locales/index.d.ts
vendored
3
locales/index.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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: "ノートのピン留めの最大数"
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,14 +114,18 @@ 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');
|
||||
|
||||
if (driveFile !== null) {
|
||||
await this.emojisRepository.update(emoji.id, {
|
||||
updatedAt: new Date(),
|
||||
name: data.name,
|
||||
|
@ -128,8 +137,21 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
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();
|
||||
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -81,4 +81,10 @@ export class MiEmoji {
|
|||
array: true, length: 128, default: '{}',
|
||||
})
|
||||
public roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
nullable: false,
|
||||
})
|
||||
public draft: boolean;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService {
|
|||
isSensitive: emojiInfo.isSensitive,
|
||||
localOnly: emojiInfo.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
|
||||
draft: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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}:`,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -66,6 +66,7 @@ export const ROLE_POLICIES = [
|
|||
'inviteLimitCycle',
|
||||
'inviteExpirationTime',
|
||||
'canManageCustomEmojis',
|
||||
'canRequestCustomEmojis',
|
||||
'canSearchNotes',
|
||||
'canUseTranslator',
|
||||
'canHideAds',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -30,14 +30,23 @@ 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)">
|
||||
<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>
|
||||
</div>
|
||||
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -311,6 +311,7 @@ export type CustomEmoji = {
|
|||
url: string;
|
||||
category: string;
|
||||
aliases: string[];
|
||||
draft: boolean;
|
||||
};
|
||||
|
||||
export type LiteInstanceMetadata = {
|
||||
|
|
Loading…
Reference in a new issue