Merge remote-tracking branch 'misskey-mattyatea/emoji-request' into develop

# Conflicts:
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/backend/src/core/UserFollowingService.ts
#	packages/frontend/src/components/MkEmojiPicker.vue
#	packages/frontend/src/pages/custom-emojis-manager.vue
This commit is contained in:
mattyatea 2023-10-18 22:37:42 +09:00
commit c7c70c1c30
36 changed files with 1089 additions and 421 deletions

View File

@ -35,6 +35,10 @@
## 2023.10.1 ## 2023.10.1
### General ### General
- Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に - Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に
- Feat: 絵文字申請を追加
- これによって絵文字リクエストロールが追加されました。
- カスタム絵文字管理の画面に 申請されている絵文字 タブが追加されました。
- カスタム絵文字のリクエストボタンが実装されました。
### Client ### Client
- Fix: 絵文字ピッカーで横に長いカスタム絵文字が見切れる問題を修正 - Fix: 絵文字ピッカーで横に長いカスタム絵文字が見切れる問題を修正
@ -78,6 +82,7 @@
- Enhance: 動画再生時のデフォルトボリュームを30%に - Enhance: 動画再生時のデフォルトボリュームを30%に
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
### Server ### Server
- Enhance: drive/files/attached-notes がページネーションに対応しました - Enhance: drive/files/attached-notes がページネーションに対応しました
- Enhance: タイムライン取得時のパフォーマンスを大幅に向上 - Enhance: タイムライン取得時のパフォーマンスを大幅に向上

10
locales/index.d.ts vendored
View File

