diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-05-18 18:45:49 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2023-05-18 18:45:49 +0900 |
| commit | 7ce569424a67c786a4f44993ce74f06989eba9df (patch) | |
| tree | 895e142f514452310382ce8c315feebcce27b1e9 | |
| parent | perf(backend): pre-compile regexp (diff) | |
| download | sharkey-7ce569424a67c786a4f44993ce74f06989eba9df.tar.gz sharkey-7ce569424a67c786a4f44993ce74f06989eba9df.tar.bz2 sharkey-7ce569424a67c786a4f44993ce74f06989eba9df.zip | |
feat: カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
17 files changed, 376 insertions, 115 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea1c2842a..8b6fefc8d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ## 13.x.x (unreleased) ### General +- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように - タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように - 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 264908482c..f49f9c5a3c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1049,6 +1049,9 @@ preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対し options: "オプション" specifyUser: "ユーザー指定" failedToPreviewUrl: "プレビューできません" +update: "更新" +rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。" _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" diff --git a/packages/backend/migration/1684386446061-emoji-improve.js b/packages/backend/migration/1684386446061-emoji-improve.js new file mode 100644 index 0000000000..40b0a2bc5e --- /dev/null +++ b/packages/backend/migration/1684386446061-emoji-improve.js @@ -0,0 +1,15 @@ +export class EmojiImprove1684386446061 { + name = 'EmojiImprove1684386446061' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "localOnly" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "emoji" ADD "isSensitive" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "emoji" ADD "roleIdsThatCanBeUsedThisEmojiAsReaction" character varying(128) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "roleIdsThatCanBeUsedThisEmojiAsReaction"`); + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "isSensitive"`); + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "localOnly"`); + } +} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 93557ce617..3499df38b7 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -7,7 +7,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository } from '@/models/index.js'; +import type { EmojisRepository, Role } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -15,6 +15,8 @@ import type { Config } from '@/config.js'; import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/server/api/stream/types.js'; +const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; + @Injectable() export class CustomEmojiService { private cache: MemoryKVCache<Emoji | null>; @@ -63,6 +65,9 @@ export class CustomEmojiService { aliases: string[]; host: string | null; license: string | null; + isSensitive: boolean; + localOnly: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: Role['id'][]; }): Promise<Emoji> { const emoji = await this.emojisRepository.insert({ id: this.idService.genId(), @@ -75,6 +80,9 @@ export class CustomEmojiService { publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, type: data.driveFile.webpublicType ?? data.driveFile.type, license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { @@ -90,10 +98,14 @@ export class CustomEmojiService { @bindThis public async update(id: Emoji['id'], data: { + driveFile?: DriveFile; name?: string; category?: string | null; aliases?: string[]; license?: string | null; + isSensitive?: boolean; + localOnly?: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction?: Role['id'][]; }): Promise<void> { const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); @@ -105,6 +117,12 @@ export class CustomEmojiService { 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, }); this.localEmojisCache.refresh(); @@ -259,7 +277,7 @@ export class CustomEmojiService { @bindThis public parseEmojiStr(emojiName: string, noteUserHost: string | null) { - const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + const match = emojiName.match(parseEmojiStrRegexp); if (!match) return { name: null, host: null }; const name = match[1]; diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 9b2d5dc0ff..dffee16e08 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -83,7 +83,7 @@ export class MfmService { if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { text += txt; // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { const part = txt.split('@'); if (part.length === 2 && href) { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 2184cfeb41..27334b33e6 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { RoleService } from '@/core/RoleService.js'; const FALLBACK = '❤'; @@ -75,6 +76,7 @@ export class ReactionService { private utilityService: UtilityService, private metaService: MetaService, private customEmojiService: CustomEmojiService, + private roleService: RoleService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, @@ -88,7 +90,7 @@ export class ReactionService { } @bindThis - public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string | null) { + public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, _reaction?: string | null) { // Check blocking if (note.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); @@ -102,10 +104,36 @@ export class ReactionService { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } + let reaction = _reaction ?? FALLBACK; + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { reaction = '❤️'; - } else { - reaction = await this.toDbReaction(reaction, user.host); + } else if (_reaction) { + const custom = reaction.match(isCustomEmojiRegexp); + if (custom) { + const reacterHost = this.utilityService.toPunyNullable(user.host); + + const name = custom[1]; + const emoji = reacterHost == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) + : await this.emojisRepository.findOneBy({ + host: reacterHost, + name, + }); + + if (emoji) { + if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) { + reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; + } else { + // リアクションとして使う権限がない + reaction = FALLBACK; + } + } else { + reaction = FALLBACK; + } + } else { + reaction = this.normalize(reaction ?? null); + } } const record: NoteReaction = { @@ -291,11 +319,9 @@ export class ReactionService { } @bindThis - public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { + public normalize(reaction: string | null): string { if (reaction == null) return FALLBACK; - reacterHost = this.utilityService.toPunyNullable(reacterHost); - // 文字列タイプのリアクションを絵文字に変換 if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; @@ -309,19 +335,6 @@ export class ReactionService { return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); } - const custom = reaction.match(isCustomEmojiRegexp); - if (custom) { - const name = custom[1]; - const emoji = reacterHost == null - ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) - : await this.emojisRepository.findOneBy({ - host: reacterHost, - name, - }); - - if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; - } - return FALLBACK; } diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 3ee7990643..f58a6a10fc 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -16,6 +16,9 @@ type IWebFinger = { subject: string; }; +const urlRegex = /^https?:\/\//; +const mRegex = /^([^@]+)@(.*)/; + @Injectable() export class WebfingerService { constructor( @@ -35,12 +38,12 @@ export class WebfingerService { @bindThis private genUrl(query: string): string { - if (query.match(/^https?:\/\//)) { + if (query.match(urlRegex)) { const u = new URL(query); return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); } - const m = query.match(/^([^@]+)@(.*)/); + const m = query.match(mRegex); if (m) { const hostname = m[2]; const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 3bad048bc0..0c7bd9ed9a 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -26,6 +26,7 @@ export class EmojiEntityService { category: emoji.category, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, + isSensitive: emoji.isSensitive, }; } @@ -51,6 +52,9 @@ export class EmojiEntityService { // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, license: emoji.license, + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, }; } diff --git a/packages/backend/src/models/entities/Emoji.ts b/packages/backend/src/models/entities/Emoji.ts index dbb437d439..8fd3e65f5e 100644 --- a/packages/backend/src/models/entities/Emoji.ts +++ b/packages/backend/src/models/entities/Emoji.ts @@ -60,4 +60,20 @@ export class Emoji { length: 1024, nullable: true, }) public license: string | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; + + // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; } diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index db4fd62cf6..c59b5d1ef4 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -22,6 +22,10 @@ export const packedEmojiSimpleSchema = { type: 'string', optional: false, nullable: false, }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; @@ -63,5 +67,22 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: true, }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, }, } as const; diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index cf78d8330c..600468a286 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -107,6 +107,9 @@ export class ImportCustomEmojisProcessorService { aliases: emojiInfo.aliases, driveFile, license: emojiInfo.license, + isSensitive: emojiInfo.isSensitive, + localOnly: emojiInfo.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 2fb3e489e7..509224e9c3 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -25,9 +25,24 @@ export const meta = { export const paramDef = { type: 'object', properties: { + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, fileId: { type: 'string', format: 'misskey:id' }, + category: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the category.', + }, + aliases: { type: 'array', items: { + type: 'string', + } }, + license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { + type: 'string', + } }, }, - required: ['fileId'], + required: ['name', 'fileId'], } as const; // TODO: ロジックをサービスに切り出す @@ -45,18 +60,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { 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 name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; - const emoji = await this.customEmojiService.add({ driveFile, - name, - category: null, - aliases: [], + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], host: null, - license: null, + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], }); this.moderationLogService.insertModerationLog(me, 'addEmoji', { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index f63348b60b..fb22bdc477 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,6 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import type { DriveFilesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -15,6 +17,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d', + }, sameNameEmojiExists: { message: 'Emoji that have same name already exists.', code: 'SAME_NAME_EMOJI_EXISTS', @@ -28,6 +35,7 @@ export const paramDef = { properties: { id: { type: 'string', format: 'misskey:id' }, name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, + fileId: { type: 'string', format: 'misskey:id' }, category: { type: 'string', nullable: true, @@ -37,6 +45,11 @@ export const paramDef = { type: 'string', } }, license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { + type: 'string', + } }, }, required: ['id', 'name', 'aliases'], } as const; @@ -45,14 +58,28 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { + let driveFile; + + if (ps.fileId) { + driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); + } + await this.customEmojiService.update(ps.id, { + driveFile, name: ps.name, category: ps.category ?? null, aliases: ps.aliases, license: ps.license ?? null, + isSensitive: ps.isSensitive, + localOnly: ps.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, }); }); } diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts index 38db081ac0..aa68f4117d 100644 --- a/packages/backend/test/unit/ReactionService.ts +++ b/packages/backend/test/unit/ReactionService.ts @@ -15,78 +15,74 @@ describe('ReactionService', () => { reactionService = app.get<ReactionService>(ReactionService); }); - describe('toDbReaction', () => { + describe('normalize', () => { test('絵文字リアクションはそのまま', async () => { - assert.strictEqual(await reactionService.toDbReaction('👍'), '👍'); - assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅'); + assert.strictEqual(await reactionService.normalize('👍'), '👍'); + assert.strictEqual(await reactionService.normalize('🍅'), '🍅'); }); test('既存のリアクションは絵文字化する pudding', async () => { - assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮'); + assert.strictEqual(await reactionService.normalize('pudding'), '🍮'); }); test('既存のリアクションは絵文字化する like', async () => { - assert.strictEqual(await reactionService.toDbReaction('like'), '👍'); + assert.strictEqual(await reactionService.normalize('like'), '👍'); }); test('既存のリアクションは絵文字化する love', async () => { - assert.strictEqual(await reactionService.toDbReaction('love'), '❤'); + assert.strictEqual(await reactionService.normalize('love'), '❤'); }); test('既存のリアクションは絵文字化する laugh', async () => { - assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆'); + assert.strictEqual(await reactionService.normalize('laugh'), '😆'); }); test('既存のリアクションは絵文字化する hmm', async () => { - assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔'); + assert.strictEqual(await reactionService.normalize('hmm'), '🤔'); }); test('既存のリアクションは絵文字化する surprise', async () => { - assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮'); + assert.strictEqual(await reactionService.normalize('surprise'), '😮'); }); test('既存のリアクションは絵文字化する congrats', async () => { - assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉'); + assert.strictEqual(await reactionService.normalize('congrats'), '🎉'); }); test('既存のリアクションは絵文字化する angry', async () => { - assert.strictEqual(await reactionService.toDbReaction('angry'), '💢'); + assert.strictEqual(await reactionService.normalize('angry'), '💢'); }); test('既存のリアクションは絵文字化する confused', async () => { - assert.strictEqual(await reactionService.toDbReaction('confused'), '😥'); + assert.strictEqual(await reactionService.normalize('confused'), '😥'); }); test('既存のリアクションは絵文字化する rip', async () => { - assert.strictEqual(await reactionService.toDbReaction('rip'), '😇'); + assert.strictEqual(await reactionService.normalize('rip'), '😇'); }); test('既存のリアクションは絵文字化する star', async () => { - assert.strictEqual(await reactionService.toDbReaction('star'), '⭐'); + assert.strictEqual(await reactionService.normalize('star'), '⭐'); }); test('異体字セレクタ除去', async () => { - assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗'); + assert.strictEqual(await reactionService.normalize('㊗️'), '㊗'); }); test('異体字セレクタ除去 必要なし', async () => { - assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗'); - }); - - test('fallback - undefined', async () => { - assert.strictEqual(await reactionService.toDbReaction(undefined), '❤'); + assert.strictEqual(await reactionService.normalize('㊗'), '㊗'); }); test('fallback - null', async () => { - assert.strictEqual(await reactionService.toDbReaction(null), '❤'); + assert.strictEqual(await reactionService.normalize(null), '❤'); }); test('fallback - empty', async () => { - assert.strictEqual(await reactionService.toDbReaction(''), '❤'); + assert.strictEqual(await reactionService.normalize(''), '❤'); }); test('fallback - unknown', async () => { - assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); + assert.strictEqual(await reactionService.normalize('unknown'), '❤'); }); }); }); diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 2f5866f340..9fbe1ec993 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -12,8 +12,10 @@ </template> </span> <span :class="$style.name">{{ role.name }}</span> - <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> - <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span> + <template v-if="detailed"> + <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> + <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span> + </template> </div> <div :class="$style.description">{{ role.description }}</div> </MkA> @@ -23,10 +25,13 @@ import { } from 'vue'; import { i18n } from '@/i18n'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ role: any; forModeration: boolean; -}>(); + detailed: boolean; +}>(), { + detailed: true, +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 3f13f0787d..3da6a0d9cb 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -2,7 +2,7 @@ <div> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="900"> + <MkSpacer :contentMax="900"> <div class="ogwlenmc"> <div v-if="tab === 'local'" class="local"> <MkInput v-model="query" :debounce="true" type="search"> @@ -123,15 +123,14 @@ const toggleSelect = (emoji) => { }; const add = async (ev: MouseEvent) => { - const files = await selectFiles(ev.currentTarget ?? ev.target, null); - - const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { - fileId: file.id, - }))); - promise.then(() => { - emojisPaginationComponent.value.reload(); - }); - os.promiseDialog(promise); + os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + }, { + done: result => { + if (result.created) { + emojisPaginationComponent.value.prepend(result.created); + } + }, + }, 'closed'); }; const edit = (emoji) => { diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 3c829d6a8e..24b72b6f7f 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -1,84 +1,168 @@ <template> <MkModalWindow ref="dialog" - :width="370" - :with-ok-button="true" - @close="$refs.dialog.close()" + :width="400" + @close="dialog.close()" @closed="$emit('closed')" - @ok="ok()" > - <template #header>:{{ emoji.name }}:</template> + <template v-if="emoji" #header>:{{ emoji.name }}:</template> + <template v-else #header>New emoji</template> - <MkSpacer :margin-min="20" :margin-max="28"> - <div class="_gaps_m"> - <img :src="`/emoji/${emoji.name}.webp`" :class="$style.img"/> - <MkInput v-model="name"> - <template #label>{{ i18n.ts.name }}</template> - </MkInput> - <MkInput v-model="category" :datalist="customEmojiCategories"> - <template #label>{{ i18n.ts.category }}</template> - </MkInput> - <MkInput v-model="aliases"> - <template #label>{{ i18n.ts.tags }}</template> - <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> - </MkInput> - <MkInput v-model="license"> - <template #label>{{ i18n.ts.license }}</template> - </MkInput> - <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <div> + <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_gaps_m"> + <div v-if="imgUrl != null" :class="$style.imgs"> + <div style="background: #000;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + <div style="background: #222;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + <div style="background: #ddd;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + <div style="background: #fff;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img"/> + </div> + </div> + <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> + <MkInput v-model="name"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkInput v-model="category" :datalist="customEmojiCategories"> + <template #label>{{ i18n.ts.category }}</template> + </MkInput> + <MkInput v-model="aliases"> + <template #label>{{ i18n.ts.tags }}</template> + <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkInput v-model="license"> + <template #label>{{ i18n.ts.license }}</template> + </MkInput> + <MkFolder> + <template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template> + <div class="_gaps"> + <MkInfo>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription }}</MkInfo> + + <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <div v-for="role in rolesThatCanBeUsedThisEmojiAsReaction" :key="role.id" :class="$style.roleItem"> + <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false"/> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> + </div> + </div> + </MkFolder> + <MkSwitch v-model="isSensitive">isSensitive</MkSwitch> + <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> + <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </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> </div> - </MkSpacer> + </div> </MkModalWindow> </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed, watch } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { customEmojiCategories } from '@/custom-emojis'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { selectFile, selectFiles } from '@/scripts/select-file'; +import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ - emoji: any, + emoji?: any, }>(); let dialog = $ref(null); -let name: string = $ref(props.emoji.name); -let category: string = $ref(props.emoji.category); -let aliases: string = $ref(props.emoji.aliases.join(' ')); -let license: string = $ref(props.emoji.license ?? ''); +let name: string = $ref(props.emoji ? props.emoji.name : ''); +let category: string = $ref(props.emoji ? props.emoji.category : ''); +let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : ''); +let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : ''); +let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false); +let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); +let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); +let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); +let file = $ref(); + +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); const emit = defineEmits<{ - (ev: 'done', v: { deleted?: boolean, updated?: any }): void, + (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, (ev: 'closed'): void }>(); -function ok() { - update(); +async function changeImage(ev) { + file = await selectFile(ev.currentTarget ?? ev.target, null); } -async function update() { - await os.apiWithDialog('admin/emoji/update', { - id: props.emoji.id, +async function addRole() { + const roles = await os.api('admin/roles/list'); + const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id); + + const { canceled, result: role } = await os.select({ + items: roles.filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), + }); + if (canceled) return; + + rolesThatCanBeUsedThisEmojiAsReaction.push(role); +} + +async function removeRole(role, ev) { + rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); +} + +async function done() { + const params = { name, category, aliases: aliases.split(' '), license: license === '' ? null : license, - }); + isSensitive, + localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id), + }; + + if (file) { + params.fileId = file.id; + } - emit('done', { - updated: { + if (props.emoji) { + await os.apiWithDialog('admin/emoji/update', { id: props.emoji.id, - name, - category, - aliases: aliases.split(' '), - license: license === '' ? null : license, - }, - }); + ...params, + }); + + emit('done', { + updated: { + id: props.emoji.id, + ...params, + }, + }); + + dialog.close(); + } else { + const created = await os.apiWithDialog('admin/emoji/add', params); + + emit('done', { + created: created, + }); - dialog.close(); + dialog.close(); + } } async function del() { @@ -100,9 +184,47 @@ async function del() { </script> <style lang="scss" module> +.imgs { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.imgContainer { + padding: 8px; + border-radius: 6px; +} + .img { display: block; height: 64px; - margin: 0 auto; + width: 64px; + object-fit: contain; +} + +.roleItem { + display: flex; +} + +.role { + flex: 1; +} + +.roleUnassign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} + +.footer { + position: sticky; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--divider); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); } </style> |