From fe938bf8e68a20035861ed549650314e7c30a420 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sat, 21 Oct 2023 16:29:09 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=E7=B5=B5=E6=96=87=E5=AD=97=E7=94=B3?= =?UTF-8?q?=E8=AB=8B=E4=B8=AD=E3=81=AE=E3=82=84=E3=81=A4=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=83=96=E3=83=AB=E3=82=92=E5=88=86=E3=81=91=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: mattyatea --- locales/index.d.ts | 1 + .../1684236161625-addEmojiDraftFlag.js | 11 -- packages/backend/src/core/CoreModule.ts | 6 + .../backend/src/core/CustomEmojiService.ts | 127 +++++++++++++----- .../core/entities/DriveFileEntityService.ts | 6 + .../core/entities/EmojiDraftsEntityService.ts | 70 ++++++++++ .../src/core/entities/EmojiEntityService.ts | 2 - packages/backend/src/di-symbols.ts | 1 + packages/backend/src/misc/json-schema.ts | 4 +- packages/backend/src/models/Emoji.ts | 6 - packages/backend/src/models/EmojiDraft.ts | 72 ++++++++++ .../backend/src/models/RepositoryModule.ts | 10 +- packages/backend/src/models/_.ts | 3 + .../backend/src/models/json-schema/emoji.ts | 82 ++++++++++- packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 8 ++ packages/backend/src/server/api/endpoints.ts | 4 + .../api/endpoints/admin/emoji/add-draft.ts | 14 +- .../server/api/endpoints/admin/emoji/add.ts | 3 +- .../api/endpoints/admin/emoji/delete.ts | 9 +- .../api/endpoints/admin/emoji/draft-update.ts | 120 +++++++++++++++++ .../api/endpoints/admin/emoji/update.ts | 45 ++++--- .../src/server/api/endpoints/emoji-drafts.ts | 65 +++++++++ packages/backend/src/types.ts | 5 + .../src/components/MkCustomEmojiEditDraft.vue | 11 +- .../src/components/MkEmojiEditDialog.vue | 78 +++-------- .../frontend/src/components/MkPagination.vue | 11 +- packages/frontend/src/pages/about.emojis.vue | 7 +- packages/frontend/src/pages/emojis.emoji.vue | 72 +++++----- 29 files changed, 666 insertions(+), 189 deletions(-) delete mode 100644 packages/backend/migration/1684236161625-addEmojiDraftFlag.js create mode 100644 packages/backend/src/core/entities/EmojiDraftsEntityService.ts create mode 100644 packages/backend/src/models/EmojiDraft.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/emoji/draft-update.ts create mode 100644 packages/backend/src/server/api/endpoints/emoji-drafts.ts diff --git a/locales/index.d.ts b/locales/index.d.ts index e662093c9b..12f61f1aac 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -261,6 +261,7 @@ export interface Locale { "imageUrl": string; "remove": string; "removed": string; + "undraftAreYouSure": string; "removeAreYouSure": string; "deleteAreYouSure": string; "resetAreYouSure": string; diff --git a/packages/backend/migration/1684236161625-addEmojiDraftFlag.js b/packages/backend/migration/1684236161625-addEmojiDraftFlag.js deleted file mode 100644 index b0a13ea498..0000000000 --- a/packages/backend/migration/1684236161625-addEmojiDraftFlag.js +++ /dev/null @@ -1,11 +0,0 @@ -export class AddEmojiDraftFlag1684236161625 { - name = 'AddEmojiDraftFlag1684236161625' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "emoji" ADD "draft" boolean NOT NULL DEFAULT false`); - } - - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "draft"`); - } -} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index e7e66646fc..44894ffa25 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -86,6 +86,7 @@ import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; import { EmojiEntityService } from './entities/EmojiEntityService.js'; +import { EmojiDraftsEntityService } from './entities/EmojiDraftsEntityService.js'; import { FollowingEntityService } from './entities/FollowingEntityService.js'; import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; @@ -217,6 +218,7 @@ const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService }; +const $EmojiDraftsEntityService: Provider = { provide: 'EmojiDraftsEntityService', useExisting: EmojiDraftsEntityService }; const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService }; const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService }; const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService }; @@ -348,6 +350,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiDraftsEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -474,6 +477,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiDraftsEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, @@ -600,6 +604,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiDraftsEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -725,6 +730,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiDraftsEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index cdc7cae852..1aa6377be3 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -12,12 +12,13 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiEmoji } from '@/models/Emoji.js'; -import type { EmojisRepository, MiRole, MiUser, DriveFilesRepository } from '@/models/_.js'; +import type { EmojisRepository, EmojiDraftsRepository, MiRole, MiUser } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { MiEmojiDraft } from '@/models/EmojiDraft.js'; const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; @@ -33,8 +34,8 @@ export class CustomEmojiService implements OnApplicationShutdown { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, + @Inject(DI.emojiDraftsRepository) + private emojiDraftsRepository: EmojiDraftsRepository, private utilityService: UtilityService, private idService: IdService, @@ -58,6 +59,40 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } + @bindThis + public async draft(data: { + driveFile: MiDriveFile; + name: string; + category: string | null; + aliases: string[]; + license: string | null; + isSensitive: boolean; + localOnly: boolean; + }, me?: MiUser): Promise { + const emoji = await this.emojiDraftsRepository.insert({ + id: this.idService.gen(), + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + originalUrl: data.driveFile.url, + publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, + type: data.driveFile.webpublicType ?? data.driveFile.type, + license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + fileId: data.driveFile.id, + }).then(x => this.emojiDraftsRepository.findOneByOrFail(x.identifiers[0])); + + if (me) { + this.moderationLogService.log(me, 'addCustomEmoji', { + emojiId: emoji.id, + emoji: emoji, + }); + } + + return emoji; + } @bindThis public async add(data: { driveFile: MiDriveFile; @@ -68,7 +103,6 @@ export class CustomEmojiService implements OnApplicationShutdown { license: string | null; isSensitive: boolean; localOnly: boolean; - draft: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; }, moderator?: MiUser): Promise { const emoji = await this.emojisRepository.insert({ @@ -85,7 +119,6 @@ export class CustomEmojiService implements OnApplicationShutdown { isSensitive: data.isSensitive, localOnly: data.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, - draft: data.draft, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { @@ -113,44 +146,27 @@ export class CustomEmojiService implements OnApplicationShutdown { category?: string | null; aliases?: string[]; license?: string | null; - fileId?: string | null; isSensitive?: boolean; localOnly?: boolean; - draft: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; }, moderator?: MiUser): Promise { const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); - const driveFile = data.fileId !== null ? await this.driveFilesRepository.findOneBy({ id: data.fileId }) : null; const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); - if (driveFile !== null) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - name: data.name, - 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, - draft: data.draft, - }); - } else { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - name: data.name, - category: data.category, - aliases: data.aliases, - license: data.license, - isSensitive: data.isSensitive, - localOnly: data.localOnly, - draft: data.draft, - roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, - }); - } + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + license: data.license, + isSensitive: data.isSensitive, + localOnly: data.localOnly, + 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(); @@ -179,7 +195,35 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } } + @bindThis + public async draftUpdate(id: MiEmoji['id'], data: { + driveFile?: MiDriveFile; + name?: string; + category?: string | null; + aliases?: string[]; + license?: string | null; + isSensitive?: boolean; + localOnly?: boolean; + }, moderator?: MiUser): Promise { + const emoji = await this.emojiDraftsRepository.findOneByOrFail({ id: id }); + const sameNameEmoji = await this.emojiDraftsRepository.findOneBy({ name: data.name }); + if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + await this.emojiDraftsRepository.update(emoji.id, { + updatedAt: new Date(), + name: data.name, + 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, + }); + + this.localEmojisCache.refresh(); + } @bindThis public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) { const emojis = await this.emojisRepository.findBy({ @@ -287,7 +331,12 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } } + @bindThis + public async draftDelete(id: MiEmojiDraft['id']) { + const emoji = await this.emojiDraftsRepository.findOneByOrFail({ id: id }); + await this.emojiDraftsRepository.delete(emoji.id); + } @bindThis public async deleteBulk(ids: MiEmoji['id'][], moderator?: MiUser) { const emojis = await this.emojisRepository.findBy({ @@ -408,12 +457,20 @@ export class CustomEmojiService implements OnApplicationShutdown { public checkDuplicate(name: string): Promise { return this.emojisRepository.exist({ where: { name, host: IsNull() } }); } + @bindThis + public checkDraftDuplicate(name: string): Promise { + return this.emojiDraftsRepository.exist({ where: { name } }); + } @bindThis public getEmojiById(id: string): Promise { return this.emojisRepository.findOneBy({ id }); } + @bindThis + public getEmojiDraftById(id: string): Promise { + return this.emojiDraftsRepository.findOneBy({ id }); + } @bindThis public dispose(): void { this.cache.dispose(); diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 14be000367..d5406373b7 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -133,7 +133,13 @@ export class DriveFileEntityService { } return url; } + @bindThis + public async getFromUrl(url: string): Promise { + const file = await this.driveFilesRepository.findOneBy({ url: url }); + if (file === null ) return null; + return file; + } @bindThis public async calcDriveUsageOf(user: MiUser['id'] | { id: MiUser['id'] }): Promise { const id = typeof user === 'object' ? user.id : user; diff --git a/packages/backend/src/core/entities/EmojiDraftsEntityService.ts b/packages/backend/src/core/entities/EmojiDraftsEntityService.ts new file mode 100644 index 0000000000..de1a621553 --- /dev/null +++ b/packages/backend/src/core/entities/EmojiDraftsEntityService.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiDraftsRepository } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { MiEmojiDraft } from '@/models/EmojiDraft.js'; + +@Injectable() +export class EmojiDraftsEntityService { + constructor( + @Inject(DI.emojiDraftsRepository) + private emojiDraftsRepository: EmojiDraftsRepository, + ) { + } + + @bindThis + public async packSimple( + src: MiEmojiDraft['id'] | MiEmojiDraft, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojiDraftsRepository.findOneByOrFail({ id: src }); + + return { + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url: emoji.publicUrl, + isSensitive: emoji.isSensitive ? true : undefined, + }; + } + + @bindThis + public packSimpleMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.packSimple(x))); + } + + @bindThis + public async packDetailed( + src: MiEmojiDraft['id'] | MiEmojiDraft, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojiDraftsRepository.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + url: emoji.publicUrl, + license: emoji.license, + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + fileId: emoji.fileId, + }; + } + + @bindThis + public packDetailedMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.packDetailed(x))); + } +} + diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index d50d7356ae..5b97cfad5e 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -33,7 +33,6 @@ export class EmojiEntityService { url: emoji.publicUrl || emoji.originalUrl, isSensitive: emoji.isSensitive ? true : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, - draft: emoji.draft, }; } @@ -62,7 +61,6 @@ export class EmojiEntityService { isSensitive: emoji.isSensitive, localOnly: emoji.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, - draft: emoji.draft, }; } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index edcdd21d60..a374370edc 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -39,6 +39,7 @@ export const DI = { followRequestsRepository: Symbol('followRequestsRepository'), instancesRepository: Symbol('instancesRepository'), emojisRepository: Symbol('emojisRepository'), + emojiDraftsRepository: Symbol('emojiDraftsRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), metasRepository: Symbol('metasRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 80c1041c62..d203d5fa82 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,7 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { packedEmojiDetailedSchema, packedEmojiDraftSimpleSchema, packedEmojiSimpleSchema, packedEmojiDraftDetailedSchema } from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; @@ -69,7 +69,9 @@ export const refs = { FederationInstance: packedFederationInstanceSchema, GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, + EmojiDraftSimple: packedEmojiDraftSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiDraftDetailed: packedEmojiDraftDetailedSchema, Flash: packedFlashSchema, }; diff --git a/packages/backend/src/models/Emoji.ts b/packages/backend/src/models/Emoji.ts index bd8d54ffc3..563ac1d9d3 100644 --- a/packages/backend/src/models/Emoji.ts +++ b/packages/backend/src/models/Emoji.ts @@ -81,10 +81,4 @@ export class MiEmoji { array: true, length: 128, default: '{}', }) public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; - - @Column('boolean', { - default: false, - nullable: false, - }) - public draft: boolean; } diff --git a/packages/backend/src/models/EmojiDraft.ts b/packages/backend/src/models/EmojiDraft.ts new file mode 100644 index 0000000000..6d469fd200 --- /dev/null +++ b/packages/backend/src/models/EmojiDraft.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from './util/id.js'; + +@Entity('emoji_draft') +@Index(['name'], { unique: true }) +export class MiEmojiDraft { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public updatedAt: Date | null; + + @Index() + @Column('varchar', { + length: 128, + }) + public name: string; + + @Column('varchar', { + length: 128, nullable: true, + }) + public category: string | null; + + @Column('varchar', { + length: 512, + }) + public originalUrl: string; + + @Column('varchar', { + length: 512, + default: '', + }) + public publicUrl: string; + + // publicUrlの方のtypeが入る + @Column('varchar', { + length: 64, nullable: true, + }) + public type: string | null; + + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public aliases: string[]; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public license: string | null; + + @Column('varchar', { + length: 1024, nullable: false, + }) + public fileId: string; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 9efd6841b1..30f11cbc4e 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiEmojiDraft, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -165,6 +165,12 @@ const $emojisRepository: Provider = { inject: [DI.db], }; +const $emojiDraftsRepository: Provider = { + provide: DI.emojiDraftsRepository, + useFactory: (db: DataSource) => db.getRepository(MiEmojiDraft), + inject: [DI.db], +}; + const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, useFactory: (db: DataSource) => db.getRepository(MiDriveFile), @@ -423,6 +429,7 @@ const $userMemosRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiDraftsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, @@ -489,6 +496,7 @@ const $userMemosRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiDraftsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index f974f95ed8..98392c0af4 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -67,6 +67,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; +import { MiEmojiDraft } from '@/models/EmojiDraft.js'; import type { Repository } from 'typeorm'; export { @@ -87,6 +88,7 @@ export { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiDraft, MiFollowing, MiFollowRequest, MiGalleryLike, @@ -153,6 +155,7 @@ export type ClipFavoritesRepository = Repository; export type DriveFilesRepository = Repository; export type DriveFoldersRepository = Repository; export type EmojisRepository = Repository; +export type EmojiDraftsRepository = Repository; export type FollowingsRepository = Repository; export type FollowRequestsRepository = Repository; export type GalleryLikesRepository = Repository; diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 90054cbc50..70da8dc67f 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -40,10 +40,36 @@ export const packedEmojiSimpleSchema = { format: 'id', }, }, - draft: { - type: 'boolean', + }, +} as const; +export const packedEmojiDraftSimpleSchema = { + type: 'object', + properties: { + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', optional: false, nullable: true, }, + url: { + type: 'string', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: true, nullable: false, + }, }, } as const; @@ -85,10 +111,6 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: true, }, - draft: { - type: 'boolean', - optional: false, nullable: true, - }, isSensitive: { type: 'boolean', optional: false, nullable: false, @@ -108,3 +130,51 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiDraftDetailedSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + fileId: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index d4c6ad82ce..16c3387ad2 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -28,6 +28,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiDraft } from '@/models/EmojiDraft.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -160,6 +161,7 @@ export const entities = [ MiPoll, MiPollVote, MiEmoji, + MiEmojiDraft, MiHashtag, MiSwSubscription, MiAbuseUserReport, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 35d44d3579..f52791fe62 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -37,6 +37,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___admin_emoji_draftUpdate from './endpoints/admin/emoji/draft-update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -243,6 +244,7 @@ import * as ep___invite_list from './endpoints/invite/list.js'; import * as ep___invite_limit from './endpoints/invite/limit.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emojiDrafts from './endpoints/emoji-drafts.js'; import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; @@ -388,6 +390,7 @@ const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-ali const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; +const $admin_emoji_draftUpdate: Provider = { provide: 'ep:admin/emoji/draft-update', useClass: ep___admin_emoji_draftUpdate.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; @@ -594,6 +597,7 @@ const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invit const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default }; const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; +const $emoji_drafts: Provider = { provide: 'ep:emoji-drafts', useClass: ep___emojiDrafts.default }; const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; @@ -743,6 +747,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_draftUpdate, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, @@ -949,6 +954,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $invite_limit, $meta, $emojis, + $emoji_drafts, $emoji, $miauth_genToken, $mute_create, @@ -1092,6 +1098,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_draftUpdate, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, @@ -1298,6 +1305,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $invite_limit, $meta, $emojis, + $emoji_drafts, $emoji, $miauth_genToken, $mute_create, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 202f449efc..748a73b17e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -37,6 +37,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___admin_emoji_draftUpdate from './endpoints/admin/emoji/draft-update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -243,6 +244,7 @@ import * as ep___invite_list from './endpoints/invite/list.js'; import * as ep___invite_limit from './endpoints/invite/limit.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emojiDrafts from './endpoints/emoji-drafts.js'; import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; @@ -386,6 +388,7 @@ const eps = [ ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], + ['admin/emoji/draft-update', ep___admin_emoji_draftUpdate], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], @@ -592,6 +595,7 @@ const eps = [ ['invite/limit', ep___invite_limit], ['meta', ep___meta], ['emojis', ep___emojis], + ['emoji-drafts', ep___emojiDrafts], ['emoji', ep___emoji], ['miauth/gen-token', ep___miauth_genToken], ['mute/create', ep___mute_create], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-draft.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-draft.ts index 5212d7285b..af85b2c10a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-draft.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-draft.ts @@ -18,6 +18,11 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, + duplicateName: { + message: 'Duplicate name.', + code: 'DUPLICATE_NAME', + id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975', + }, }, } as const; @@ -55,11 +60,15 @@ export default class extends Endpoint { private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); + const isDraftDuplicate = await this.customEmojiService.checkDraftDuplicate(ps.name); + + if (isDuplicate || isDraftDuplicate) throw new ApiError(meta.errors.duplicateName); const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); - const emoji = await this.customEmojiService.add({ + const emoji = await this.customEmojiService.draft({ driveFile, name: ps.name, category: ps.category ?? null, @@ -67,9 +76,6 @@ export default class extends Endpoint { license: ps.license ?? null, isSensitive: ps.isSensitive ?? false, localOnly: ps.localOnly ?? false, - host: null, - draft: true, - roleIdsThatCanBeUsedThisEmojiAsReaction: [], }); await this.moderationLogService.log(me, 'addCustomEmoji', { 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 251abbc8d0..292cc89f54 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -51,7 +51,7 @@ export const paramDef = { type: 'string', } }, }, - required: ['name', 'fileId', 'draft'], + required: ['name', 'fileId'], } as const; // TODO: ロジックをサービスに切り出す @@ -82,7 +82,6 @@ export default class extends Endpoint { // eslint- license: ps.license ?? null, isSensitive: ps.isSensitive ?? false, localOnly: ps.localOnly ?? false, - draft: false, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], }, me); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 58aa0b9950..b3ec350272 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -36,7 +36,14 @@ export default class extends Endpoint { // eslint- private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.delete(ps.id, me); + const emoji = await this.customEmojiService.getEmojiById(ps.id); + const draftEmoji = await this.customEmojiService.getEmojiDraftById(ps.id); + if (emoji != null) { + await this.customEmojiService.delete(ps.id, me); + } + if (draftEmoji != null) { + await this.customEmojiService.draftDelete(ps.id); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/draft-update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/draft-update.ts new file mode 100644 index 0000000000..3e1aa4131a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/draft-update.ts @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import type { DriveFilesRepository, EmojiDraftsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + 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', + id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + 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, + 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', + } }, + draft: { type: 'boolean' }, + }, + required: ['id', 'name', 'aliases'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojiDraftsRepository) + private emojiDraftsRepository: EmojiDraftsRepository, + + private customEmojiService: CustomEmojiService, + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + let driveFile; + const isDraft = !!ps.draft; + if (ps.fileId) { + driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); + } + + const emoji = await this.customEmojiService.getEmojiDraftById(ps.id); + if (emoji != null) { + if (ps.name !== emoji.name) { + const isDuplicate = await this.customEmojiService.checkDraftDuplicate(ps.name); + if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); + } + } else { + throw new ApiError(meta.errors.noSuchEmoji); + } + if (!isDraft) { + const file = await this.driveFileEntityService.getFromUrl(emoji.originalUrl); + if (file === null) throw new ApiError(meta.errors.noSuchFile); + await this.customEmojiService.add({ + driveFile: file, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + host: null, + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, me); + await this.customEmojiService.draftDelete(ps.id); + } else { + await this.customEmojiService.draftUpdate(ps.id, { + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + } + }); + } +} 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 bebfdae8f2..f996560ce1 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -5,8 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, EmojiDraftsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -57,7 +58,7 @@ export const paramDef = { } }, draft: { type: 'boolean' }, }, - required: ['id', 'name', 'aliases', 'draft'], + required: ['id', 'name', 'aliases'], } as const; @Injectable() @@ -67,10 +68,11 @@ export default class extends Endpoint { // eslint- private driveFilesRepository: DriveFilesRepository, private customEmojiService: CustomEmojiService, + private driveFileEntityService: DriveFileEntityService, ) { super(meta, paramDef, async (ps, me) => { let driveFile; - + const isDraft = !!ps.draft; if (ps.fileId) { driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); @@ -85,18 +87,31 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchEmoji); } - 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, - fileId: ps.fileId ?? null, - draft: ps.draft, - }, me); + if (!isDraft) { + 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, + }, me); + } else { + const file = await this.driveFileEntityService.getFromUrl(emoji.originalUrl); + if (file === null) throw new ApiError(meta.errors.noSuchFile); + await this.customEmojiService.draft({ + driveFile: file, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + await this.customEmojiService.delete(ps.id); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/emoji-drafts.ts b/packages/backend/src/server/api/endpoints/emoji-drafts.ts new file mode 100644 index 0000000000..5b23dac093 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-drafts.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { EmojiDraftsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiDraftsEntityService } from '@/core/entities/EmojiDraftsEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + allowGet: true, + cacheSec: 3600, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + emojis: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'EmojiDraftDetailed', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiDraftsRepository) + private emojiDraftsRepository: EmojiDraftsRepository, + + private emojiDraftsEntityService: EmojiDraftsEntityService, + ) { + super(meta, paramDef, async () => { + const emojis = await this.emojiDraftsRepository.find({ + order: { + category: 'ASC', + name: 'ASC', + }, + }); + + return { + emojis: await this.emojiDraftsEntityService.packDetailedMany(emojis), + }; + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 316073c992..f7ebabca2d 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -33,6 +33,7 @@ export const moderationLogTypes = [ 'unsuspend', 'updateUserNote', 'addCustomEmoji', + 'requestCustomEmoji', 'updateCustomEmoji', 'deleteCustomEmoji', 'assignRole', @@ -88,6 +89,10 @@ export type ModerationLogPayloads = { emojiId: string; emoji: any; }; + requestCustomEmoji: { + emojiId: string; + emoji: any; + }; updateCustomEmoji: { emojiId: string; before: any; diff --git a/packages/frontend/src/components/MkCustomEmojiEditDraft.vue b/packages/frontend/src/components/MkCustomEmojiEditDraft.vue index c609898b66..d5446eb18e 100644 --- a/packages/frontend/src/components/MkCustomEmojiEditDraft.vue +++ b/packages/frontend/src/components/MkCustomEmojiEditDraft.vue @@ -45,18 +45,19 @@ const emojisDraftPaginationComponent = shallowRef ({ query: (query.value && query.value !== '') ? query.value : null, - draft: true, })), }; const editDraft = (emoji) => { + emoji.isDraft = true; os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { emoji: emoji, isRequest: false, + isDraftEdit: true, }, { done: result => { if (result.updated) { @@ -80,16 +81,16 @@ async function undrafted(emoji) { }); if (canceled) return; - await os.api('admin/emoji/update', { + await os.api('admin/emoji/draft-update', { id: emoji.id, + fileId: emoji.fileId, name: emoji.name, category: emoji.category, aliases: emoji.aliases, license: emoji.license, - draft: false, isSensitive: emoji.isSensitive, localOnly: emoji.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, + isDraft: false, }); emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); diff --git a/packages/frontend/src/components/MkEmojiEditDialog.vue b/packages/frontend/src/components/MkEmojiEditDialog.vue index 65690042a5..cc0c17fd10 100644 --- a/packages/frontend/src/components/MkEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkEmojiEditDialog.vue @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only isSensitive {{ i18n.ts.localOnly }} - + {{ i18n.ts.draft }} @@ -100,6 +100,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ emoji?: any, isRequest: boolean, + isDraftEdit?: boolean, }>(); let dialog = $ref(null); @@ -109,18 +110,18 @@ 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 roleIdsThatCanBeUsedThisEmojiAsReaction = $ref((props.emoji && props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); let file = $ref(); let chooseFile: DriveFile|null = $ref(null); -let draft = $ref(props.emoji ? props.emoji.draft : false); let isRequest = $ref(props.isRequest); - +let isDraftEdit = $ref(props.isDraftEdit ?? false); +let isDraft = $ref(!!(isDraftEdit || props.isRequest)); watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => { rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); }, { immediate: true }); -const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); +const imgUrl = computed(() => file ? file.url : props.emoji && !isDraftEdit ? `/emoji/${props.emoji.name}.webp` : props.emoji && props.emoji.url ? props.emoji.url : null); const validation = computed(() => { return name.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null; }); @@ -129,28 +130,6 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -async function add() { - const ret = await os.api('admin/emoji/add-draft', { - name: name, - category: category, - aliases: aliases.split(' '), - license: license === '' ? null : license, - fileId: chooseFile.id, - }); - - emit('done', { - updated: { - id: ret.id, - name, - category, - aliases: aliases.split(' '), - license: license === '' ? null : license, - draft: true, - }, - }); - - dialog.close(); -} async function changeImage(ev) { file = await selectFile(ev.currentTarget ?? ev.target, null); const candidate = file.name.replace(/\.(.+)$/, ''); @@ -174,37 +153,13 @@ async function addRole() { async function removeRole(role, ev) { rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); } -async function update() { - await os.apiWithDialog('admin/emoji/update', { - id: props.emoji.id, - name, - category, - aliases: aliases.split(' '), - license: license === '' ? null : license, - fileId: chooseFile?.id, - draft: draft, - }); - - emit('done', { - updated: { - id: props.emoji.id, - name, - category, - aliases: aliases.split(' '), - license: license === '' ? null : license, - draft: draft, - }, - }); - - dialog.close(); -} async function done() { const params = { name, category: category === '' ? null : category, aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''), license: license === '' ? null : license, - draft: draft, + draft: isDraft, isSensitive, localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id), @@ -213,12 +168,19 @@ async function done() { if (file) { params.fileId = file.id; } - console.log(props.emoji); + if (props.emoji) { - await os.apiWithDialog('admin/emoji/update', { - id: props.emoji.id, - ...params, - }); + if (isDraftEdit) { + await os.apiWithDialog('admin/emoji/draft-update', { + id: props.emoji.id, + ...params, + }); + } else { + await os.apiWithDialog('admin/emoji/update', { + id: props.emoji.id, + ...params, + }); + } emit('done', { updated: { diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 5a87273386..2a0b0fa025 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -201,11 +201,13 @@ async function init(): Promise { ...params, limit: props.pagination.limit ?? 10, }).then(res => { + if (props.pagination.endpoint === 'emoji-drafts') { + res = res.emojis; + } for (let i = 0; i < res.length; i++) { const item = res[i]; if (i === 3) item._shouldInsertAd_ = true; } - if (res.length === 0 || props.pagination.noPaging) { concatItems(res); more.value = false; @@ -214,7 +216,6 @@ async function init(): Promise { concatItems(res); more.value = true; } - offset.value = res.length; error.value = false; fetching.value = false; @@ -241,6 +242,9 @@ const fetchMore = async (): Promise => { untilId: Array.from(items.value.keys()).at(-1), }), }).then(res => { + if (props.pagination.endpoint === 'emoji-drafts') { + res = res.emojis; + } for (let i = 0; i < res.length; i++) { const item = res[i]; if (i === 10) item._shouldInsertAd_ = true; @@ -305,6 +309,9 @@ const fetchMoreAhead = async (): Promise => { sinceId: Array.from(items.value.keys()).at(-1), }), }).then(res => { + if (props.pagination.endpoint === 'emoji-drafts') { + res = res.emojis; + } if (res.length === 0) { items.value = concatMapWithArray(items.value, res); more.value = false; diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index ced2881786..ecbefbd086 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -32,13 +32,13 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
- +
@@ -73,8 +73,7 @@ definePageMetadata(ref({})); let q = $ref(''); let searchEmojis = $ref(null); let selectedTags = $ref(new Set()); -const draftEmojis = customEmojis.value.filter(emoji => emoji.draft); - +const draftEmojis = await os.apiGet('emoji-drafts'); function search() { if ((q === '' || q == null) && selectedTags.size === 0) { searchEmojis = null; diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index bd2cb33f55..d112cfcb7c 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,13 +26,13 @@ import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - emoji: { - name: string; - aliases: string[]; - category: string; - url: string; - draft: boolean; - }; + emoji: { + name: string; + aliases: string[]; + category: string; + url: string; + }; + draft?: boolean; }>(); function menu(ev) { @@ -50,7 +50,7 @@ function menu(ev) { text: i18n.ts.info, icon: 'ti ti-info-circle', action: () => { - os.apiGet('emoji', { name: props.emoji.name }).then(res => { + os.apiGet('emoji-drafts', { name: props.emoji.name }).then(res => { os.alert({ type: 'info', text: `License: ${res.license}`, @@ -63,45 +63,45 @@ function menu(ev) {