絵文字の登録リクエスト機能の追加

(cherry picked from commit 702a3e86878bc7f210d90f15c4f4417d542ba086)
This commit is contained in:
tar_bin 2023-05-17 00:44:19 +09:00 committed by mattyatea
parent 096fa16c4c
commit ffa81260ac
24 changed files with 351 additions and 64 deletions

3
locales/index.d.ts vendored
View File

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

View File

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

@ -31,6 +31,12 @@ export class CustomEmojiService implements OnApplicationShutdown {
@Inject(DI.redis) @Inject(DI.redis)
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.config)
private config: Config,
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository) @Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository, private emojisRepository: EmojisRepository,
@ -66,6 +72,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license: string | null; license: string | null;
isSensitive: boolean; isSensitive: boolean;
localOnly: boolean; localOnly: boolean;
draft: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][];
}, moderator?: MiUser): Promise<MiEmoji> { }, moderator?: MiUser): Promise<MiEmoji> {
const emoji = await this.emojisRepository.insert({ const emoji = await this.emojisRepository.insert({
@ -82,6 +89,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
isSensitive: data.isSensitive, isSensitive: data.isSensitive,
localOnly: data.localOnly, localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: data.draft,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) { if (data.host == null) {
@ -111,6 +119,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license?: string | null; license?: string | null;
isSensitive?: boolean; isSensitive?: boolean;
localOnly?: boolean; localOnly?: boolean;
draft: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
}, moderator?: MiUser): Promise<void> { }, moderator?: MiUser): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
@ -125,9 +134,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license: data.license, license: data.license,
isSensitive: data.isSensitive, isSensitive: data.isSensitive,
localOnly: data.localOnly, localOnly: data.localOnly,
originalUrl: data.driveFile != null ? data.driveFile.url : undefined, draft: data.draft,
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, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
}); });

View File

@ -32,6 +32,7 @@ export type RolePolicies = {
inviteLimitCycle: number; inviteLimitCycle: number;
inviteExpirationTime: number; inviteExpirationTime: number;
canManageCustomEmojis: boolean; canManageCustomEmojis: boolean;
canRequestCustomEmojis: boolean;
canSearchNotes: boolean; canSearchNotes: boolean;
canUseTranslator: boolean; canUseTranslator: boolean;
canHideAds: boolean; canHideAds: boolean;
@ -57,6 +58,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteLimitCycle: 60 * 24 * 7, inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0, inviteExpirationTime: 0,
canManageCustomEmojis: false, canManageCustomEmojis: false,
canRequestCustomEmojis: false,
canSearchNotes: false, canSearchNotes: false,
canUseTranslator: true, canUseTranslator: true,
canHideAds: false, canHideAds: false,
@ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown {
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), 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)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', 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, url: emoji.publicUrl || emoji.originalUrl,
isSensitive: emoji.isSensitive ? true : undefined, isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
draft: emoji.draft,
}; };
} }
@ -61,6 +62,7 @@ export class EmojiEntityService {
isSensitive: emoji.isSensitive, isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly, localOnly: emoji.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: emoji.draft,
}; };
} }

View File

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

View File

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

View File

@ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService {
isSensitive: emojiInfo.isSensitive, isSensitive: emojiInfo.isSensitive,
localOnly: emojiInfo.localOnly, localOnly: emojiInfo.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: [], 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_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_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_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_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_deleteBulk from './endpoints/admin/emoji/delete-bulk.js';
import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.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_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_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_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_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_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 }; 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_drive_showFile,
$admin_emoji_addAliasesBulk, $admin_emoji_addAliasesBulk,
$admin_emoji_add, $admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_copy, $admin_emoji_copy,
$admin_emoji_deleteBulk, $admin_emoji_deleteBulk,
$admin_emoji_delete, $admin_emoji_delete,
@ -1077,6 +1080,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_drive_showFile, $admin_drive_showFile,
$admin_emoji_addAliasesBulk, $admin_emoji_addAliasesBulk,
$admin_emoji_add, $admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_copy, $admin_emoji_copy,
$admin_emoji_deleteBulk, $admin_emoji_deleteBulk,
$admin_emoji_delete, $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_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_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_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_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_deleteBulk from './endpoints/admin/emoji/delete-bulk.js';
import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.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/drive/show-file', ep___admin_drive_showFile],
['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk],
['admin/emoji/add', ep___admin_emoji_add], ['admin/emoji/add', ep___admin_emoji_add],
['admin/emoji/add-draft', ep___admin_emoji_addDraft],
['admin/emoji/copy', ep___admin_emoji_copy], ['admin/emoji/copy', ep___admin_emoji_copy],
['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], ['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk],
['admin/emoji/delete', ep___admin_emoji_delete], ['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', type: 'string',
} }, } },
}, },
required: ['name', 'fileId'], required: ['name','fileId', 'draft'],
} as const; } as const;
// TODO: ロジックをサービスに切り出す // TODO: ロジックをサービスに切り出す
@ -68,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.duplicateName); 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, license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false, isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false, localOnly: ps.localOnly ?? false,
draft: false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
}, me); }, me);

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps"> <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)" 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"> <div class="query">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search"> <MkInput v-model="q" class="" :placeholder="i18n.ts.search">
@ -22,21 +23,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection v-if="searchEmojis"> <MkFoldableSection v-if="searchEmojis">
<template #header>{{ i18n.ts.searchResult }}</template> <template #header>{{ i18n.ts.searchResult }}</template>
<div :class="$style.emojis"> <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> </div>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category">
<template #header>{{ category || i18n.ts.other }}</template> <template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.emojis"> <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> </div>
</MkFoldableSection> </MkFoldableSection>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from 'vue'; import { watch, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XEmoji from './emojis.emoji.vue'; import XEmoji from './emojis.emoji.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -44,6 +45,7 @@ import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
const customEmojiTags = getCustomEmojiTags(); 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), () => { watch($$(q), () => {
search(); search();
}); });

