feat: カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように

This commit is contained in:
syuilo 2023-05-18 18:45:49 +09:00
parent 9b5b3a4d1b
commit 7ce569424a
17 changed files with 376 additions and 115 deletions

View file

@ -15,6 +15,7 @@
## 13.x.x (unreleased) ## 13.x.x (unreleased)
### General ### General
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように - タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります - 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります

View file

@ -1049,6 +1049,9 @@ preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対し
options: "オプション" options: "オプション"
specifyUser: "ユーザー指定" specifyUser: "ユーザー指定"
failedToPreviewUrl: "プレビューできません" failedToPreviewUrl: "プレビューできません"
update: "更新"
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
_initialAccountSetting: _initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!" accountCreated: "アカウントの作成が完了しました!"

View file

@ -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"`);
}
}

View file

@ -7,7 +7,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Emoji } from '@/models/entities/Emoji.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 { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.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 { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js'; import type { Serialized } from '@/server/api/stream/types.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
export class CustomEmojiService { export class CustomEmojiService {
private cache: MemoryKVCache<Emoji | null>; private cache: MemoryKVCache<Emoji | null>;
@ -63,6 +65,9 @@ export class CustomEmojiService {
aliases: string[]; aliases: string[];
host: string | null; host: string | null;
license: string | null; license: string | null;
isSensitive: boolean;
localOnly: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: Role['id'][];
}): Promise<Emoji> { }): Promise<Emoji> {
const emoji = await this.emojisRepository.insert({ const emoji = await this.emojisRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
@ -75,6 +80,9 @@ export class CustomEmojiService {
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
type: data.driveFile.webpublicType ?? data.driveFile.type, type: data.driveFile.webpublicType ?? data.driveFile.type,
license: data.license, license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) { if (data.host == null) {
@ -90,10 +98,14 @@ export class CustomEmojiService {
@bindThis @bindThis
public async update(id: Emoji['id'], data: { public async update(id: Emoji['id'], data: {
driveFile?: DriveFile;
name?: string; name?: string;
category?: string | null; category?: string | null;
aliases?: string[]; aliases?: string[];
license?: string | null; license?: string | null;
isSensitive?: boolean;
localOnly?: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: Role['id'][];
}): Promise<void> { }): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
@ -105,6 +117,12 @@ export class CustomEmojiService {
category: data.category, category: data.category,
aliases: data.aliases, aliases: data.aliases,
license: data.license, 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(); this.localEmojisCache.refresh();
@ -259,7 +277,7 @@ export class CustomEmojiService {
@bindThis @bindThis
public parseEmojiStr(emojiName: string, noteUserHost: string | null) { 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 }; if (!match) return { name: null, host: null };
const name = match[1]; const name = match[1];

View file

@ -83,7 +83,7 @@ export class MfmService {
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt; text += txt;
// メンション // メンション
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
const part = txt.split('@'); const part = txt.split('@');
if (part.length === 2 && href) { if (part.length === 2 && href) {

View file

@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { RoleService } from '@/core/RoleService.js';
const FALLBACK = '❤'; const FALLBACK = '❤';
@ -75,6 +76,7 @@ export class ReactionService {
private utilityService: UtilityService, private utilityService: UtilityService,
private metaService: MetaService, private metaService: MetaService,
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
private roleService: RoleService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
@ -88,7 +90,7 @@ export class ReactionService {
} }
@bindThis @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 // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(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.'); 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))) { if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) {
reaction = '❤️'; reaction = '❤️';
} else { } else if (_reaction) {
reaction = await this.toDbReaction(reaction, user.host); 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 = { const record: NoteReaction = {
@ -291,11 +319,9 @@ export class ReactionService {
} }
@bindThis @bindThis
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { public normalize(reaction: string | null): string {
if (reaction == null) return FALLBACK; if (reaction == null) return FALLBACK;
reacterHost = this.utilityService.toPunyNullable(reacterHost);
// 文字列タイプのリアクションを絵文字に変換 // 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; 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, ''); 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; return FALLBACK;
} }

View file

@ -16,6 +16,9 @@ type IWebFinger = {
subject: string; subject: string;
}; };
const urlRegex = /^https?:\/\//;
const mRegex = /^([^@]+)@(.*)/;
@Injectable() @Injectable()
export class WebfingerService { export class WebfingerService {
constructor( constructor(
@ -35,12 +38,12 @@ export class WebfingerService {
@bindThis @bindThis
private genUrl(query: string): string { private genUrl(query: string): string {
if (query.match(/^https?:\/\//)) { if (query.match(urlRegex)) {
const u = new URL(query); const u = new URL(query);
return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
} }
const m = query.match(/^([^@]+)@(.*)/); const m = query.match(mRegex);
if (m) { if (m) {
const hostname = m[2]; const hostname = m[2];
const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';

View file

@ -26,6 +26,7 @@ export class EmojiEntityService {
category: emoji.category, category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
isSensitive: emoji.isSensitive,
}; };
} }
@ -51,6 +52,9 @@ export class EmojiEntityService {
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
license: emoji.license, license: emoji.license,
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
}; };
} }

View file

@ -60,4 +60,20 @@ export class Emoji {
length: 1024, nullable: true, length: 1024, nullable: true,
}) })
public license: string | null; 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[];
} }

View file

@ -22,6 +22,10 @@ export const packedEmojiSimpleSchema = {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isSensitive: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;
@ -63,5 +67,22 @@ export const packedEmojiDetailedSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, 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; } as const;

View file

@ -107,6 +107,9 @@ export class ImportCustomEmojisProcessorService {
aliases: emojiInfo.aliases, aliases: emojiInfo.aliases,
driveFile, driveFile,
license: emojiInfo.license, license: emojiInfo.license,
isSensitive: emojiInfo.isSensitive,
localOnly: emojiInfo.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
}); });
} }

View file

@ -25,9 +25,24 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' }, 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; } as const;
// TODO: ロジックをサービスに切り出す // TODO: ロジックをサービスに切り出す
@ -45,18 +60,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) { ) {
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 name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;
const emoji = await this.customEmojiService.add({ const emoji = await this.customEmojiService.add({
driveFile, driveFile,
name, name: ps.name,
category: null, category: ps.category ?? null,
aliases: [], aliases: ps.aliases ?? [],
host: null, host: null,
license: null, license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
}); });
this.moderationLogService.insertModerationLog(me, 'addEmoji', { this.moderationLogService.insertModerationLog(me, 'addEmoji', {

View file

@ -1,6 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.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'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -15,6 +17,11 @@ export const meta = {
code: 'NO_SUCH_EMOJI', code: 'NO_SUCH_EMOJI',
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
}, },
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d',
},
sameNameEmojiExists: { sameNameEmojiExists: {
message: 'Emoji that have same name already exists.', message: 'Emoji that have same name already exists.',
code: 'SAME_NAME_EMOJI_EXISTS', code: 'SAME_NAME_EMOJI_EXISTS',
@ -28,6 +35,7 @@ export const paramDef = {
properties: { properties: {
id: { type: 'string', format: 'misskey:id' }, id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' },
category: { category: {
type: 'string', type: 'string',
nullable: true, nullable: true,
@ -37,6 +45,11 @@ export const paramDef = {
type: 'string', type: 'string',
} }, } },
license: { type: 'string', nullable: true }, license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
}, },
required: ['id', 'name', 'aliases'], required: ['id', 'name', 'aliases'],
} as const; } as const;
@ -45,14 +58,28 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { 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, { await this.customEmojiService.update(ps.id, {
driveFile,
name: ps.name, name: ps.name,
category: ps.category ?? null, category: ps.category ?? null,
aliases: ps.aliases, aliases: ps.aliases,
license: ps.license ?? null, license: ps.license ?? null,
isSensitive: ps.isSensitive,
localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
}); });
}); });
} }

