From 8286c9a9768a04c58f804155522b5eecff993bd1 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Thu, 13 Aug 2026 15:48:18 +0200 Subject: [PATCH 1/2] feat(emoji): allow remote emoji suggestions --- locales/index.d.ts | 12 ++ packages/backend/src/core/DriveService.ts | 35 ++-- .../src/core/EmojiSuggestionService.ts | 87 ++++++++-- .../src/server/api/emoji-suggestion.ts | 12 +- .../backend/src/server/api/endpoint-list.ts | 1 + .../api/endpoints/emoji-suggestions/create.ts | 5 +- .../server/api/endpoints/emoji/list-remote.ts | 87 ++++++++++ .../test/unit/EmojiSuggestionService.ts | 149 ++++++++++++++++++ .../src/components/MkRemoteEmojiBrowser.vue | 112 +++++++++++++ .../src/pages/custom-emojis-manager.vue | 90 +---------- .../frontend/src/pages/emoji-edit-dialog.vue | 27 ++-- .../frontend/src/pages/emoji-suggestions.vue | 18 ++- .../misskey-js/generator/src/generator.ts | 2 +- packages/misskey-js/src/api.types.ts | 13 +- .../misskey-js/src/autogen/apiClientJSDoc.ts | 13 +- packages/misskey-js/src/autogen/endpoint.ts | 3 + packages/misskey-js/src/autogen/entities.ts | 2 + packages/misskey-js/src/autogen/types.ts | 115 +++++++++++++- packages/misskey-js/src/entities.ts | 14 ++ packages/misskey-js/test-d/api.ts | 21 ++- sharkey-locales/en-US.yml | 3 + 21 files changed, 684 insertions(+), 137 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/emoji/list-remote.ts create mode 100644 packages/frontend/src/components/MkRemoteEmojiBrowser.vue diff --git a/locales/index.d.ts b/locales/index.d.ts index bba217f7b4..dec92bd609 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12096,6 +12096,18 @@ export interface Locale extends ILocale { * Propose a custom emoji for this server. Your image remains in your Drive while moderators review it. */ "emojiSuggestionDescription": string; + /** + * Browse remote emojis + */ + "browseRemoteEmojis": string; + /** + * Search emojis cached from other servers. + */ + "browseRemoteEmojisDescription": string; + /** + * Select an emoji to propose it for this server. A copy will be added to your Drive for moderators to review. + */ + "remoteEmojiSuggestionDescription": string; /** * There are no pending emoji suggestions. */ diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 4fb396225f..c4bb6ddfbe 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -92,6 +92,11 @@ type UploadFromUrlArgs = { isForImport?: boolean; }; +type AddFileResult = { + file: MiDriveFile; + isNew: boolean; +}; + @Injectable() export class DriveService { public static NoSuchFolderError = class extends Error {}; @@ -492,7 +497,11 @@ export class DriveService { * */ @bindThis - public async addFile({ + public async addFile(args: AddFileArgs): Promise { + return (await this.addFileWithResult(args)).file; + } + + private async addFileWithResult({ user, path, name = null, @@ -506,7 +515,7 @@ export class DriveService { requestIp = null, requestHeaders = null, ext = null, - }: AddFileArgs): Promise { + }: AddFileArgs): Promise { const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw; const info = await this.fileInfoService.getFileInfo(path); @@ -535,7 +544,7 @@ export class DriveService { await this.driveFilesRepository.update({ id: matched.id }, { isSensitive: true }); matched.isSensitive = true; } - return matched; + return { file: matched, isNew: false }; } } @@ -645,6 +654,8 @@ export class DriveService { file.uri = uri; } + let isNew = true; + if (isLink) { try { file.size = 0; @@ -657,6 +668,7 @@ export class DriveService { } catch (err) { // duplicate key error (when already registered) if (isDuplicateKeyValueError(err)) { + isNew = false; this.registerLogger.debug(`already registered ${file.uri}`); file = await this.driveFilesRepository.findOneBy({ @@ -692,7 +704,7 @@ export class DriveService { } } - return file; + return { file, isNew }; } @bindThis @@ -875,7 +887,12 @@ export class DriveService { } @bindThis - public async uploadFromUrl({ + public async uploadFromUrl(args: UploadFromUrlArgs): Promise { + return (await this.uploadFromUrlWithResult(args)).file; + } + + @bindThis + public async uploadFromUrlWithResult({ url, user, folderId = null, @@ -887,7 +904,7 @@ export class DriveService { requestIp = null, requestHeaders = null, isForImport = false, - }: UploadFromUrlArgs): Promise { + }: UploadFromUrlArgs): Promise { // Create temp file const [path, cleanup] = await createTemp(); @@ -904,9 +921,9 @@ export class DriveService { comment = null; } - const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); - this.downloaderLogger.debug(`Upload succeeded: created file ${driveFile.id}`); - return driveFile!; + const result = await this.addFileWithResult({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); + this.downloaderLogger.debug(`Upload succeeded: created file ${result.file.id}`); + return result; } catch (err) { this.downloaderLogger.error(`Failed to create drive file from ${url}: ${renderInlineError(err)}`); throw err; diff --git a/packages/backend/src/core/EmojiSuggestionService.ts b/packages/backend/src/core/EmojiSuggestionService.ts index f484c6e44f..2beb5575b4 100644 --- a/packages/backend/src/core/EmojiSuggestionService.ts +++ b/packages/backend/src/core/EmojiSuggestionService.ts @@ -34,6 +34,7 @@ export type EmojiSuggestionError = | 'duplicateName' | 'duplicateSuggestion' | 'noSuchFile' + | 'noSuchRemoteEmoji' | 'noSuchSuggestion' | 'tooManyPendingSuggestions' | 'unsupportedFileType'; @@ -42,9 +43,8 @@ export type EmojiSuggestionResult = | { ok: true, value: T } | { ok: false, reason: EmojiSuggestionError }; -export type CreateEmojiSuggestionOptions = { +type CreateEmojiSuggestionBaseOptions = { name: string; - fileId: string; category: string | null; aliases: string[]; license: string | null; @@ -52,6 +52,11 @@ export type CreateEmojiSuggestionOptions = { isSensitive: boolean; }; +export type CreateEmojiSuggestionOptions = CreateEmojiSuggestionBaseOptions & ( + | { fileId: string; remoteEmojiId?: never } + | { fileId?: never; remoteEmojiId: string } +); + @Injectable() export class EmojiSuggestionService { private readonly logger: Logger; @@ -83,26 +88,78 @@ export class EmojiSuggestionService { user: MiUser, ): Promise> { const name = options.name.normalize('NFC'); - const file = await this.driveFilesRepository.findOneBy({ - id: options.fileId, - userId: user.id, - }); - if (file == null) return { ok: false, reason: 'noSuchFile' }; - if (!FILE_TYPE_IMAGE.includes(file.type)) return { ok: false, reason: 'unsupportedFileType' }; - if (await this.customEmojiService.checkDuplicate(name)) return { ok: false, reason: 'duplicateName' }; + let file: MiDriveFile | null = null; + let remoteSource: { url: string; isSensitive: boolean } | null = null; + let remoteFileIsNew = false; + + const cleanupRemoteFile = async () => { + if (!remoteFileIsNew || file == null) return; + + await this.driveService.deleteFileSync(file); + remoteFileIsNew = false; + }; - const [pendingCount, duplicateSuggestion] = await Promise.all([ + if (options.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: options.fileId, + userId: user.id, + }); + if (file == null) return { ok: false, reason: 'noSuchFile' }; + } else { + const emoji = await this.customEmojiService.emojisByIdCache.fetchMaybe(options.remoteEmojiId); + if (emoji == null || emoji.host == null) return { ok: false, reason: 'noSuchRemoteEmoji' }; + remoteSource = { + url: emoji.originalUrl, + isSensitive: emoji.isSensitive, + }; + } + + const [isDuplicateName, pendingCount, duplicateSuggestion] = await Promise.all([ + this.customEmojiService.checkDuplicate(name), this.emojiSuggestionsRepository.countBy({ userId: user.id }), this.emojiSuggestionsRepository.exists({ - where: [ - { userId: user.id, name }, + where: file == null ? { name } : [ + { name }, { fileId: file.id }, ], }), ]); + if (isDuplicateName) return { ok: false, reason: 'duplicateName' }; if (pendingCount >= MAX_PENDING_EMOJI_SUGGESTIONS) return { ok: false, reason: 'tooManyPendingSuggestions' }; if (duplicateSuggestion) return { ok: false, reason: 'duplicateSuggestion' }; + if (remoteSource != null) { + const upload = await this.driveService.uploadFromUrlWithResult({ + url: remoteSource.url, + user, + sensitive: remoteSource.isSensitive, + }); + file = upload.file; + remoteFileIsNew = upload.isNew; + } + + if (file == null) return { ok: false, reason: 'noSuchFile' }; + let duplicateFile: boolean; + try { + duplicateFile = await this.emojiSuggestionsRepository.exists({ where: { fileId: file.id } }); + } catch (error) { + try { + await cleanupRemoteFile(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError]); + } + throw error; + } + + if (duplicateFile) { + await cleanupRemoteFile(); + return { ok: false, reason: 'duplicateSuggestion' }; + } + if (!FILE_TYPE_IMAGE.includes(file.type)) { + await cleanupRemoteFile(); + return { ok: false, reason: 'unsupportedFileType' }; + } + let suggestion: MiEmojiSuggestion; try { suggestion = await this.emojiSuggestionsRepository.insertOne({ @@ -122,6 +179,12 @@ export class EmojiSuggestionService { }, }); } catch (error) { + try { + await cleanupRemoteFile(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError]); + } + // The preflight check gives a useful early response, while the unique // constraints close the race between simultaneous submissions. if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateSuggestion' }; diff --git a/packages/backend/src/server/api/emoji-suggestion.ts b/packages/backend/src/server/api/emoji-suggestion.ts index 6c0a4cb4eb..cc7f625621 100644 --- a/packages/backend/src/server/api/emoji-suggestion.ts +++ b/packages/backend/src/server/api/emoji-suggestion.ts @@ -16,6 +16,11 @@ export const emojiSuggestionErrors = { code: 'UNSUPPORTED_FILE_TYPE', id: '63a9ff92-f992-4bc9-9d43-9fe1ea0ea3ec', }, + noSuchRemoteEmoji: { + message: 'No such remote emoji.', + code: 'NO_SUCH_REMOTE_EMOJI', + id: '413b2a5e-c6f5-47cf-b7ad-bb18d5eec9e9', + }, duplicateName: { message: 'An emoji with this name already exists.', code: 'DUPLICATE_NAME', @@ -47,6 +52,7 @@ export const emojiSuggestionParamDef = { properties: { name: { type: 'string', maxLength: 128, pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' }, fileId: { type: 'string', format: 'misskey:id' }, + remoteEmojiId: { type: 'string', format: 'misskey:id' }, category: { type: 'string', nullable: true, maxLength: 128 }, aliases: { type: 'array', @@ -57,7 +63,11 @@ export const emojiSuggestionParamDef = { isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, }, - required: ['name', 'fileId'], + required: ['name'], + oneOf: [ + { required: ['fileId'] }, + { required: ['remoteEmojiId'] }, + ], } as const; export const emojiSuggestionListParamDef = { diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 9d6502502d..2ae2392079 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -463,6 +463,7 @@ export * as 'chat/history' from './endpoints/chat/history.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; export * as 'admin/antennas/global' from './endpoints/admin/antennas/global.js'; export * as 'drive/files/generate-alt-text' from './endpoints/drive/files/generate-alt.js'; +export * as 'emoji/list-remote' from './endpoints/emoji/list-remote.js'; export * as 'emoji-suggestions/cancel' from './endpoints/emoji-suggestions/cancel.js'; export * as 'emoji-suggestions/create' from './endpoints/emoji-suggestions/create.js'; export * as 'emoji-suggestions/list' from './endpoints/emoji-suggestions/list.js'; diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts index 02b69ee9d9..870a995a07 100644 --- a/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts @@ -34,9 +34,12 @@ export default class extends Endpoint { // eslint- private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, ) { super(meta, paramDef, async (ps, me) => { + const source = ps.fileId != null + ? { fileId: ps.fileId } + : { remoteEmojiId: ps.remoteEmojiId! }; const result = await this.emojiSuggestionService.create({ name: ps.name, - fileId: ps.fileId, + ...source, category: ps.category ?? null, aliases: ps.aliases ?? [], license: ps.license ?? null, diff --git a/packages/backend/src/server/api/endpoints/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/emoji/list-remote.ts new file mode 100644 index 0000000000..fdaeb3a383 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji/list-remote.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { EmojisRepository } from '@/models/_.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'read:account', + limit: { + duration: 1000 * 5, + max: 10, + }, + res: { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'object', + optional: false, + nullable: false, + ref: 'EmojiDetailed', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string', nullable: true, default: null }, + host: { type: 'string', nullable: true, default: null }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojisRepository) + private readonly emojisRepository: EmojisRepository, + + private readonly queryService: QueryService, + private readonly emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps) => { + const query = this.queryService.makePaginationQuery( + this.emojisRepository.createQueryBuilder('emoji'), + ps.sinceId, + ps.untilId, + ).andWhere('emoji.host IS NOT NULL'); + + if (ps.query) { + const names = ps.query + .normalize('NFC') + .split(/\s/) + .filter(value => value.length > 0) + .map(value => `%${sqlLikeEscape(value)}%`); + query.andWhere('emoji.name ~~ ANY(ARRAY[:...names])', { names }); + } + + if (ps.host) { + const hosts = ps.host + .split(/\s/) + .filter(value => value.length > 0) + .map(value => `%${sqlLikeEscape(value)}%`); + query.andWhere('emoji.host ~~ ANY(ARRAY[:...hosts])', { hosts }); + } + + const emojis = await query + .take(ps.limit) + .getMany(); + + return await this.emojiEntityService.packDetailedMany(emojis); + }); + } +} diff --git a/packages/backend/test/unit/EmojiSuggestionService.ts b/packages/backend/test/unit/EmojiSuggestionService.ts index 03493739be..9c9b7163af 100644 --- a/packages/backend/test/unit/EmojiSuggestionService.ts +++ b/packages/backend/test/unit/EmojiSuggestionService.ts @@ -30,6 +30,13 @@ describe('EmojiSuggestionService', () => { url: 'https://example.test/emoji-original.png', webpublicUrl: 'https://example.test/emoji-public.webp', }; + const remoteEmoji = { + id: '9abc000008', + name: 'party_blob', + host: 'remote.example', + originalUrl: 'https://remote.example/emoji.png', + isSensitive: true, + }; const suggestion = { id: '9abc000004', userId: user.id, @@ -74,11 +81,16 @@ describe('EmojiSuggestionService', () => { const customEmoji = { checkDuplicate: jest.fn(async () => false), createEmoji: jest.fn(async () => emoji), + emojisByIdCache: { + fetchMaybe: jest.fn(async () => remoteEmoji), + }, ...overrides?.customEmoji, }; const driveService = { uploadFromUrl: jest.fn(async () => emojiFile), + uploadFromUrlWithResult: jest.fn(async () => ({ file: emojiFile, isNew: true })), deleteFile: jest.fn(async () => undefined), + deleteFileSync: jest.fn(async () => undefined), ...overrides?.driveService, }; const notificationService = { @@ -133,6 +145,143 @@ describe('EmojiSuggestionService', () => { expect(suggestions.insertOne).not.toHaveBeenCalled(); }); + test('submission can copy a cached remote emoji into the proposer Drive', async () => { + const remoteFile = { + ...file, + id: '9abc000009', + url: 'https://example.test/copied-remote.png', + isSensitive: true, + }; + const { service, driveService, suggestions } = createService({ + driveService: { uploadFromUrlWithResult: jest.fn(async () => ({ file: remoteFile, isNew: true })) }, + }); + + await expect(service.create({ + name: suggestion.name, + remoteEmojiId: remoteEmoji.id, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: false, + isSensitive: true, + }, proposer)).resolves.toEqual({ ok: true, value: { ...suggestion, user } }); + + expect(driveService.uploadFromUrlWithResult).toHaveBeenCalledWith({ + url: remoteEmoji.originalUrl, + user: proposer, + sensitive: true, + }); + expect(suggestions.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ userId: user.id, fileId: remoteFile.id }), + expect.anything(), + ); + }); + + test('a rejected remote image removes its newly created Drive copy', async () => { + const unsupportedFile = { ...emojiFile, type: 'application/octet-stream' }; + const { service, driveService, suggestions } = createService({ + driveService: { + uploadFromUrlWithResult: jest.fn(async () => ({ file: unsupportedFile, isNew: true })), + }, + }); + + await expect(service.create({ + name: suggestion.name, + remoteEmojiId: remoteEmoji.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, proposer)).resolves.toEqual({ ok: false, reason: 'unsupportedFileType' }); + + expect(driveService.deleteFileSync).toHaveBeenCalledWith(unsupportedFile); + expect(suggestions.insertOne).not.toHaveBeenCalled(); + }); + + test('a rejected remote image preserves a deduplicated Drive file', async () => { + const unsupportedFile = { ...emojiFile, type: 'application/octet-stream' }; + const { service, driveService } = createService({ + driveService: { + uploadFromUrlWithResult: jest.fn(async () => ({ file: unsupportedFile, isNew: false })), + }, + }); + + await expect(service.create({ + name: suggestion.name, + remoteEmojiId: remoteEmoji.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, proposer)).resolves.toEqual({ ok: false, reason: 'unsupportedFileType' }); + + expect(driveService.deleteFileSync).not.toHaveBeenCalled(); + }); + + test('a simultaneous remote duplicate removes its new Drive copy', async () => { + const driverError = Object.assign(new Error('duplicate key'), { code: '23505' }); + const duplicateError = new QueryFailedError('', [], driverError); + const { service, driveService } = createService({ + suggestions: { insertOne: jest.fn(async () => { throw duplicateError; }) }, + }); + + await expect(service.create({ + name: suggestion.name, + remoteEmojiId: remoteEmoji.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, proposer)).resolves.toEqual({ ok: false, reason: 'duplicateSuggestion' }); + + expect(driveService.deleteFileSync).toHaveBeenCalledWith(emojiFile); + }); + + test('a remote suggestion insert failure removes its new Drive copy', async () => { + const failure = new Error('insert failed'); + const { service, driveService } = createService({ + suggestions: { insertOne: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.create({ + name: suggestion.name, + remoteEmojiId: remoteEmoji.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, proposer)).rejects.toBe(failure); + + expect(driveService.deleteFileSync).toHaveBeenCalledWith(emojiFile); + }); + + test('submission rejects local emojis as remote sources without copying them', async () => { + const { service, driveService, suggestions } = createService({ + customEmoji: { + emojisByIdCache: { + fetchMaybe: jest.fn(async () => ({ ...remoteEmoji, host: null })), + }, + }, + }); + + await expect(service.create({ + name: suggestion.name, + remoteEmojiId: remoteEmoji.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, proposer)).resolves.toEqual({ ok: false, reason: 'noSuchRemoteEmoji' }); + + expect(driveService.uploadFromUrlWithResult).not.toHaveBeenCalled(); + expect(suggestions.insertOne).not.toHaveBeenCalled(); + }); + test('submission publishes a queue change to every active emoji reviewer', async () => { const adminId = '9abc000007'; const { service, notificationService, roleService, globalEventService } = createService({ diff --git a/packages/frontend/src/components/MkRemoteEmojiBrowser.vue b/packages/frontend/src/components/MkRemoteEmojiBrowser.vue new file mode 100644 index 0000000000..815cf44c96 --- /dev/null +++ b/packages/frontend/src/components/MkRemoteEmojiBrowser.vue @@ -0,0 +1,112 @@ + + + + + + diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 0bca5c1076..db7b92d528 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -40,31 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
- - - - - - - - - - - - - -
+ @@ -76,12 +52,11 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; +import MkRemoteEmojiBrowser from '@/components/MkRemoteEmojiBrowser.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import FormSplit from '@/components/form/split.vue'; import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; @@ -89,8 +64,6 @@ const emojisPaginationComponent = useTemplateRef('emojisPaginationComponent'); const tab = ref('local'); const query = ref(null); -const queryRemote = ref(null); -const host = ref(null); const selectMode = ref(false); const selectedEmojis = ref([]); @@ -105,20 +78,11 @@ const pagination = { })), }; -const remotePagination = { - endpoint: 'admin/emoji/list-remote' as const, - limit: 30, - params: computed(() => ({ - query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null, - host: (host.value && host.value !== '') ? host.value : null, - })), -}; - const selectAll = () => { if (selectedEmojis.value.length > 0) { selectedEmojis.value = []; } else { - selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id); + selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values() ?? [], item => item.id); } }; @@ -324,6 +288,7 @@ const headerActions = computed(() => [{ }, { icon: 'ti ti-dots', handler: menu, + text: i18n.ts.more, }]); const headerTabs = computed(() => [{ @@ -394,52 +359,5 @@ definePage(() => ({ } } - > .remote { - .empty { - margin: var(--MI-margin); - } - - .ldhfsamy { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - grid-gap: 12px; - margin: var(--MI-margin) 0; - - > .emoji { - display: flex; - align-items: center; - padding: 12px; - text-align: left; - - &:hover { - color: var(--MI_THEME-accent); - } - - > .img { - width: 32px; - height: 32px; - object-fit: contain; - } - - > .body { - padding: 0 0 0 8px; - white-space: nowrap; - overflow: hidden; - - > .name { - text-overflow: ellipsis; - overflow: hidden; - } - - > .info { - opacity: 0.5; - font-size: 90%; - text-overflow: ellipsis; - overflow: hidden; - } - } - } - } - } } diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 9ee82782d0..8a7b98bfc5 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -94,8 +94,11 @@ import MkSwitch from '@/components/MkSwitch.vue'; import { selectFile } from '@/utility/select-file.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; +const emojiCategoryDatalist = computed(() => customEmojiCategories.value.filter(category => category != null)); + const props = defineProps<{ emoji?: Misskey.entities.EmojiDetailed, + remoteEmoji?: Misskey.entities.EmojiDetailed, suggestion?: boolean, }>(); @@ -105,12 +108,13 @@ const emit = defineEmits<{ }>(); const windowEl = ref | null>(null); -const name = ref(props.emoji ? props.emoji.name : ''); -const category = ref(props.emoji?.category ? props.emoji.category : ''); -const aliases = ref(props.emoji ? props.emoji.aliases.join(' ') : ''); -const license = ref(props.emoji?.license ? props.emoji.license : ''); -const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false); -const localOnly = ref(props.emoji ? props.emoji.localOnly : false); +const sourceEmoji = props.emoji ?? props.remoteEmoji; +const name = ref(sourceEmoji?.name ?? ''); +const category = ref(sourceEmoji?.category ?? ''); +const aliases = ref(sourceEmoji?.aliases.join(' ') ?? ''); +const license = ref(sourceEmoji?.license ?? ''); +const isSensitive = ref(sourceEmoji?.isSensitive ?? false); +const localOnly = ref(sourceEmoji?.localOnly ?? false); const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); const rolesThatCanBeUsedThisEmojiAsReaction = ref([]); const file = ref(); @@ -119,7 +123,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => { rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); }, { immediate: true }); -const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null); +const imgUrl = computed(() => file.value ? file.value.url : sourceEmoji?.url ?? null); async function changeImage(ev: Event) { file.value = await selectFile(ev.currentTarget ?? ev.target, null); @@ -156,16 +160,17 @@ async function done() { }; if (props.suggestion) { - if (!file.value) { + if (!file.value && !props.remoteEmoji) { await os.alert({ type: 'error', text: i18n.ts.selectFile }); return; } const suggestionParams = { ...baseParams, - fileId: file.value.id, }; - const suggested = await os.apiWithDialog('emoji-suggestions/create', suggestionParams); + const suggested = file.value + ? await os.apiWithDialog('emoji-suggestions/create', { ...suggestionParams, fileId: file.value.id }) + : await os.apiWithDialog('emoji-suggestions/create', { ...suggestionParams, remoteEmojiId: props.remoteEmoji!.id }); emit('done', { suggested }); windowEl.value?.close(); return; diff --git a/packages/frontend/src/pages/emoji-suggestions.vue b/packages/frontend/src/pages/emoji-suggestions.vue index 8bcdbba43f..72d040b7a5 100644 --- a/packages/frontend/src/pages/emoji-suggestions.vue +++ b/packages/frontend/src/pages/emoji-suggestions.vue @@ -9,6 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.emojiSuggestionDescription }} + + + + +
+ {{ i18n.ts.remoteEmojiSuggestionDescription }} + +
+
+