diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2d39cdef..c4fba1941b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ ## 2023.10.1 ### General - Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に +- Feat: 絵文字申請を追加 + - これによって絵文字リクエストロールが追加されました。 + - カスタム絵文字管理の画面に 申請されている絵文字 タブが追加されました。 + - カスタム絵文字のリクエストボタンが実装されました。 ### Client - Fix: 絵文字ピッカーで横に長いカスタム絵文字が見切れる問題を修正 @@ -78,6 +82,7 @@ - Enhance: 動画再生時のデフォルトボリュームを30%に - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 + ### Server - Enhance: drive/files/attached-notes がページネーションに対応しました - Enhance: タイムライン取得時のパフォーマンスを大幅に向上 diff --git a/locales/index.d.ts b/locales/index.d.ts index cee9f42ca7..c249eac574 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -272,6 +272,7 @@ export interface Locale { "removed": string; "removeAreYouSure": string; "deleteAreYouSure": string; + "undraftAreYouSure": string; "resetAreYouSure": string; "saved": string; "messaging": string; @@ -676,6 +677,8 @@ export interface Locale { "regenerateLoginToken": string; "regenerateLoginTokenDescription": string; "setMultipleBySeparatingWithSpace": string; + "emojiNameValidation": string; + "isSensitive": string; "fileIdOrUrl": string; "behavior": string; "sample": string; @@ -849,8 +852,11 @@ export interface Locale { "low": string; "GamingSpeedChange": string; "GamingSpeedChangeInfo": string; + "list": string; "emailNotConfiguredWarning": string; "ratio": string; + "newEmojis": string; + "draftEmojis": string; "showVisibilityColor": string; "previewNoteText": string; "customCss": string; @@ -1004,6 +1010,7 @@ export interface Locale { "unassign": string; "color": string; "manageCustomEmojis": string; + "requestCustomEmojis": string; "youCannotCreateAnymore": string; "cannotPerformTemporary": string; "cannotPerformTemporaryDescription": string; @@ -1046,6 +1053,8 @@ export interface Locale { "sensitiveWordsDescription2": string; "notesSearchNotAvailable": string; "license": string; + "draft": string; + "undrafted": string; "unfavoriteConfirm": string; "myClips": string; "drivecleaner": string; @@ -1585,6 +1594,7 @@ export interface Locale { "inviteLimitCycle": string; "inviteExpirationTime": string; "canManageCustomEmojis": string; + "canRequestCustomEmojis": string; "driveCapacity": string; "alwaysMarkNsfw": string; "pinMax": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 102277c3a7..3506689a66 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -269,6 +269,7 @@ remove: "削除" removed: "削除しました" removeAreYouSure: "「{x}」を削除しますか?" deleteAreYouSure: "「{x}」を削除しますか?" +undraftAreYouSure: "「{x}」をドラフト解除しますか?" resetAreYouSure: "リセットしますか?" saved: "保存しました" messaging: "チャット" @@ -673,6 +674,8 @@ other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。" setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。" +emojiNameValidation: "名前には英数字と_が利用できます。" +isSensitive: "センシティブ" fileIdOrUrl: "ファイルIDまたはURL" behavior: "動作" sample: "サンプル" @@ -846,9 +849,12 @@ middle: "中" low: "低" GamingSpeedChange: "ゲーミングの光るスピードの調整" GamingSpeedChangeInfo: "左にすれば早くなる、右にすれば遅くなる。それだけ。" +list: "一覧" emailNotConfiguredWarning: "メールアドレスの設定がされていません。" ratio: "比率" showVisibilityColor: "ノートの公開範囲を色付けする" +newEmojis: "新しい絵文字" +draftEmojis: "申請されている絵文字" previewNoteText: "本文をプレビュー" customCss: "カスタムCSS" customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" @@ -1001,6 +1007,7 @@ assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" +requestCustomEmojis: "カスタム絵文字のリクエスト" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" @@ -1043,6 +1050,8 @@ sensitiveWordsDescription: "設定したワードが含まれるノートの公 sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" notesSearchNotAvailable: "ノート検索は利用できません。" license: "ライセンス" +draft: "ドラフト" +undrafted: "ドラフト解除" unfavoriteConfirm: "お気に入り解除しますか?" myClips: "自分のクリップ" drivecleaner: "ドライブクリーナー" @@ -1506,6 +1515,7 @@ _role: inviteLimitCycle: "招待コードの発行間隔" inviteExpirationTime: "招待コードの有効期限" canManageCustomEmojis: "カスタム絵文字の管理" + canRequestCustomEmojis: "カスタム絵文字のリクエスト" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" pinMax: "ノートのピン留めの最大数" diff --git a/packages/backend/migration/1684236161625-addEmojiDraftFlag.js b/packages/backend/migration/1684236161625-addEmojiDraftFlag.js new file mode 100644 index 0000000000..b0a13ea498 --- /dev/null +++ b/packages/backend/migration/1684236161625-addEmojiDraftFlag.js @@ -0,0 +1,11 @@ +export class AddEmojiDraftFlag1684236161625 { + name = 'AddEmojiDraftFlag1684236161625' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "draft" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "draft"`); + } +} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index a7786e861f..80acc4042b 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -16,10 +16,8 @@ import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; - const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; @Injectable() @@ -66,6 +64,7 @@ export class CustomEmojiService implements OnApplicationShutdown { license: string | null; isSensitive: boolean; localOnly: boolean; + draft: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; }, moderator?: MiUser): Promise<MiEmoji> { const emoji = await this.emojisRepository.insert({ @@ -82,6 +81,7 @@ 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) { @@ -111,6 +111,7 @@ export class CustomEmojiService implements OnApplicationShutdown { license?: string | null; isSensitive?: boolean; localOnly?: boolean; + draft: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; }, moderator?: MiUser): Promise<void> { const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); @@ -125,6 +126,7 @@ export class CustomEmojiService implements OnApplicationShutdown { license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, + draft: data.draft, 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, diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index c8734cb0b2..be4f874803 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -33,6 +33,7 @@ export type RolePolicies = { inviteLimitCycle: number; inviteExpirationTime: number; canManageCustomEmojis: boolean; + canRequestCustomEmojis: boolean; canSearchNotes: boolean; canUseTranslator: boolean; canHideAds: boolean; @@ -59,6 +60,7 @@ export const DEFAULT_POLICIES: RolePolicies = { inviteLimitCycle: 60 * 24 * 7, inviteExpirationTime: 0, canManageCustomEmojis: false, + canRequestCustomEmojis: false, canSearchNotes: false, canUseTranslator: true, canHideAds: false, @@ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown { inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 4d7e14f683..e1230012be 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -19,7 +19,13 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { + FollowingsRepository, + FollowRequestsRepository, + InstancesRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; @@ -53,25 +59,18 @@ export class UserFollowingService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.config) private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, - @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private cacheService: CacheService, private utilityService: UtilityService, private userEntityService: UserEntityService, @@ -197,10 +196,18 @@ export class UserFollowingService implements OnModuleInit { @bindThis private async insertFollowingDoc( followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox'] }, follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox'] }, silent = false, withReplies?: boolean, @@ -247,8 +254,7 @@ export class UserFollowingService implements OnModuleInit { }); // 通知を作成 - this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - }, followee.id); + this.notificationService.createNotification(follower.id, 'followRequestAccepted', {}, followee.id); } if (alreadyFollowed) return; @@ -322,18 +328,25 @@ export class UserFollowingService implements OnModuleInit { }); // 通知を作成 - this.notificationService.createNotification(followee.id, 'follow', { - }, follower.id); + this.notificationService.createNotification(followee.id, 'follow', {}, follower.id); } } @bindThis public async unfollow( follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox']; }, followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox']; }, silent = false, ): Promise<void> { @@ -464,10 +477,18 @@ export class UserFollowingService implements OnModuleInit { @bindThis public async createFollowRequest( follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox']; }, followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox']; }, requestId?: string, withReplies?: boolean, @@ -560,7 +581,11 @@ export class UserFollowingService implements OnModuleInit { @bindThis public async acceptFollowRequest( followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox']; }, follower: MiUser, ): Promise<void> { @@ -588,7 +613,11 @@ export class UserFollowingService implements OnModuleInit { @bindThis public async acceptAllFollowRequests( user: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; + id: MiUser['id']; + host: MiUser['host']; + uri: MiUser['host']; + inbox: MiUser['inbox']; + sharedInbox: MiUser['sharedInbox']; }, ): Promise<void> { const requests = await this.followRequestsRepository.findBy({ diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 5b97cfad5e..d50d7356ae 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -33,6 +33,7 @@ export class EmojiEntityService { url: emoji.publicUrl || emoji.originalUrl, isSensitive: emoji.isSensitive ? true : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, + draft: emoji.draft, }; } @@ -61,6 +62,7 @@ export class EmojiEntityService { isSensitive: emoji.isSensitive, localOnly: emoji.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, + draft: emoji.draft, }; } diff --git a/packages/backend/src/models/Emoji.ts b/packages/backend/src/models/Emoji.ts index 563ac1d9d3..bd8d54ffc3 100644 --- a/packages/backend/src/models/Emoji.ts +++ b/packages/backend/src/models/Emoji.ts @@ -81,4 +81,10 @@ 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/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 99a58f8773..90054cbc50 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -40,6 +40,10 @@ export const packedEmojiSimpleSchema = { format: 'id', }, }, + draft: { + type: 'boolean', + optional: false, nullable: true, + }, }, } as const; @@ -81,6 +85,10 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: true, }, + draft: { + type: 'boolean', + optional: false, nullable: true, + }, isSensitive: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index a52af54a39..088596bba6 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService { isSensitive: emojiInfo.isSensitive, localOnly: emojiInfo.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: [], + draft: false, }); } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index b4cdc196f8..79164e51df 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -27,6 +27,7 @@ import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-al import * as ep___admin_emoji_setlocalOnlyBulk from './endpoints/admin/emoji/set-localonly-bulk.js'; import * as ep___admin_emoji_setisSensitiveBulk from './endpoints/admin/emoji/set-issensitive-bulk.js'; import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; +import * as ep___admin_emoji_addDraft from './endpoints/admin/emoji/add-draft.js'; import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; @@ -382,6 +383,7 @@ const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-ali const $admin_emoji_setlocalOnlyBulk: Provider = { provide: 'ep:admin/emoji/set-localonly-bulk', useClass: ep___admin_emoji_setlocalOnlyBulk.default }; const $admin_emoji_setisSensitiveBulk: Provider = { provide: 'ep:admin/emoji/set-issensitive-bulk', useClass: ep___admin_emoji_setisSensitiveBulk.default }; const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default }; +const $admin_emoji_addDraft: Provider = { provide: 'ep:admin/emoji/add-draft', useClass: ep___admin_emoji_addDraft.default }; const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default }; const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default }; const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default }; @@ -741,6 +743,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_setlocalOnlyBulk, $admin_emoji_setisSensitiveBulk, $admin_emoji_add, + $admin_emoji_addDraft, $admin_emoji_copy, $admin_emoji_deleteBulk, $admin_emoji_delete, @@ -1092,6 +1095,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_drive_showFile, $admin_emoji_addAliasesBulk, $admin_emoji_add, + $admin_emoji_addDraft, $admin_emoji_copy, $admin_emoji_deleteBulk, $admin_emoji_delete, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3fac9552ad..c2ab5d85fa 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -26,6 +26,7 @@ import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; +import * as ep___admin_emoji_addDraft from './endpoints/admin/emoji/add-draft.js'; import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; @@ -377,6 +378,7 @@ const eps = [ ['admin/drive/show-file', ep___admin_drive_showFile], ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], ['admin/emoji/add', ep___admin_emoji_add], + ['admin/emoji/add-draft', ep___admin_emoji_addDraft], ['admin/emoji/copy', ep___admin_emoji_copy], ['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], ['admin/emoji/delete', ep___admin_emoji_delete], 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 new file mode 100644 index 0000000000..7088f801e9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-draft.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { DriveFilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canRequestCustomEmojis', + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, + category: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the category.', + }, + aliases: { type: 'array', items: { + type: 'string', + } }, + license: { type: 'string', nullable: true }, + fileId: { type: 'string', format: 'misskey:id' }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + }, + required: ['name', 'fileId'], +} as const; + +// TODO: ロジックをサービスに切り出す + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private customEmojiService: CustomEmojiService, + + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); + + const emoji = await this.customEmojiService.add({ + driveFile, + name: ps.name, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + host: null, + draft: true, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }); + + return { + id: emoji.id, + }; + }); + } +} 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 faab8ee608..7f4474419c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -41,17 +41,21 @@ export const paramDef = { nullable: true, description: 'Use `null` to reset the category.', }, - aliases: { type: 'array', items: { - type: 'string', - } }, + aliases: { + type: 'array', items: { + type: 'string', + }, + }, license: { type: 'string', nullable: true }, isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', items: { + type: 'string', + }, + }, }, - required: ['name', 'fileId'], + required: ['name', 'fileId', 'draft'], } as const; // TODO: ロジックをサービスに切り出す @@ -61,13 +65,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, - private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); if (isDuplicate) throw new ApiError(meta.errors.duplicateName); @@ -81,6 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index ab16d86a3d..8fba829c5e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -64,6 +64,7 @@ export const paramDef = { type: 'object', properties: { query: { type: 'string', nullable: true, default: null }, + draft: { type: 'boolean', nullable: true, default: null }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -86,6 +87,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- let emojis: MiEmoji[]; + if (ps.draft !== null) { + if (ps.draft) { + q.andWhere('emoji.draft = TRUE'); + } else { + q.andWhere('emoji.draft = FALSE'); + } + } + if (ps.query) { //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); //const emojis = await q.limit(ps.limit).getMany(); 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 0bcf30a1c6..7512e88c05 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -60,8 +60,9 @@ export const paramDef = { roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { type: 'string', } }, + draft: { type: 'boolean' }, }, - required: ['id', 'name', 'aliases'], + required: ['id', 'name', 'draft', 'aliases'], } as const; @Injectable() @@ -97,6 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- isSensitive: ps.isSensitive, localOnly: ps.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, + draft: ps.draft, }, me); }); } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 7c4f910559..f8b655e772 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -96,6 +96,10 @@ const emojiDb = computed(() => { const customEmojiDB: EmojiDef[] = []; for (const x of customEmojis.value) { + if (x.draft) { + continue; + } + customEmojiDB.push({ name: x.name, emoji: `:${x.name}:`, diff --git a/packages/frontend/src/components/MkCustomEmojiEditDraft.vue b/packages/frontend/src/components/MkCustomEmojiEditDraft.vue new file mode 100644 index 0000000000..34d033119d --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiEditDraft.vue @@ -0,0 +1,214 @@ +<template> +<MkPagination ref="emojisDraftPaginationComponent" :pagination="paginationDraft"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <template v-for="emoji in items" :key="emoji.id"> + <div class="emoji _panel"> + <div class="img"> + <div class="imgLight"><img :src="emoji.url" :alt="emoji.name"/></div> + <div class="imgDark"><img :src="emoji.url" :alt="emoji.name"/></div> + </div> + <div class="info"> + <div class="name">{{ i18n.ts.name }}: {{ emoji.name }}</div> + <div class="category">{{ i18n.ts.category }}:{{ emoji.category }}</div> + <div class="aliases">{{ i18n.ts.tags }}:{{ emoji.aliases.join(' ') }}</div> + <div class="license">{{ i18n.ts.license }}:{{ emoji.license }}</div> + </div> + <div class="edit-button"> + <MkButton primary class="edit" @click="editDraft(emoji)"> + {{ i18n.ts.edit }} + </MkButton> + <MkButton class="draft" @click="undrafted(emoji)"> + {{ i18n.ts.undrafted }} + </MkButton> + <MkButton danger class="delete" @click="deleteDraft(emoji)"> + {{ i18n.ts.delete }} + </MkButton> + </div> + </div> + </template> + </div> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import MkButton from '@/components/MkButton.vue'; + +const emojisDraftPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); + +const query = ref(null); + +const paginationDraft = { + endpoint: 'admin/emoji/list' as const, + limit: 30, + params: computed(() => ({ + query: (query.value && query.value !== '') ? query.value : null, + draft: true, + })), +}; + +const editDraft = (emoji) => { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { + emoji: emoji, + isRequest: false, + }, { + done: result => { + if (result.updated) { + emojisDraftPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ + ...oldEmoji, + ...result.updated, + })); + emojisDraftPaginationComponent.value.reload(); + } else if (result.deleted) { + emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); + emojisDraftPaginationComponent.value.reload(); + } + }, + }, 'closed'); +}; + +async function undrafted(emoji) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('undraftAreYouSure', { x: emoji.name }), + }); + if (canceled) return; + + await os.api('admin/emoji/update', { + id: emoji.id, + name: emoji.name, + category: emoji.category, + aliases: emoji.aliases, + license: emoji.license, + draft: false, + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, + }); + + emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); + emojisDraftPaginationComponent.value.reload(); +} + +async function deleteDraft(emoji) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: emoji.name }), + }); + if (canceled) return; + + os.api('admin/emoji/delete', { + id: emoji.id, + }).then(() => { + emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); + emojisDraftPaginationComponent.value.reload(); + }); +} +</script> + +<style lang="scss" scoped> +.empty { + margin: var(--margin); +} + +.ldhfsamy { + > .emoji { + align-items: center; + padding: 11px; + text-align: left; + border: solid 1px var(--panel); + margin: 10px; + + > .img { + display: grid; + grid-row: 1; + grid-column: 1/ span 2; + grid-template-columns: 50% 50%; + place-content: center; + place-items: center; + + > .imgLight { + display: grid; + grid-column: 1; + background-color: #fff; + margin-bottom: 12px; + > img { + max-height: 64px; + max-width: 100%; + } + } + + > .imgDark { + display: grid; + grid-column: 2; + background-color: #000; + margin-bottom: 12px; + > img { + max-height: 64px; + max-width: 100%; + } + } + } + + > .info { + display: grid; + grid-row: 2; + grid-template-rows: 30px 30px 30px; + + > .name { + grid-row: 1; + text-overflow: ellipsis; + overflow: hidden; + } + + > .category { + grid-row: 2; + text-overflow: ellipsis; + overflow: hidden; + } + + > .aliases { + grid-row: 3; + text-overflow: ellipsis; + overflow: hidden; + } + + > .license { + grid-row: 4; + text-overflow: ellipsis; + overflow: hidden; + } + } + + > .edit-button { + display: grid; + grid-template-rows: 42px; + margin-top: 6px; + + > .edit { + grid-row: 1; + width: 100%; + margin: 6px 0; + } + + > .draft { + grid-row: 2; + width: 100%; + margin: 6px 0; + } + + > .delete { + grid-row: 3; + width: 100%; + margin: 6px 0; + } + } + } +} +</style> diff --git a/packages/frontend/src/components/MkCustomEmojiEditLocal.vue b/packages/frontend/src/components/MkCustomEmojiEditLocal.vue new file mode 100644 index 0000000000..7112a38430 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiEditLocal.vue @@ -0,0 +1,225 @@ +<template> +<MkInput v-model="query" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> +</MkInput> +<MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> +</MkSwitch> +<div v-if="selectMode" class="_buttons"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setLisenceBulk">Set Lisence</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> +</div> +<MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="100"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id"> + <button v-if="emoji.draft" class="emoji _panel _button emoji-draft" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name + ' (draft)' }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + <button v-else class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </div> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); + +const query = ref(null); +const selectMode = ref(false); +const selectedEmojis = ref<string[]>([]); + +const pagination = { + endpoint: 'admin/emoji/list' as const, + limit: 30, + params: computed(() => ({ + query: (query.value && query.value !== '') ? query.value : null, + })), +}; + +const selectAll = () => { + if (selectedEmojis.value.length > 0) { + selectedEmojis.value = []; + } else { + selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id); + } +}; + +const toggleSelect = (emoji) => { + if (selectedEmojis.value.includes(emoji.id)) { + selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); + } else { + selectedEmojis.value.push(emoji.id); + } +}; + +const edit = (emoji) => { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { + emoji: emoji, + isRequest: false, + }, { + done: result => { + if (result.updated) { + emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ + ...oldEmoji, + ...result.updated, + })); + emojisPaginationComponent.value.reload(); + } else if (result.deleted) { + emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); + } + }, + }, 'closed'); +}; + +const setCategoryBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Category', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-category-bulk', { + ids: selectedEmojis.value, + category: result, + }); + emojisPaginationComponent.value.reload(); +}; + +const setLisenceBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'License', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-license-bulk', { + ids: selectedEmojis.value, + license: result, + }); + emojisPaginationComponent.value.reload(); +}; + +const addTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/add-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const removeTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const setTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const delBulk = async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/delete-bulk', { + ids: selectedEmojis.value, + }); + emojisPaginationComponent.value.reload(); +}; +</script> + +<style lang="scss" scoped> +.ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: var(--margin); + + div > .emoji { + display: flex; + align-items: center; + padding: 11px; + text-align: left; + border: solid 1px var(--panel); + width: 100%; + + &:hover { + border-color: var(--inputBorderHover); + } + + &.selected { + border-color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; + } + } + } +} + +.emoji-draft { + --c: rgb(255 196 0 / 15%);; + background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%); + background-size: 16px 16px; +} +</style> diff --git a/packages/frontend/src/components/MkCustomEmojiEditRemote.vue b/packages/frontend/src/components/MkCustomEmojiEditRemote.vue new file mode 100644 index 0000000000..26c8dd66ac --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiEditRemote.vue @@ -0,0 +1,110 @@ +<template> +<FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> +</FormSplit> +<MkPagination :pagination="remotePagination" :displayLimit="100"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> + </div> + </div> + </div> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import MkInput from '@/components/MkInput.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const queryRemote = ref(null); +const host = ref(null); + +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 im = (emoji) => { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, + }); +}; + +const remoteMenu = (emoji, ev: MouseEvent) => { + os.popupMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: i18n.ts.import, + icon: 'ti ti-plus', + action: () => { im(emoji); }, + }], ev.currentTarget ?? ev.target); +}; +</script> + +<style lang="scss" scoped> +.empty { + margin: var(--margin); +} + +.ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 32px; + height: 32px; + } + + > .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; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/components/MkEmojiEditDialog.vue similarity index 72% rename from packages/frontend/src/pages/emoji-edit-dialog.vue rename to packages/frontend/src/components/MkEmojiEditDialog.vue index 2e6050490e..43b03971e7 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/components/MkEmojiEditDialog.vue @@ -7,10 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkModalWindow ref="dialog" :width="400" + :withOkButton="false " @close="dialog.close()" @closed="$emit('closed')" > <template v-if="emoji" #header>:{{ emoji.name }}:</template> + <template v-else-if="isRequest" #header>{{ i18n.ts.requestCustomEmojis }}</template> <template v-else #header>New emoji</template> <div> @@ -33,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> <MkInput v-model="name" pattern="[a-z0-9_]"> <template #label>{{ i18n.ts.name }}</template> + <template #caption>{{ i18n.ts.emojiNameValidation }}</template> </MkInput> <MkInput v-model="category" :datalist="customEmojiCategories"> <template #label>{{ i18n.ts.category }}</template> @@ -44,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="license"> <template #label>{{ i18n.ts.license }}</template> </MkInput> - <MkFolder> + <MkFolder v-if="!isRequest"> <template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template> <template #suffix>{{ rolesThatCanBeUsedThisEmojiAsReaction.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisEmojiAsReaction.length }}</template> @@ -61,13 +64,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo> </div> </MkFolder> - <MkSwitch v-model="isSensitive">isSensitive</MkSwitch> + <MkSwitch v-model="isSensitive">{{ i18n.ts.isSensitive }}</MkSwitch> <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> - <MkButton v-if="emoji" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkSwitch v-if="!isRequest" v-model="draft" :disabled="isRequest"> + {{ i18n.ts.draft }} + </MkSwitch> </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 :class="$style.footerButtons"> + <MkButton v-if="!isRequest" danger rounded style="margin: 0 auto;" @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkButton v-if="validation" primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> + <MkButton v-else rounded style="margin: 0 auto;"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> + </div> </div> </div> </MkModalWindow> @@ -76,6 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { DriveFile } from 'misskey-js/built/entities.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -90,6 +100,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ emoji?: any, + isRequest: boolean, }>(); let dialog = $ref(null); @@ -102,18 +113,56 @@ let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); let file = $ref<Misskey.entities.DriveFile>(); +let chooseFile: DriveFile|null = $ref(null); +let draft = $ref(props.emoji ? props.emoji.draft : false); +let isRequest = $ref(props.isRequest); +let url; 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 validation = computed(() => { + return name.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null; +}); const emit = defineEmits<{ (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, (ev: 'closed'): void }>(); +function ok() { + if (isRequest) { + if (chooseFile !== null && name.match(/^[a-zA-Z0-9_]+$/)) { + add(); + } + } else { + update(); + } +} + +async function add() { + const ret = await os.api('admin/emoji/add-draft', { + name: name, + category: category, + aliases: aliases.split(' '), + license: license === '' ? null : license, + fileId: chooseFile.id, + }); + + emit('done', { + updated: { + id: ret.id, + name, + category, + aliases: aliases.split(' '), + license: license === '' ? null : license, + draft: true, + }, + }); + + dialog.close(); +} async function changeImage(ev) { file = await selectFile(ev.currentTarget ?? ev.target, null); const candidate = file.name.replace(/\.(.+)$/, ''); @@ -137,13 +186,37 @@ 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.split(' ').filter(x => x !== ''), + aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''), license: license === '' ? null : license, + draft: draft, isSensitive, localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id), @@ -152,7 +225,7 @@ 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, @@ -168,7 +241,9 @@ async function done() { dialog.close(); } else { - const created = await os.apiWithDialog('admin/emoji/add', params); + const created = isRequest + ? await os.apiWithDialog('admin/emoji/add-draft', params) + : await os.apiWithDialog('admin/emoji/add', params); emit('done', { created: created, @@ -178,6 +253,13 @@ async function done() { } } +function chooseFileFrom(ev) { + selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { + chooseFile = files_[0]; + url = chooseFile.url; + }); +} + async function del() { const { canceled } = await os.confirm({ type: 'warning', @@ -240,4 +322,11 @@ async function del() { -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); } + +.footerButtons { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} </style> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 4f035777f3..981ea2f8c9 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -72,15 +72,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header> - <XSection v-for="category in groupedData" :key="`custom:${category}`" :initialShown="false" - :emojis="computed(() => customEmojis.filter(filterAvailable))" - :category="category" - @chosen="chosen" - /> + :emojis="computed(() => customEmojis.filter(emoji => !emoji.draft).filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))" + @chosen="chosen" + > + {{ category || i18n.ts.other }} + </XSection> </div> <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.emoji }}</header> @@ -196,7 +196,7 @@ watch(q, () => { const searchCustom = () => { const max = 100; - const emojis = customEmojis.value; + const emojis = customEmojis.value.filter(emoji => !emoji.draft); const matches = new Set<Misskey.entities.CustomEmoji>(); const exactMatch = emojis.find(emoji => emoji.name === newQ); diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 2d496e5fb9..d5c47056d6 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<span v-if="errored">:{{ customEmojiName }}:</span> +<span v-if="errored || isDraft">:{{ customEmojiName }}:</span> <img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/> </template> @@ -25,6 +25,7 @@ const props = defineProps<{ const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@'))); +const isDraft = computed(() => customEmojisMap.get(customEmojiName.value)?.draft ?? false); const rawUrl = computed(() => { if (props.url) { diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index f4ce0a096d..68835e0cbf 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -67,6 +67,7 @@ export const ROLE_POLICIES = [ 'inviteLimitCycle', 'inviteExpirationTime', 'canManageCustomEmojis', + 'canRequestCustomEmojis', 'canSearchNotes', 'canUseTranslator', 'canHideAds', diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 8ecd1bd2eb..2cf568023a 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { shallowRef, computed, markRaw, watch } from 'vue'; +import { shallowRef, computed, markRaw, triggerRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { api, apiGet } from '@/os.js'; import { useStream } from '@/stream.js'; @@ -11,6 +11,7 @@ import { get, set } from '@/scripts/idb-proxy.js'; const storageCache = await get('emojis'); export const customEmojis = shallowRef<Misskey.entities.CustomEmoji[]>(Array.isArray(storageCache) ? storageCache : []); +export const customEmojisNameMap = computed(() => new Map(customEmojis.value.map(item => [item.name, item]))); export const customEmojiCategories = computed<[ ...string[], null ]>(() => { const categories = new Set<string>(); for (const emoji of customEmojis.value) { @@ -34,16 +35,19 @@ const stream = useStream(); stream.on('emojiAdded', emojiData => { customEmojis.value = [emojiData.emoji, ...customEmojis.value]; + triggerRef(customEmojis); set('emojis', customEmojis.value); }); stream.on('emojiUpdated', emojiData => { customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.CustomEmoji ?? item); + triggerRef(customEmojis); set('emojis', customEmojis.value); }); stream.on('emojiDeleted', emojiData => { customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name)); + triggerRef(customEmojis); set('emojis', customEmojis.value); }); @@ -60,6 +64,7 @@ export async function fetchCustomEmojis(force = false) { } customEmojis.value = res.emojis; + triggerRef(customEmojis); set('emojis', res.emojis); set('lastEmojisFetchedAt', now); } diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index c8f56f1674..f76e5779f3 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -4,52 +4,96 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps"> - <MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton> +<MkStickyContainer> + <template #header> + <MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/> + </template> + <MkSpacer v-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> + <MkButton + v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link + to="/custom-emojis-manager" + > + {{ i18n.ts.manageCustomEmojis }} + </MkButton> + <MkButton + v-if="$i && (!$i.isModerator || !$i.policies.canManageCustomEmojis || $i.policies.canRequestCustomEmojis)" + primary style="margin-top: 8px;" @click="edit" + > + {{ i18n.ts.requestCustomEmojis }} + </MkButton> - <div class="query"> - <MkInput v-model="q" class="" :placeholder="i18n.ts.search"> - <template #prefix><i class="ti ti-search"></i></template> - </MkInput> + <div class="query" style="margin-top: 10px;"> + <MkInput v-model="q" class="" :placeholder="i18n.ts.search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> - <!-- たくさんあると邪魔 - <div class="tags"> - <span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> + <!-- たくさんあると邪魔 + <div class="tags"> + <span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> + </div> + --> </div> - --> - </div> - <MkFoldableSection v-if="searchEmojis"> - <template #header>{{ i18n.ts.searchResult }}</template> + <MkFoldableSection v-if="searchEmojis"> + <template #header>{{ i18n.ts.searchResult }}</template> + <div :class="$style.emojis"> + <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/> + </div> + </MkFoldableSection> + + <MkFoldableSection v-for="category in filteredCategories" v-once :key="category"> + <template #header>{{ category || i18n.ts.other }}</template> + <div :class="$style.emojis"> + <XEmoji + v-for="emoji in customEmojis.filter(e => e.category === category && !e.draft)" :key="emoji.name" + :emoji="emoji" :draft="emoji.draft" + /> + </div> + </MkFoldableSection> + </MkSpacer> + + <MkSpacer v-if="tab === 'draft'" :contentMax="1000" :marginMin="20"> <div :class="$style.emojis"> - <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/> + <XEmoji v-for="emoji in draftEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/> </div> - </MkFoldableSection> - - <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> - <template #header>{{ category || i18n.ts.other }}</template> - <div :class="$style.emojis"> - <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> - </div> - </MkFoldableSection> -</div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { watch, defineAsyncComponent, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import XEmoji from './emojis.emoji.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; +import { customEmojis, customEmojiCategories } from '@/custom-emojis.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os'; import { $i } from '@/account.js'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('emojis'); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'emojis', + title: i18n.ts.list, +}, { + key: 'draft', + title: i18n.ts.draftEmojis, +}]); +const filteredCategories = computed(() => { + return customEmojiCategories.value.filter((category: any) => { + return customEmojis.value.some((em: any) => em.category === category && !em.draft); + }); +}); +definePageMetadata(ref({})); -const customEmojiTags = getCustomEmojiTags(); let q = $ref(''); let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null); let selectedTags = $ref(new Set()); +const draftEmojis = customEmojis.value.filter(emoji => emoji.draft); function search() { if ((q === '' || q == null) && selectedTags.size === 0) { @@ -80,6 +124,16 @@ function toggleTag(tag) { } } +const edit = () => { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { + isRequest: true, + }, { + done: result => { + window.location.reload(); + }, + }, 'closed'); +}; + watch($$(q), () => { search(); }); @@ -87,6 +141,11 @@ watch($$(q), () => { watch($$(selectedTags), () => { search(); }, { deep: true }); + +definePageMetadata({ + title: i18n.ts.customEmojis, + icon: null, +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 333af93ef8..ad8c1081b1 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -42,7 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> <div :class="$style.items"> - <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`"> + <MkA + v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" + :class="$style.item" :to="`/instance-info/${instance.host}`" + > <MkInstanceCardMini :instance="instance"/> </MkA> </div> diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 9bdb58fa64..52bfdf018b 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -89,9 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> - <XEmojis/> - </MkSpacer> + <XEmojis v-else-if="tab === 'emojis'"/> <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> <XFederation/> </MkSpacer> diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue index 259354b3d0..7a1aa25423 100644 --- a/packages/frontend/src/pages/admin/instance-block.vue +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> - <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <template #header> + <XHeader :actions="headerActions" :tabs="headerTabs"/> + </template> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <MkTextarea v-if="tab === 'block'" v-model="blockedHosts"> diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 8015bb7a7f..1e7baac500 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -279,6 +279,66 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> + <template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> + <template #suffix> + <span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> + <template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> + <template #suffix> + <span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> + <template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> + <template #suffix> + <span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 001cf3490e..954df0bec8 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -95,6 +95,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> + <template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> + <template #suffix>{{ policies.canRequestCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canRequestCustomEmojis"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> <template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template> diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index e64c31b8e5..6e55ffb945 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -10,64 +10,13 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="900"> <div class="ogwlenmc"> <div v-if="tab === 'local'" class="local"> - <MkInput v-model="query" :debounce="true" type="search"> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.search }}</template> - </MkInput> - <MkSwitch v-model="selectMode" style="margin: 8px 0;"> - <template #label>Select mode</template> - </MkSwitch> - <div v-if="selectMode" class="_buttons"> - <MkButton inline @click="selectAll">Select all</MkButton> - <MkButton inline @click="setCategoryBulk">Set category</MkButton> - <MkButton inline @click="setTagBulk">Set tag</MkButton> - <MkButton inline @click="setisSensitiveBulk">Set isSensitive</MkButton> - <MkButton inline @click="setlocalOnlyBulk">Set localOnly</MkButton> - <MkButton inline @click="addTagBulk">Add tag</MkButton> - <MkButton inline @click="removeTagBulk">Remove tag</MkButton> - <MkButton inline @click="setLicenseBulk">Set License</MkButton> - <MkButton inline danger @click="delBulk">Delete</MkButton> - </div> - <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> - <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="ldhfsamy"> - <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> - <img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.category }}</div> - </div> - </button> - </div> - </template> - </MkPagination> + <MkCustomEmojiEditLocal/> + </div> + <div v-if="tab === 'draft'" class="draft"> + <MkCustomEmojiEditDraft/> </div> - <div v-else-if="tab === 'remote'" class="remote"> - <FormSplit> - <MkInput v-model="queryRemote" :debounce="true" type="search"> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.search }}</template> - </MkInput> - <MkInput v-model="host" :debounce="true"> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - </FormSplit> - <MkPagination :pagination="remotePagination"> - <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="ldhfsamy"> - <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> - <img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.host }}</div> - </div> - </div> - </div> - </template> - </MkPagination> + <MkCustomEmojiEditRemote/> </div> </div> </MkSpacer> @@ -76,105 +25,30 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; -import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkPagination from '@/components/MkPagination.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import FormSplit from '@/components/form/split.vue'; -import { selectFile, selectFiles } from '@/scripts/select-file.js'; -import * as os from '@/os.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import {switch1, swtch} from "@/os.js"; +import { computed, defineAsyncComponent, ref } from 'vue'; +import MkCustomEmojiEditDraft from '@/components/MkCustomEmojiEditDraft.vue'; +import MkCustomEmojiEditLocal from '@/components/MkCustomEmojiEditLocal.vue'; +import MkCustomEmojiEditRemote from '@/components/MkCustomEmojiEditRemote.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); - -const tab = ref('local'); -const query = ref(null); -const queryRemote = ref(null); -const host = ref(null); -const selectMode = ref(false); -const selectedEmojis = ref<string[]>([]); - -const pagination = { - endpoint: 'admin/emoji/list' as const, - limit: 30, - params: computed(() => ({ - query: (query.value && query.value !== '') ? query.value : null, - })), -}; - -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); - } -}; - -const toggleSelect = (emoji) => { - if (selectedEmojis.value.includes(emoji.id)) { - selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); - } else { - selectedEmojis.value.push(emoji.id); - } -}; +const tab = ref('draft'); const add = async (ev: MouseEvent) => { - os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { }, { done: result => { - if (result.created) { - emojisPaginationComponent.value.prepend(result.created); - } + //TODO: emitにして追加を反映 + // if (result.created) { + // emojisPaginationComponent.value.prepend(result.created); + // emojisPaginationComponent.value.reload(); + // } }, }, 'closed'); }; -const edit = (emoji) => { - os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { - emoji: emoji, - }, { - done: result => { - if (result.updated) { - emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ - ...oldEmoji, - ...result.updated, - })); - } else if (result.deleted) { - emojisPaginationComponent.value.removeItem(emoji.id); - } - }, - }, 'closed'); -}; - -const im = (emoji) => { - os.apiWithDialog('admin/emoji/copy', { - emojiId: emoji.id, - }); -}; - -const remoteMenu = (emoji, ev: MouseEvent) => { - os.popupMenu([{ - type: 'label', - text: ':' + emoji.name + ':', - }, { - text: i18n.ts.import, - icon: 'ti ti-plus', - action: () => { im(emoji); }, - }], ev.currentTarget ?? ev.target); -}; - const menu = (ev: MouseEvent) => { os.popupMenu([{ icon: 'ti ti-download', @@ -217,101 +91,6 @@ const menu = (ev: MouseEvent) => { }], ev.currentTarget ?? ev.target); }; -const setCategoryBulk = async () => { - const { canceled, result } = await os.inputText({ - title: 'Category', - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/set-category-bulk', { - ids: selectedEmojis.value, - category: result, - }); - emojisPaginationComponent.value.reload(); -}; - -const setLicenseBulk = async () => { - const { canceled, result } = await os.inputText({ - title: 'License', - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/set-license-bulk', { - ids: selectedEmojis.value, - license: result, - }); - emojisPaginationComponent.value.reload(); -}; - -const addTagBulk = async () => { - const { canceled, result } = await os.inputText({ - title: 'Tag', - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/add-aliases-bulk', { - ids: selectedEmojis.value, - aliases: result.split(' '), - }); - emojisPaginationComponent.value.reload(); -}; - -const removeTagBulk = async () => { - const { canceled, result } = await os.inputText({ - title: 'Tag', - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { - ids: selectedEmojis.value, - aliases: result.split(' '), - }); - emojisPaginationComponent.value.reload(); -}; - -const setTagBulk = async () => { - const { canceled, result } = await os.inputText({ - title: 'Tag', - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/set-aliases-bulk', { - ids: selectedEmojis.value, - aliases: result.split(' '), - }); - emojisPaginationComponent.value.reload(); -}; -const setisSensitiveBulk = async () => { - const { canceled, result } = await os.switch1({ - title: 'isSensitive', - type: "mksw" - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/set-issensitive-bulk', { - ids: selectedEmojis.value, - isSensitive: result - }); - emojisPaginationComponent.value.reload(); -}; -const setlocalOnlyBulk = async () => { - const { canceled, result } = await os.switch1({ - title: 'localOnly', - type: "mksw" - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/set-localonly-bulk', { - ids: selectedEmojis.value, - localOnly: result - }); - emojisPaginationComponent.value.reload(); -}; -const delBulk = async () => { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.deleteConfirm, - }); - if (canceled) return; - await os.apiWithDialog('admin/emoji/delete-bulk', { - ids: selectedEmojis.value, - }); - emojisPaginationComponent.value.reload(); -}; - const headerActions = $computed(() => [{ asFullButton: true, icon: 'ti ti-plus', @@ -323,6 +102,9 @@ const headerActions = $computed(() => [{ }]); const headerTabs = $computed(() => [{ + key: 'draft', + title: i18n.ts.draftEmojis, +}, { key: 'local', title: i18n.ts.local, }, { @@ -337,103 +119,4 @@ definePageMetadata(computed(() => ({ </script> <style lang="scss" scoped> -.ogwlenmc { - > .local { - .empty { - margin: var(--margin); - } - - .ldhfsamy { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - grid-gap: 12px; - margin: var(--margin) 0; - - > .emoji { - display: flex; - align-items: center; - padding: 11px; - text-align: left; - border: solid 1px var(--panel); - - &:hover { - border-color: var(--inputBorderHover); - } - - &.selected { - border-color: var(--accent); - } - - > .img { - width: 42px; - height: 42px; - } - - > .body { - padding: 0 0 0 8px; - white-space: nowrap; - overflow: hidden; - - > .name { - text-overflow: ellipsis; - overflow: hidden; - } - - > .info { - opacity: 0.5; - text-overflow: ellipsis; - overflow: hidden; - } - } - } - } - } - - > .remote { - .empty { - margin: var(--margin); - } - - .ldhfsamy { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - grid-gap: 12px; - margin: var(--margin) 0; - - > .emoji { - display: flex; - align-items: center; - padding: 12px; - text-align: left; - - &:hover { - color: var(--accent); - } - - > .img { - width: 32px; - height: 32px; - } - - > .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; - } - } - } - } - } -} </style> diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index 9aaa7890a9..6cd7b1bbb0 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -4,10 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<button class="_button" :class="$style.root" @click="menu"> +<button v-if="emoji.draft" class="zuvgdzyu _button emoji-draft" @click="menu"> + <img style="max-height: 64px;object-fit: contain;" :src="emoji.url" class="img" loading="lazy"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name + ' (draft)' }}</div> + <div class="info">{{ emoji.aliases.join(' ') }}</div> + </div> +</button> +<button v-else class="_button" :class="$style.root" @click="menu"> <img :src="emoji.url" :class="$style.img" loading="lazy"/> <div :class="$style.body"> - <div :class="$style.name" class="_monospace">{{ emoji.name }}</div> + <div :class="$style.name">{{ emoji.name }}</div> <div :class="$style.info">{{ emoji.aliases.join(' ') }}</div> </div> </button> @@ -25,6 +32,7 @@ const props = defineProps<{ aliases: string[]; category: string; url: string; + draft: boolean; }; }>(); @@ -91,4 +99,12 @@ function menu(ev) { text-overflow: ellipsis; overflow: hidden; } + +.emoji-draft { + --c: rgb(255 196 0 / 15%);; + background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%); + background-size: 16px 16px; + max-width: 64px; + width: 100%; +} </style> diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 895f34689b..4ad2145787 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -284,6 +284,7 @@ type CustomEmoji = { url: string; category: string; aliases: string[]; + draft: boolean; }; // @public (undocumented) diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 315929655d..84fb5c062e 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -312,6 +312,7 @@ export type CustomEmoji = { url: string; category: string; aliases: string[]; + draft: boolean; }; export type LiteInstanceMetadata = {