View file

@ -15,78 +15,74 @@ describe('ReactionService', () => {
reactionService = app.get<ReactionService>(ReactionService); reactionService = app.get<ReactionService>(ReactionService);
}); });
describe('toDbReaction', () => { describe('normalize', () => {
test('絵文字リアクションはそのまま', async () => { test('絵文字リアクションはそのまま', async () => {
assert.strictEqual(await reactionService.toDbReaction('👍'), '👍'); assert.strictEqual(await reactionService.normalize('👍'), '👍');
assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅'); assert.strictEqual(await reactionService.normalize('🍅'), '🍅');
}); });
test('既存のリアクションは絵文字化する pudding', async () => { test('既存のリアクションは絵文字化する pudding', async () => {
assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮'); assert.strictEqual(await reactionService.normalize('pudding'), '🍮');
}); });
test('既存のリアクションは絵文字化する like', async () => { test('既存のリアクションは絵文字化する like', async () => {
assert.strictEqual(await reactionService.toDbReaction('like'), '👍'); assert.strictEqual(await reactionService.normalize('like'), '👍');
}); });
test('既存のリアクションは絵文字化する love', async () => { test('既存のリアクションは絵文字化する love', async () => {
assert.strictEqual(await reactionService.toDbReaction('love'), '❤'); assert.strictEqual(await reactionService.normalize('love'), '❤');
}); });
test('既存のリアクションは絵文字化する laugh', async () => { test('既存のリアクションは絵文字化する laugh', async () => {
assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆'); assert.strictEqual(await reactionService.normalize('laugh'), '😆');
}); });
test('既存のリアクションは絵文字化する hmm', async () => { test('既存のリアクションは絵文字化する hmm', async () => {
assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔'); assert.strictEqual(await reactionService.normalize('hmm'), '🤔');
}); });
test('既存のリアクションは絵文字化する surprise', async () => { test('既存のリアクションは絵文字化する surprise', async () => {
assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮'); assert.strictEqual(await reactionService.normalize('surprise'), '😮');
}); });
test('既存のリアクションは絵文字化する congrats', async () => { test('既存のリアクションは絵文字化する congrats', async () => {
assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉'); assert.strictEqual(await reactionService.normalize('congrats'), '🎉');
}); });
test('既存のリアクションは絵文字化する angry', async () => { test('既存のリアクションは絵文字化する angry', async () => {
assert.strictEqual(await reactionService.toDbReaction('angry'), '💢'); assert.strictEqual(await reactionService.normalize('angry'), '💢');
}); });
test('既存のリアクションは絵文字化する confused', async () => { test('既存のリアクションは絵文字化する confused', async () => {
assert.strictEqual(await reactionService.toDbReaction('confused'), '😥'); assert.strictEqual(await reactionService.normalize('confused'), '😥');
}); });
test('既存のリアクションは絵文字化する rip', async () => { test('既存のリアクションは絵文字化する rip', async () => {
assert.strictEqual(await reactionService.toDbReaction('rip'), '😇'); assert.strictEqual(await reactionService.normalize('rip'), '😇');
}); });
test('既存のリアクションは絵文字化する star', async () => { test('既存のリアクションは絵文字化する star', async () => {
assert.strictEqual(await reactionService.toDbReaction('star'), '⭐'); assert.strictEqual(await reactionService.normalize('star'), '⭐');
}); });
test('異体字セレクタ除去', async () => { test('異体字セレクタ除去', async () => {
assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗'); assert.strictEqual(await reactionService.normalize('㊗️'), '㊗');
}); });
test('異体字セレクタ除去 必要なし', async () => { test('異体字セレクタ除去 必要なし', async () => {
assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗'); assert.strictEqual(await reactionService.normalize('㊗'), '㊗');
});
test('fallback - undefined', async () => {
assert.strictEqual(await reactionService.toDbReaction(undefined), '❤');
}); });
test('fallback - null', async () => { test('fallback - null', async () => {
assert.strictEqual(await reactionService.toDbReaction(null), '❤'); assert.strictEqual(await reactionService.normalize(null), '❤');
}); });
test('fallback - empty', async () => { test('fallback - empty', async () => {
assert.strictEqual(await reactionService.toDbReaction(''), '❤'); assert.strictEqual(await reactionService.normalize(''), '❤');
}); });
test('fallback - unknown', async () => { test('fallback - unknown', async () => {
assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); assert.strictEqual(await reactionService.normalize('unknown'), '❤');
}); });
}); });
}); });