@ -272,6 +272,7 @@ export interface Locale {
"removed": string; "removed": string;
"removeAreYouSure": string; "removeAreYouSure": string;
"deleteAreYouSure": string; "deleteAreYouSure": string;
"undraftAreYouSure": string;
"resetAreYouSure": string; "resetAreYouSure": string;
"saved": string; "saved": string;
"messaging": string; "messaging": string;
@ -676,6 +677,8 @@ export interface Locale {
"regenerateLoginToken": string; "regenerateLoginToken": string;
"regenerateLoginTokenDescription": string; "regenerateLoginTokenDescription": string;
"setMultipleBySeparatingWithSpace": string; "setMultipleBySeparatingWithSpace": string;
"emojiNameValidation": string;
"isSensitive": string;
"fileIdOrUrl": string; "fileIdOrUrl": string;
"behavior": string; "behavior": string;
"sample": string; "sample": string;
@ -849,8 +852,11 @@ export interface Locale {
"low": string; "low": string;
"GamingSpeedChange": string; "GamingSpeedChange": string;
"GamingSpeedChangeInfo": string; "GamingSpeedChangeInfo": string;
"list": string;
"emailNotConfiguredWarning": string; "emailNotConfiguredWarning": string;
"ratio": string; "ratio": string;
"newEmojis": string;
"draftEmojis": string;
"showVisibilityColor": string; "showVisibilityColor": string;
"previewNoteText": string; "previewNoteText": string;
"customCss": string; "customCss": string;
@ -1004,6 +1010,7 @@ export interface Locale {
"unassign": string; "unassign": string;
"color": string; "color": string;
"manageCustomEmojis": string; "manageCustomEmojis": string;
"requestCustomEmojis": string;
"youCannotCreateAnymore": string; "youCannotCreateAnymore": string;
"cannotPerformTemporary": string; "cannotPerformTemporary": string;
"cannotPerformTemporaryDescription": string; "cannotPerformTemporaryDescription": string;
@ -1046,6 +1053,8 @@ export interface Locale {
"sensitiveWordsDescription2": string; "sensitiveWordsDescription2": string;
"notesSearchNotAvailable": string; "notesSearchNotAvailable": string;
"license": string; "license": string;
"draft": string;
"undrafted": string;
"unfavoriteConfirm": string; "unfavoriteConfirm": string;
"myClips": string; "myClips": string;
"drivecleaner": string; "drivecleaner": string;
@ -1585,6 +1594,7 @@ export interface Locale {
"inviteLimitCycle": string; "inviteLimitCycle": string;
"inviteExpirationTime": string; "inviteExpirationTime": string;
"canManageCustomEmojis": string; "canManageCustomEmojis": string;
"canRequestCustomEmojis": string;
"driveCapacity": string; "driveCapacity": string;
"alwaysMarkNsfw": string; "alwaysMarkNsfw": string;
"pinMax": string; "pinMax": string;

View File

@ -269,6 +269,7 @@ remove: "削除"
removed: "削除しました" removed: "削除しました"
removeAreYouSure: "「{x}」を削除しますか?" removeAreYouSure: "「{x}」を削除しますか?"
deleteAreYouSure: "「{x}」を削除しますか?" deleteAreYouSure: "「{x}」を削除しますか?"
undraftAreYouSure: "「{x}」をドラフト解除しますか?"
resetAreYouSure: "リセットしますか?" resetAreYouSure: "リセットしますか?"
saved: "保存しました" saved: "保存しました"
messaging: "チャット" messaging: "チャット"
@ -673,6 +674,8 @@ other: "その他"
regenerateLoginToken: "ログイントークンを再生成" regenerateLoginToken: "ログイントークンを再生成"
regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。" regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。"
setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。" setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。"
emojiNameValidation: "名前には英数字と_が利用できます。"
isSensitive: "センシティブ"
fileIdOrUrl: "ファイルIDまたはURL" fileIdOrUrl: "ファイルIDまたはURL"
behavior: "動作" behavior: "動作"
sample: "サンプル" sample: "サンプル"
@ -846,9 +849,12 @@ middle: "中"
low: "低" low: "低"
GamingSpeedChange: "ゲーミングの光るスピードの調整" GamingSpeedChange: "ゲーミングの光るスピードの調整"
GamingSpeedChangeInfo: "左にすれば早くなる、右にすれば遅くなる。それだけ。" GamingSpeedChangeInfo: "左にすれば早くなる、右にすれば遅くなる。それだけ。"
list: "一覧"
emailNotConfiguredWarning: "メールアドレスの設定がされていません。" emailNotConfiguredWarning: "メールアドレスの設定がされていません。"
ratio: "比率" ratio: "比率"
showVisibilityColor: "ノートの公開範囲を色付けする" showVisibilityColor: "ノートの公開範囲を色付けする"
newEmojis: "新しい絵文字"
draftEmojis: "申請されている絵文字"
previewNoteText: "本文をプレビュー" previewNoteText: "本文をプレビュー"
customCss: "カスタムCSS" customCss: "カスタムCSS"
customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。"
@ -1001,6 +1007,7 @@ assign: "アサイン"
unassign: "アサインを解除" unassign: "アサインを解除"
color: "色" color: "色"
manageCustomEmojis: "カスタム絵文字の管理" manageCustomEmojis: "カスタム絵文字の管理"
requestCustomEmojis: "カスタム絵文字のリクエスト"
youCannotCreateAnymore: "これ以上作成することはできません。" youCannotCreateAnymore: "これ以上作成することはできません。"
cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporary: "一時的に利用できません"
cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
@ -1043,6 +1050,8 @@ sensitiveWordsDescription: "設定したワードが含まれるノートの公
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
notesSearchNotAvailable: "ノート検索は利用できません。" notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス" license: "ライセンス"
draft: "ドラフト"
undrafted: "ドラフト解除"
unfavoriteConfirm: "お気に入り解除しますか?" unfavoriteConfirm: "お気に入り解除しますか?"
myClips: "自分のクリップ" myClips: "自分のクリップ"
drivecleaner: "ドライブクリーナー" drivecleaner: "ドライブクリーナー"
@ -1506,6 +1515,7 @@ _role:
inviteLimitCycle: "招待コードの発行間隔" inviteLimitCycle: "招待コードの発行間隔"
inviteExpirationTime: "招待コードの有効期限" inviteExpirationTime: "招待コードの有効期限"
canManageCustomEmojis: "カスタム絵文字の管理" canManageCustomEmojis: "カスタム絵文字の管理"
canRequestCustomEmojis: "カスタム絵文字のリクエスト"
driveCapacity: "ドライブ容量" driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与" alwaysMarkNsfw: "ファイルにNSFWを常に付与"
pinMax: "ノートのピン留めの最大数" pinMax: "ノートのピン留めの最大数"

View File

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

View File

@ -16,10 +16,8 @@ import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/types.js'; import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
@ -66,6 +64,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license: string | null; license: string | null;
isSensitive: boolean; isSensitive: boolean;
localOnly: boolean; localOnly: boolean;
draft: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][];
}, moderator?: MiUser): Promise<MiEmoji> { }, moderator?: MiUser): Promise<MiEmoji> {
const emoji = await this.emojisRepository.insert({ const emoji = await this.emojisRepository.insert({
@ -82,6 +81,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
isSensitive: data.isSensitive, isSensitive: data.isSensitive,
localOnly: data.localOnly, localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: data.draft,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) { if (data.host == null) {
@ -111,6 +111,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license?: string | null; license?: string | null;
isSensitive?: boolean; isSensitive?: boolean;
localOnly?: boolean; localOnly?: boolean;
draft: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
}, moderator?: MiUser): Promise<void> { }, moderator?: MiUser): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
@ -125,6 +126,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
license: data.license, license: data.license,
isSensitive: data.isSensitive, isSensitive: data.isSensitive,
localOnly: data.localOnly, localOnly: data.localOnly,
draft: data.draft,
originalUrl: data.driveFile != null ? data.driveFile.url : undefined, originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? 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, type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,

View File

@ -33,6 +33,7 @@ export type RolePolicies = {
inviteLimitCycle: number; inviteLimitCycle: number;
inviteExpirationTime: number; inviteExpirationTime: number;
canManageCustomEmojis: boolean; canManageCustomEmojis: boolean;
canRequestCustomEmojis: boolean;
canSearchNotes: boolean; canSearchNotes: boolean;
canUseTranslator: boolean; canUseTranslator: boolean;
canHideAds: boolean; canHideAds: boolean;
@ -59,6 +60,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteLimitCycle: 60 * 24 * 7, inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0, inviteExpirationTime: 0,
canManageCustomEmojis: false, canManageCustomEmojis: false,
canRequestCustomEmojis: false,
canSearchNotes: false, canSearchNotes: false,
canUseTranslator: true, canUseTranslator: true,
canHideAds: false, canHideAds: false,
@ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown {
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), 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)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),

View File

@ -19,7 +19,13 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { WebhookService } from '@/core/WebhookService.js'; import { WebhookService } from '@/core/WebhookService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.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 { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -53,25 +59,18 @@ export class UserFollowingService implements OnModuleInit {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository) @Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository, private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
private cacheService: CacheService, private cacheService: CacheService,
private utilityService: UtilityService, private utilityService: UtilityService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
@ -197,10 +196,18 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
private async insertFollowingDoc( private async insertFollowingDoc(
followee: { 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: { 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, silent = false,
withReplies?: boolean, withReplies?: boolean,
@ -247,8 +254,7 @@ export class UserFollowingService implements OnModuleInit {
}); });
// 通知を作成 // 通知を作成
this.notificationService.createNotification(follower.id, 'followRequestAccepted', { this.notificationService.createNotification(follower.id, 'followRequestAccepted', {}, followee.id);
}, followee.id);
} }
if (alreadyFollowed) return; if (alreadyFollowed) return;
@ -322,18 +328,25 @@ export class UserFollowingService implements OnModuleInit {
}); });
// 通知を作成 // 通知を作成
this.notificationService.createNotification(followee.id, 'follow', { this.notificationService.createNotification(followee.id, 'follow', {}, follower.id);
}, follower.id);
} }
} }
@bindThis @bindThis
public async unfollow( public async unfollow(
follower: { 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: { 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, silent = false,
): Promise<void> { ): Promise<void> {
@ -464,10 +477,18 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async createFollowRequest( public async createFollowRequest(
follower: { 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: { 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, requestId?: string,
withReplies?: boolean, withReplies?: boolean,
@ -560,7 +581,11 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async acceptFollowRequest( public async acceptFollowRequest(
followee: { 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, follower: MiUser,
): Promise<void> { ): Promise<void> {
@ -588,7 +613,11 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async acceptAllFollowRequests( public async acceptAllFollowRequests(
user: { 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> { ): Promise<void> {
const requests = await this.followRequestsRepository.findBy({ const requests = await this.followRequestsRepository.findBy({

View File

@ -33,6 +33,7 @@ export class EmojiEntityService {
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
isSensitive: emoji.isSensitive ? true : undefined, isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
draft: emoji.draft,
}; };
} }
@ -61,6 +62,7 @@ export class EmojiEntityService {
isSensitive: emoji.isSensitive, isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly, localOnly: emoji.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: emoji.draft,
}; };
} }

View File

@ -81,4 +81,10 @@ export class MiEmoji {
array: true, length: 128, default: '{}', array: true, length: 128, default: '{}',
}) })
public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; public roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
@Column('boolean', {
default: false,
nullable: false,
})
public draft: boolean;
} }

View File

@ -40,6 +40,10 @@ export const packedEmojiSimpleSchema = {
format: 'id', format: 'id',
}, },
}, },
draft: {
type: 'boolean',
optional: false, nullable: true,
},
}, },
} as const; } as const;
@ -81,6 +85,10 @@ export const packedEmojiDetailedSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
draft: {
type: 'boolean',
optional: false, nullable: true,
},
isSensitive: { isSensitive: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService {
isSensitive: emojiInfo.isSensitive, isSensitive: emojiInfo.isSensitive,
localOnly: emojiInfo.localOnly, localOnly: emojiInfo.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: [], roleIdsThatCanBeUsedThisEmojiAsReaction: [],
draft: false,
}); });
} }