View File

@ -259,6 +259,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </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'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix> <template #suffix>

View File

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

View File

@ -30,14 +30,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}"> <template #default="{items}">
<div class="ldhfsamy"> <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"/> <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="body">
<div class="name _monospace">{{ emoji.name }}</div> <div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div> <div class="info">{{ emoji.category }}</div>
</div> </div>
</button> </button>
</div> </div>
</div>
</template> </template>
</MkPagination> </MkPagination>
</div> </div>
@ -57,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{items}"> <template #default="{items}">
<div class="ldhfsamy"> <div class="ldhfsamy">
<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> <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="body">
<div class="name _monospace">{{ emoji.name }}</div> <div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div> <div class="info">{{ emoji.host }}</div>
@ -148,6 +157,7 @@ const edit = (emoji) => {
...oldEmoji, ...oldEmoji,
...result.updated, ...result.updated,
})); }));
emojisPaginationComponent.value.reload();
} else if (result.deleted) { } else if (result.deleted) {
emojisPaginationComponent.value.removeItem(emoji.id); emojisPaginationComponent.value.removeItem(emoji.id);
} }
@ -323,12 +333,13 @@ definePageMetadata(computed(() => ({
grid-gap: 12px; grid-gap: 12px;
margin: var(--margin) 0; margin: var(--margin) 0;
> .emoji { div > .emoji {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 11px; padding: 11px;
text-align: left; text-align: left;
border: solid 1px var(--panel); border: solid 1px var(--panel);
width: 100%;
&:hover { &:hover {
border-color: var(--inputBorderHover); 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> </style>

View File

@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="400" :width="400"
:with-ok-button="true"
@close="dialog.close()" @close="dialog.close()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
@ -68,6 +69,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer> </MkSpacer>
<div :class="$style.footer"> <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> <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>
</div> </div>
</MkModalWindow> </MkModalWindow>
@ -87,9 +92,11 @@ import { customEmojiCategories } from '@/custom-emojis.js';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import { selectFile, selectFiles } from '@/scripts/select-file.js'; import { selectFile, selectFiles } from '@/scripts/select-file.js';
import MkRolePreview from '@/components/MkRolePreview.vue'; import MkRolePreview from '@/components/MkRolePreview.vue';
import { $i } from '@/account';
const props = defineProps<{ const props = defineProps<{
emoji?: any, emoji?: any,
isRequest: boolean,
}>(); }>();
let dialog = $ref(null); let dialog = $ref(null);
@ -102,18 +109,55 @@ let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref<Misskey.entities.DriveFile>(); 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 () => { watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true }); }, { immediate: true });
const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); 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<{ const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): 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) { async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null); file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, ''); const candidate = file.name.replace(/\.(.+)$/, '');
@ -137,7 +181,31 @@ async function addRole() {
async function removeRole(role, ev) { async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); 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() { async function done() {
const params = { const params = {
name, name,

View File

@ -4,7 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <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"/> <img :src="emoji.url" :class="$style.img" loading="lazy"/>
<div :class="$style.body"> <div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div> <div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
@ -25,6 +32,7 @@ const props = defineProps<{
aliases: string[]; aliases: string[];
category: string; category: string;
url: string; url: string;
draft: boolean;
}; };
}>(); }>();
@ -91,4 +99,10 @@ function menu(ev) {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; 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> </style>

View File

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