View file

@ -12,8 +12,10 @@
</template> </template>
</span> </span>
<span :class="$style.name">{{ role.name }}</span> <span :class="$style.name">{{ role.name }}</span>
<span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> <template v-if="detailed">
<span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</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>
</div> </div>
<div :class="$style.description">{{ role.description }}</div> <div :class="$style.description">{{ role.description }}</div>
</MkA> </MkA>
@ -23,10 +25,13 @@
import { } from 'vue'; import { } from 'vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = withDefaults(defineProps<{
role: any; role: any;
forModeration: boolean; forModeration: boolean;
}>(); detailed: boolean;
}>(), {
detailed: true,
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -2,7 +2,7 @@
<div> <div>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="900"> <MkSpacer :contentMax="900">
<div class="ogwlenmc"> <div class="ogwlenmc">
<div v-if="tab === 'local'" class="local"> <div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search"> <MkInput v-model="query" :debounce="true" type="search">
@ -123,15 +123,14 @@ const toggleSelect = (emoji) => {
}; };
const add = async (ev: MouseEvent) => { const add = async (ev: MouseEvent) => {
const files = await selectFiles(ev.currentTarget ?? ev.target, null); os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
}, {
const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { done: result => {
fileId: file.id, if (result.created) {
}))); emojisPaginationComponent.value.prepend(result.created);
promise.then(() => { }
emojisPaginationComponent.value.reload(); },
}); }, 'closed');
os.promiseDialog(promise);
}; };
const edit = (emoji) => { const edit = (emoji) => {

View file

@ -1,84 +1,168 @@
<template> <template>
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="370" :width="400"
:with-ok-button="true" @close="dialog.close()"
@close="$refs.dialog.close()"
@closed="$emit('closed')" @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>
<div class="_gaps_m"> <MkSpacer :marginMin="20" :marginMax="28">
<img :src="`/emoji/${emoji.name}.webp`" :class="$style.img"/> <div class="_gaps_m">
<MkInput v-model="name"> <div v-if="imgUrl != null" :class="$style.imgs">
<template #label>{{ i18n.ts.name }}</template> <div style="background: #000;" :class="$style.imgContainer">
</MkInput> <img :src="imgUrl" :class="$style.img"/>
<MkInput v-model="category" :datalist="customEmojiCategories"> </div>
<template #label>{{ i18n.ts.category }}</template> <div style="background: #222;" :class="$style.imgContainer">
</MkInput> <img :src="imgUrl" :class="$style.img"/>
<MkInput v-model="aliases"> </div>
<template #label>{{ i18n.ts.tags }}</template> <div style="background: #ddd;" :class="$style.imgContainer">
<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> <img :src="imgUrl" :class="$style.img"/>
</MkInput> </div>
<MkInput v-model="license"> <div style="background: #fff;" :class="$style.imgContainer">
<template #label>{{ i18n.ts.license }}</template> <img :src="imgUrl" :class="$style.img"/>
</MkInput> </div>
<MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </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> </div>
</MkSpacer> </div>
</MkModalWindow> </MkModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { computed, watch } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.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 * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { customEmojiCategories } from '@/custom-emojis'; 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<{ const props = defineProps<{
emoji: any, emoji?: any,
}>(); }>();
let dialog = $ref(null); let dialog = $ref(null);
let name: string = $ref(props.emoji.name); let name: string = $ref(props.emoji ? props.emoji.name : '');
let category: string = $ref(props.emoji.category); let category: string = $ref(props.emoji ? props.emoji.category : '');
let aliases: string = $ref(props.emoji.aliases.join(' ')); let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : '');
let license: string = $ref(props.emoji.license ?? ''); 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<{ const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean, updated?: any }): void, (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void (ev: 'closed'): void
}>(); }>();
function ok() { async function changeImage(ev) {
update(); file = await selectFile(ev.currentTarget ?? ev.target, null);
} }
async function update() { async function addRole() {
await os.apiWithDialog('admin/emoji/update', { const roles = await os.api('admin/roles/list');
id: props.emoji.id, 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, name,
category, category,
aliases: aliases.split(' '), aliases: aliases.split(' '),
license: license === '' ? null : license, license: license === '' ? null : license,
}); isSensitive,
localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
};
emit('done', { if (file) {
updated: { params.fileId = file.id;
}
if (props.emoji) {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id, id: props.emoji.id,
name, ...params,
category, });
aliases: aliases.split(' '),
license: license === '' ? null : license,
},
});
dialog.close(); 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();
}
} }
async function del() { async function del() {
@ -100,9 +184,47 @@ async function del() {
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.imgs {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.imgContainer {
padding: 8px;
border-radius: 6px;
}
.img { .img {
display: block; display: block;
height: 64px; 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> </style>