View File

@ -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_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_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_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_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_deleteBulk from './endpoints/admin/emoji/delete-bulk.js';
import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.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_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_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_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_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_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 }; 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_setlocalOnlyBulk,
$admin_emoji_setisSensitiveBulk, $admin_emoji_setisSensitiveBulk,
$admin_emoji_add, $admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_copy, $admin_emoji_copy,
$admin_emoji_deleteBulk, $admin_emoji_deleteBulk,
$admin_emoji_delete, $admin_emoji_delete,
@ -1092,6 +1095,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_drive_showFile, $admin_drive_showFile,
$admin_emoji_addAliasesBulk, $admin_emoji_addAliasesBulk,
$admin_emoji_add, $admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_copy, $admin_emoji_copy,
$admin_emoji_deleteBulk, $admin_emoji_deleteBulk,
$admin_emoji_delete, $admin_emoji_delete,

View File

@ -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_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_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_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_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_deleteBulk from './endpoints/admin/emoji/delete-bulk.js';
import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.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/drive/show-file', ep___admin_drive_showFile],
['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk],
['admin/emoji/add', ep___admin_emoji_add], ['admin/emoji/add', ep___admin_emoji_add],
['admin/emoji/add-draft', ep___admin_emoji_addDraft],
['admin/emoji/copy', ep___admin_emoji_copy], ['admin/emoji/copy', ep___admin_emoji_copy],
['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], ['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk],
['admin/emoji/delete', ep___admin_emoji_delete], ['admin/emoji/delete', ep___admin_emoji_delete],

View File

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

View File

@ -41,17 +41,21 @@ export const paramDef = {
nullable: true, nullable: true,
description: 'Use `null` to reset the category.', description: 'Use `null` to reset the category.',
}, },
aliases: { type: 'array', items: { aliases: {
type: 'array', items: {
type: 'string', type: 'string',
} }, },
},
license: { type: 'string', nullable: true }, license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' }, isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' }, localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { roleIdsThatCanBeUsedThisEmojiAsReaction: {
type: 'array', items: {
type: 'string', type: 'string',
} },
}, },
required: ['name', 'fileId'], },
},
required: ['name', 'fileId', 'draft'],
} as const; } as const;
// TODO: ロジックをサービスに切り出す // TODO: ロジックをサービスに切り出す
@ -61,13 +65,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
private emojiEntityService: EmojiEntityService, private emojiEntityService: EmojiEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.duplicateName); 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, license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false, isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false, localOnly: ps.localOnly ?? false,
draft: false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
}, me); }, me);

View File

@ -64,6 +64,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
query: { type: 'string', nullable: true, default: null }, query: { type: 'string', nullable: true, default: null },
draft: { type: 'boolean', nullable: true, default: null },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { 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[]; let emojis: MiEmoji[];
if (ps.draft !== null) {
if (ps.draft) {
q.andWhere('emoji.draft = TRUE');
} else {
q.andWhere('emoji.draft = FALSE');
}
}
if (ps.query) { if (ps.query) {
//q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
//const emojis = await q.limit(ps.limit).getMany(); //const emojis = await q.limit(ps.limit).getMany();

View File

@ -60,8 +60,9 @@ export const paramDef = {
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string', type: 'string',
} }, } },
draft: { type: 'boolean' },
}, },
required: ['id', 'name', 'aliases'], required: ['id', 'name', 'draft', 'aliases'],
} as const; } as const;
@Injectable() @Injectable()
@ -97,6 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isSensitive: ps.isSensitive, isSensitive: ps.isSensitive,
localOnly: ps.localOnly, localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: ps.draft,
}, me); }, me);
}); });
} }

View File

@ -96,6 +96,10 @@ const emojiDb = computed(() => {
const customEmojiDB: EmojiDef[] = []; const customEmojiDB: EmojiDef[] = [];
for (const x of customEmojis.value) { for (const x of customEmojis.value) {
if (x.draft) {
continue;
}
customEmojiDB.push({ customEmojiDB.push({
name: x.name, name: x.name,
emoji: `:${x.name}:`, emoji: `:${x.name}:`,

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -7,10 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="400" :width="400"
:withOkButton="false "
@close="dialog.close()" @close="dialog.close()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template v-if="emoji" #header>:{{ emoji.name }}:</template> <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> <template v-else #header>New emoji</template>
<div> <div>
@ -33,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
<MkInput v-model="name" pattern="[a-z0-9_]"> <MkInput v-model="name" pattern="[a-z0-9_]">
<template #label>{{ i18n.ts.name }}</template> <template #label>{{ i18n.ts.name }}</template>
<template #caption>{{ i18n.ts.emojiNameValidation }}</template>
</MkInput> </MkInput>
<MkInput v-model="category" :datalist="customEmojiCategories"> <MkInput v-model="category" :datalist="customEmojiCategories">
<template #label>{{ i18n.ts.category }}</template> <template #label>{{ i18n.ts.category }}</template>
@ -44,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="license"> <MkInput v-model="license">
<template #label>{{ i18n.ts.license }}</template> <template #label>{{ i18n.ts.license }}</template>
</MkInput> </MkInput>
<MkFolder> <MkFolder v-if="!isRequest">
<template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template> <template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template>
<template #suffix>{{ rolesThatCanBeUsedThisEmojiAsReaction.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisEmojiAsReaction.length }}</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> <MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo>
</div> </div>
</MkFolder> </MkFolder>
<MkSwitch v-model="isSensitive">isSensitive</MkSwitch> <MkSwitch v-model="isSensitive">{{ i18n.ts.isSensitive }}</MkSwitch>
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</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> </div>
</MkSpacer> </MkSpacer>
<div :class="$style.footer"> <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>
</div> </div>
</MkModalWindow> </MkModalWindow>
@ -76,6 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { DriveFile } from 'misskey-js/built/entities.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -90,6 +100,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{ const props = defineProps<{
emoji?: any, emoji?: any,
isRequest: boolean,
}>(); }>();
let dialog = $ref(null); 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 roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref<Misskey.entities.DriveFile>(); 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 () => { watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true }); }, { immediate: true });
const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); 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<{ const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): 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) { async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null); file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, ''); const candidate = file.name.replace(/\.(.+)$/, '');
@ -137,13 +186,37 @@ async function addRole() {
async function removeRole(role, ev) { async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); 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() { async function done() {
const params = { const params = {
name, name,
category: category === '' ? null : category, category: category === '' ? null : category,
aliases: aliases.split(' ').filter(x => x !== ''), aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''),
license: license === '' ? null : license, license: license === '' ? null : license,
draft: draft,
isSensitive, isSensitive,
localOnly, localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id), roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
@ -152,7 +225,7 @@ async function done() {
if (file) { if (file) {
params.fileId = file.id; params.fileId = file.id;
} }
console.log(props.emoji);
if (props.emoji) { if (props.emoji) {
await os.apiWithDialog('admin/emoji/update', { await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id, id: props.emoji.id,
@ -168,7 +241,9 @@ async function done() {
dialog.close(); dialog.close();
} else { } 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', { emit('done', {
created: created, 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() { async function del() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
@ -240,4 +322,11 @@ async function del() {
-webkit-backdrop-filter: var(--blur, blur(15px)); -webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px));
} }
.footerButtons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style> </style>

View File

@ -72,15 +72,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-once class="group"> <div v-once class="group">
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
<XSection <XSection
v-for="category in groupedData" v-for="category in groupedData"
:key="`custom:${category}`" :key="`custom:${category}`"
:initialShown="false" :initialShown="false"
:emojis="computed(() => customEmojis.filter(filterAvailable))" :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}:`))"
:category="category"
@chosen="chosen" @chosen="chosen"
/> >
{{ category || i18n.ts.other }}
</XSection>
</div> </div>
<div v-once class="group"> <div v-once class="group">
<header class="_acrylic">{{ i18n.ts.emoji }}</header> <header class="_acrylic">{{ i18n.ts.emoji }}</header>
@ -196,7 +196,7 @@ watch(q, () => {
const searchCustom = () => { const searchCustom = () => {
const max = 100; const max = 100;
const emojis = customEmojis.value; const emojis = customEmojis.value.filter(emoji => !emoji.draft);
const matches = new Set<Misskey.entities.CustomEmoji>(); const matches = new Set<Misskey.entities.CustomEmoji>();
const exactMatch = emojis.find(emoji => emoji.name === newQ); const exactMatch = emojis.find(emoji => emoji.name === newQ);

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <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"/> <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> </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 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 isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
const isDraft = computed(() => customEmojisMap.get(customEmojiName.value)?.draft ?? false);
const rawUrl = computed(() => { const rawUrl = computed(() => {
if (props.url) { if (props.url) {

View File

@ -67,6 +67,7 @@ export const ROLE_POLICIES = [
'inviteLimitCycle', 'inviteLimitCycle',
'inviteExpirationTime', 'inviteExpirationTime',
'canManageCustomEmojis', 'canManageCustomEmojis',
'canRequestCustomEmojis',
'canSearchNotes', 'canSearchNotes',
'canUseTranslator', 'canUseTranslator',
'canHideAds', 'canHideAds',

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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 * as Misskey from 'misskey-js';
import { api, apiGet } from '@/os.js'; import { api, apiGet } from '@/os.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
@ -11,6 +11,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
const storageCache = await get('emojis'); const storageCache = await get('emojis');
export const customEmojis = shallowRef<Misskey.entities.CustomEmoji[]>(Array.isArray(storageCache) ? storageCache : []); 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 ]>(() => { export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
const categories = new Set<string>(); const categories = new Set<string>();
for (const emoji of customEmojis.value) { for (const emoji of customEmojis.value) {
@ -34,16 +35,19 @@ const stream = useStream();
stream.on('emojiAdded', emojiData => { stream.on('emojiAdded', emojiData => {
customEmojis.value = [emojiData.emoji, ...customEmojis.value]; customEmojis.value = [emojiData.emoji, ...customEmojis.value];
triggerRef(customEmojis);
set('emojis', customEmojis.value); set('emojis', customEmojis.value);
}); });
stream.on('emojiUpdated', emojiData => { stream.on('emojiUpdated', emojiData => {
customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.CustomEmoji ?? item); 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); set('emojis', customEmojis.value);
}); });
stream.on('emojiDeleted', emojiData => { stream.on('emojiDeleted', emojiData => {
customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name)); customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name));
triggerRef(customEmojis);
set('emojis', customEmojis.value); set('emojis', customEmojis.value);
}); });
@ -60,6 +64,7 @@ export async function fetchCustomEmojis(force = false) {
} }
customEmojis.value = res.emojis; customEmojis.value = res.emojis;
triggerRef(customEmojis);
set('emojis', res.emojis); set('emojis', res.emojis);
set('lastEmojisFetchedAt', now); set('lastEmojisFetchedAt', now);
} }

View File

@ -4,10 +4,25 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div class="_gaps"> <MkStickyContainer>
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton> <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"> <div class="query" style="margin-top: 10px;">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search"> <MkInput v-model="q" class="" :placeholder="i18n.ts.search">
<template #prefix><i class="ti ti-search"></i></template> <template #prefix><i class="ti ti-search"></i></template>
</MkInput> </MkInput>
@ -22,34 +37,63 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection v-if="searchEmojis"> <MkFoldableSection v-if="searchEmojis">
<template #header>{{ i18n.ts.searchResult }}</template> <template #header>{{ i18n.ts.searchResult }}</template>
<div :class="$style.emojis"> <div :class="$style.emojis">
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/> <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
</div> </div>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> <MkFoldableSection v-for="category in filteredCategories" v-once :key="category">
<template #header>{{ category || i18n.ts.other }}</template> <template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.emojis"> <div :class="$style.emojis">
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> <XEmoji
v-for="emoji in customEmojis.filter(e => e.category === category && !e.draft)" :key="emoji.name"
:emoji="emoji" :draft="emoji.draft"
/>
</div> </div>
</MkFoldableSection> </MkFoldableSection>
</div> </MkSpacer>
<MkSpacer v-if="tab === 'draft'" :contentMax="1000" :marginMin="20">
<div :class="$style.emojis">
<XEmoji v-for="emoji in draftEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from 'vue'; import { watch, defineAsyncComponent, ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XEmoji from './emojis.emoji.vue'; import XEmoji from './emojis.emoji.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.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 { i18n } from '@/i18n.js';
import * as os from '@/os';
import { $i } from '@/account.js'; 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 q = $ref('');
let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null); let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null);
let selectedTags = $ref(new Set()); let selectedTags = $ref(new Set());
const draftEmojis = customEmojis.value.filter(emoji => emoji.draft);
function search() { function search() {
if ((q === '' || q == null) && selectedTags.size === 0) { 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), () => { watch($$(q), () => {
search(); search();
}); });
@ -87,6 +141,11 @@ watch($$(q), () => {
watch($$(selectedTags), () => { watch($$(selectedTags), () => {
search(); search();
}, { deep: true }); }, { deep: true });
definePageMetadata({
title: i18n.ts.customEmojis,
icon: null,
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -42,7 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
<div :class="$style.items"> <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"/> <MkInstanceCardMini :instance="instance"/>
</MkA> </MkA>
</div> </div>

View File

@ -89,9 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection> </FormSection>
</div> </div>
</MkSpacer> </MkSpacer>
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> <XEmojis v-else-if="tab === 'emojis'"/>
<XEmojis/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
<XFederation/> <XFederation/>
</MkSpacer> </MkSpacer>

View File

@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <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"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init"> <FormSuspense :p="init">
<MkTextarea v-if="tab === 'block'" v-model="blockedHosts"> <MkTextarea v-if="tab === 'block'" v-model="blockedHosts">

View File

@ -279,6 +279,66 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </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'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix> <template #suffix>

View File

@ -95,6 +95,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </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'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template> <template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@ -10,64 +10,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="900"> <MkSpacer :contentMax="900">
<div class="ogwlenmc"> <div class="ogwlenmc">
<div v-if="tab === 'local'" class="local"> <div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search"> <MkCustomEmojiEditLocal/>
<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> </div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> <div v-if="tab === 'draft'" class="draft">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <MkCustomEmojiEditDraft/>
<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> </div>
</button>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'remote'" class="remote"> <div v-else-if="tab === 'remote'" class="remote">
<FormSplit> <MkCustomEmojiEditRemote/>
<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>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>
@ -76,105 +25,30 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; import { computed, defineAsyncComponent, ref } from 'vue';
import MkButton from '@/components/MkButton.vue'; import MkCustomEmojiEditDraft from '@/components/MkCustomEmojiEditDraft.vue';
import MkInput from '@/components/MkInput.vue'; import MkCustomEmojiEditLocal from '@/components/MkCustomEmojiEditLocal.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkCustomEmojiEditRemote from '@/components/MkCustomEmojiEditRemote.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import { selectFile } from '@/scripts/select-file';
import FormSplit from '@/components/form/split.vue'; import * as os from '@/os';
import { selectFile, selectFiles } from '@/scripts/select-file.js'; import { i18n } from '@/i18n';
import * as os from '@/os.js'; import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import {switch1, swtch} from "@/os.js";
const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); const tab = ref('draft');
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 add = async (ev: MouseEvent) => { const add = async (ev: MouseEvent) => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
}, { }, {
done: result => { done: result => {
if (result.created) { //TODO: emit
emojisPaginationComponent.value.prepend(result.created); // if (result.created) {
} // emojisPaginationComponent.value.prepend(result.created);
// emojisPaginationComponent.value.reload();
// }
}, },
}, 'closed'); }, '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) => { const menu = (ev: MouseEvent) => {
os.popupMenu([{ os.popupMenu([{
icon: 'ti ti-download', icon: 'ti ti-download',
@ -217,101 +91,6 @@ const menu = (ev: MouseEvent) => {
}], ev.currentTarget ?? ev.target); }], 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(() => [{ const headerActions = $computed(() => [{
asFullButton: true, asFullButton: true,
icon: 'ti ti-plus', icon: 'ti ti-plus',
@ -323,6 +102,9 @@ const headerActions = $computed(() => [{
}]); }]);
const headerTabs = $computed(() => [{ const headerTabs = $computed(() => [{
key: 'draft',
title: i18n.ts.draftEmojis,
}, {
key: 'local', key: 'local',
title: i18n.ts.local, title: i18n.ts.local,
}, { }, {
@ -337,103 +119,4 @@ definePageMetadata(computed(() => ({
</script> </script>
<style lang="scss" scoped> <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> </style>

View File

@ -4,10 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <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"/> <img :src="emoji.url" :class="$style.img" loading="lazy"/>
<div :class="$style.body"> <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 :class="$style.info">{{ emoji.aliases.join(' ') }}</div>
</div> </div>
</button> </button>
@ -25,6 +32,7 @@ const props = defineProps<{
aliases: string[]; aliases: string[];
category: string; category: string;
url: string; url: string;
draft: boolean;
}; };
}>(); }>();
@ -91,4 +99,12 @@ function menu(ev) {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; 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> </style>

View File

@ -284,6 +284,7 @@ type CustomEmoji = {
url: string; url: string;
category: string; category: string;
aliases: string[]; aliases: string[];
draft: boolean;
}; };
// @public (undocumented) // @public (undocumented)

View File

@ -312,6 +312,7 @@ export type CustomEmoji = {
url: string; url: string;
category: string; category: string;
aliases: string[]; aliases: string[];
draft: boolean;
}; };
export type LiteInstanceMetadata = { export type LiteInstanceMetadata = {