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

# Conflicts:
#	CHANGELOG.md
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/backend/src/core/CustomEmojiService.ts
#	packages/backend/src/models/RepositoryModule.ts
#	packages/backend/src/server/api/EndpointsModule.ts
#	packages/backend/src/server/api/endpoints.ts
#	packages/backend/src/server/api/endpoints/admin/emoji/update.ts
#	packages/frontend/src/components/MkCustomEmojiEditLocal.vue
#	packages/frontend/src/components/MkCustomEmojiEditRemote.vue
#	packages/frontend/src/components/MkEmojiEditDialog.vue
#	packages/frontend/src/pages/about.emojis.vue
#	packages/frontend/src/pages/admin/roles.editor.vue
#	packages/frontend/src/pages/custom-emojis-manager.vue
#	packages/frontend/src/pages/emojis.emoji.vue
This commit is contained in:
mattyatea 2023-12-23 09:30:48 +09:00
commit 68b48bc16f
35 changed files with 1286 additions and 476 deletions

View file

@ -37,7 +37,6 @@
- Enhance: つながりの公開範囲をフォロー/フォロワーで個別に設定可能に #12072
- Enhance: ローカリゼーションの更新
- Enhance: 依存関係の更新
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加

6
locales/index.d.ts vendored
View file

@ -279,6 +279,7 @@ export interface Locale {
"imageUrl": string;
"remove": string;
"removed": string;
"requestApprovalAreYouSure": string;
"removeAreYouSure": string;
"deleteAreYouSure": string;
"undraftAreYouSure": string;
@ -869,9 +870,9 @@ export interface Locale {
"high": string;
"middle": string;
"low": string;
"list": string;
"GamingSpeedChange": string;
"GamingSpeedChangeInfo": string;
"list": string;
"emailNotConfiguredWarning": string;
"ratio": string;
"showVisibilityColor": string;
@ -1077,6 +1078,9 @@ export interface Locale {
"license": string;
"draft": string;
"undrafted": string;
"requestPending": string;
"approval": string;
"requestingEmojis": string;
"unfavoriteConfirm": string;
"myClips": string;
"drivecleaner": string;

View file

@ -276,6 +276,7 @@ announcements: "お知らせ"
imageUrl: "画像URL"
remove: "削除"
removed: "削除しました"
requestApprovalAreYouSure: "「{x}」のリクエストを承認しますか?"
removeAreYouSure: "「{x}」を削除しますか?"
deleteAreYouSure: "「{x}」を削除しますか?"
undraftAreYouSure: "「{x}」をドラフト解除しますか?"
@ -866,9 +867,9 @@ priority: "優先度"
high: "高"
middle: "中"
low: "低"
list: "一覧"
GamingSpeedChange: "ゲーミングの光るスピードの調整"
GamingSpeedChangeInfo: "左にすれば早くなる、右にすれば遅くなる。それだけ。"
list: "一覧"
emailNotConfiguredWarning: "メールアドレスの設定がされていません。"
ratio: "比率"
showVisibilityColor: "ノートの公開範囲を色付けする"
@ -1072,6 +1073,9 @@ hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス"
requestPending: "申請中"
approval: "承認"
requestingEmojis: "リクエストされている絵文字"
draft: "ドラフト"
undrafted: "ドラフト解除"
unfavoriteConfirm: "お気に入り解除しますか?"

View file

@ -0,0 +1,13 @@
export class EmojiRequest1698131657286 {
name = 'EmojiRequest1698131657286'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "emoji_request" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "name" character varying(128) NOT NULL, "category" character varying(128), "originalUrl" character varying(512) NOT NULL, "publicUrl" character varying(512) NOT NULL DEFAULT '', "type" character varying(64), "aliases" character varying(128) array NOT NULL DEFAULT '{}', "license" character varying(1024), "fileId" character varying(1024) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "isSensitive" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3c74521e048dc744f0c7eb65f4a" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ea1d771e867e9843300f09d02c" ON "emoji_request" ("name") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_ea1d771e867e9843300f09d02c"`);
await queryRunner.query(`DROP TABLE "emoji_request"`);
}
}

View file

@ -90,6 +90,7 @@ import { ClipEntityService } from './entities/ClipEntityService.js';
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
import { EmojiEntityService } from './entities/EmojiEntityService.js';
import { EmojiRequestsEntityService } from './entities/EmojiRequestsEntityService.js';
import { FollowingEntityService } from './entities/FollowingEntityService.js';
import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js';
import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js';
@ -225,6 +226,7 @@ const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService };
const $EmojiRequestsEntityService: Provider = { provide: 'EmojiRequestsEntityService', useExisting: EmojiRequestsEntityService };
const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService };
const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService };
const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService };
@ -360,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
DriveFileEntityService,
DriveFolderEntityService,
EmojiEntityService,
EmojiRequestsEntityService,
FollowingEntityService,
FollowRequestEntityService,
GalleryLikeEntityService,
@ -490,6 +493,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$DriveFileEntityService,
$DriveFolderEntityService,
$EmojiEntityService,
$EmojiRequestsEntityService,
$FollowingEntityService,
$FollowRequestEntityService,
$GalleryLikeEntityService,
@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
DriveFileEntityService,
DriveFolderEntityService,
EmojiEntityService,
EmojiRequestsEntityService,
FollowingEntityService,
FollowRequestEntityService,
GalleryLikeEntityService,
@ -749,6 +754,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$DriveFileEntityService,
$DriveFolderEntityService,
$EmojiEntityService,
$EmojiRequestsEntityService,
$FollowingEntityService,
$FollowRequestEntityService,
$GalleryLikeEntityService,

View file

@ -12,12 +12,14 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiEmoji } from '@/models/Emoji.js';
import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
import type { EmojisRepository, EmojiRequestsRepository, MiRole, MiUser } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { MiEmojiRequest } from '@/models/EmojiRequest.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
@Injectable()
@ -32,6 +34,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
@Inject(DI.emojiRequestsRepository)
private emojiRequestsRepository: EmojiRequestsRepository,
private utilityService: UtilityService,
private idService: IdService,
private emojiEntityService: EmojiEntityService,
@ -54,6 +59,41 @@ export class CustomEmojiService implements OnApplicationShutdown {
});
}
@bindThis
public async request(data: {
driveFile: MiDriveFile;
name: string;
category: string | null;
aliases: string[];
license: string | null;
isSensitive: boolean;
localOnly: boolean;
}, me?: MiUser): Promise<MiEmojiRequest> {
const emoji = await this.emojiRequestsRepository.insert({
id: this.idService.gen(),
updatedAt: new Date(),
name: data.name,
category: data.category,
aliases: data.aliases,
originalUrl: data.driveFile.url,
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
type: data.driveFile.webpublicType ?? data.driveFile.type,
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
fileId: data.driveFile.id,
}).then(x => this.emojiRequestsRepository.findOneByOrFail(x.identifiers[0]));
if (me) {
this.moderationLogService.log(me, 'addCustomEmoji', {
emojiId: emoji.id,
emoji: emoji,
});
}
return emoji;
}
@bindThis
public async add(data: {
driveFile: MiDriveFile;
@ -64,7 +104,6 @@ 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({
@ -81,7 +120,6 @@ export class CustomEmojiService implements OnApplicationShutdown {
isSensitive: data.isSensitive,
localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: data.draft,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) {
@ -111,7 +149,6 @@ 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 });
@ -126,7 +163,6 @@ 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,
@ -161,6 +197,36 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
}
@bindThis
public async updateRequest(id: MiEmojiRequest['id'], data: {
driveFile?: MiDriveFile;
name?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
isSensitive?: boolean;
localOnly?: boolean;
}, moderator?: MiUser): Promise<void> {
const emoji = await this.emojiRequestsRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojiRequestsRepository.findOneBy({ name: data.name });
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
await this.emojiRequestsRepository.update(emoji.id, {
updatedAt: new Date(),
name: data.name,
category: data.category,
aliases: data.aliases,
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
});
this.localEmojisCache.refresh();
}
@bindThis
public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) {
const emojis = await this.emojisRepository.findBy({
@ -232,36 +298,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setLocalOnlyBulk(ids: MiEmoji['id'][], localOnly: boolean | false) {
await this.emojisRepository.update({
id: In(ids),
}, {
updatedAt: new Date(),
localOnly: localOnly,
});
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setisSensitiveBulk(ids: MiEmoji['id'][], isSensitive: boolean | false) {
await this.emojisRepository.update({
id: In(ids),
}, {
updatedAt: new Date(),
isSensitive: isSensitive,
});
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setLicenseBulk(ids: MiEmoji['id'][], license: string | null) {
await this.emojisRepository.update({
@ -298,6 +335,13 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
}
@bindThis
public async deleteRequest(id: MiEmojiRequest['id']) {
const emoji = await this.emojiRequestsRepository.findOneByOrFail({ id: id });
await this.emojiRequestsRepository.delete(emoji.id);
}
@bindThis
public async deleteBulk(ids: MiEmoji['id'][], moderator?: MiUser) {
const emojis = await this.emojisRepository.findBy({
@ -419,11 +463,21 @@ export class CustomEmojiService implements OnApplicationShutdown {
return this.emojisRepository.exist({ where: { name, host: IsNull() } });
}
@bindThis
public checkRequestDuplicate(name: string): Promise<boolean> {
return this.emojiRequestsRepository.exist({ where: { name } });
}
@bindThis
public getEmojiById(id: string): Promise<MiEmoji | null> {
return this.emojisRepository.findOneBy({ id });
}
@bindThis
public getEmojiRequestById(id: string): Promise<MiEmojiRequest | null> {
return this.emojiRequestsRepository.findOneBy({ id });
}
@bindThis
public dispose(): void {
this.cache.dispose();

View file

@ -72,6 +72,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canRequestCustomEmojis: false,
canManageAvatarDecorations: false,
canRequestCustomEmojis: false,
canSearchNotes: false,
@ -336,6 +337,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
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)),
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),

View file

@ -133,7 +133,10 @@ export class DriveFileEntityService {
}
return url;
}
@bindThis
public async getFromUrl(url: string): Promise<MiDriveFile | null> {
return this.driveFilesRepository.findOneBy({ url: url });
}
@bindThis
public async calcDriveUsageOf(user: MiUser['id'] | { id: MiUser['id'] }): Promise<number> {
const id = typeof user === 'object' ? user.id : user;

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { EmojiRequestsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { bindThis } from '@/decorators.js';
import { MiEmojiRequest } from '@/models/EmojiRequest.js';
@Injectable()
export class EmojiRequestsEntityService {
constructor(
@Inject(DI.emojiRequestsRepository)
private emojiRequestsRepository: EmojiRequestsRepository,
) {
}
@bindThis
public async packSimple(
src: MiEmojiRequest['id'] | MiEmojiRequest,
): Promise<Packed<'EmojiRequestSimple'>> {
const emoji = typeof src === 'object' ? src : await this.emojiRequestsRepository.findOneByOrFail({ id: src });
return {
aliases: emoji.aliases,
name: emoji.name,
category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl,
isSensitive: emoji.isSensitive ? true : undefined,
};
}
@bindThis
public packSimpleMany(
emojis: any[],
) {
return Promise.all(emojis.map(x => this.packSimple(x)));
}
@bindThis
public async packDetailed(
src: MiEmojiRequest['id'] | MiEmojiRequest,
): Promise<Packed<'EmojiRequestDetailed'>> {
const emoji = typeof src === 'object' ? src : await this.emojiRequestsRepository.findOneByOrFail({ id: src });
return {
id: emoji.id,
aliases: emoji.aliases,
name: emoji.name,
category: emoji.category,
url: emoji.publicUrl,
license: emoji.license,
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
fileId: emoji.fileId,
};
}
@bindThis
public packDetailedMany(
emojis: any[],
) {
return Promise.all(emojis.map(x => this.packDetailed(x)));
}
}

View file

@ -41,6 +41,7 @@ export const DI = {
followRequestsRepository: Symbol('followRequestsRepository'),
instancesRepository: Symbol('instancesRepository'),
emojisRepository: Symbol('emojisRepository'),
emojiRequestsRepository: Symbol('emojiRequestsRepository'),
driveFilesRepository: Symbol('driveFilesRepository'),
driveFoldersRepository: Symbol('driveFoldersRepository'),
metasRepository: Symbol('metasRepository'),

View file

@ -33,7 +33,7 @@ import { packedClipSchema } from '@/models/json-schema/clip.js';
import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js';
import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
import { packedEmojiDetailedSchema, packedEmojiRequestSimpleSchema, packedEmojiSimpleSchema, packedEmojiRequestDetailedSchema } from '@/models/json-schema/emoji.js';
import { packedFlashSchema } from '@/models/json-schema/flash.js';
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js';
@ -73,7 +73,9 @@ export const refs = {
FederationInstance: packedFederationInstanceSchema,
GalleryPost: packedGalleryPostSchema,
EmojiSimple: packedEmojiSimpleSchema,
EmojiRequestSimple: packedEmojiRequestSimpleSchema,
EmojiDetailed: packedEmojiDetailedSchema,
EmojiRequestDetailed: packedEmojiRequestDetailedSchema,
Flash: packedFlashSchema,
Signin: packedSigninSchema,
RoleLite: packedRoleLiteSchema,

View file

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
import { id } from './util/id.js';
@Entity('emoji_request')
@Index(['name'], { unique: true })
export class MiEmojiRequest {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
nullable: true,
})
public updatedAt: Date | null;
@Column('varchar', {
length: 128,
})
public name: string;
@Column('varchar', {
length: 128, nullable: true,
})
public category: string | null;
@Column('varchar', {
length: 512,
})
public originalUrl: string;
@Column('varchar', {
length: 512,
default: '',
})
public publicUrl: string;
// publicUrlの方のtypeが入る
@Column('varchar', {
length: 64, nullable: true,
})
public type: string | null;
@Column('varchar', {
array: true, length: 128, default: '{}',
})
public aliases: string[];
@Column('varchar', {
length: 1024, nullable: true,
})
public license: string | null;
@Column('varchar', {
length: 1024, nullable: false,
})
public fileId: string;
@Column('boolean', {
default: false,
})
public localOnly: boolean;
@Column('boolean', {
default: false,
})
public isSensitive: boolean;
}

View file

@ -25,7 +25,7 @@ import {
MiDriveFile,
MiDriveFolder,
MiEmoji,
MiFlash,
MiEmojiRequest,MiFlash,
MiFlashLike,
MiFollowRequest,
MiFollowing,
@ -244,6 +244,12 @@ const $emojisRepository: Provider = {
inject: [DI.db],
};
const $emojiRequestsRepository: Provider = {
provide: DI.emojiRequestsRepository,
useFactory: (db: DataSource) => db.getRepository(MiEmojiRequest),
inject: [DI.db],
};
const $driveFilesRepository: Provider = {
provide: DI.driveFilesRepository,
useFactory: (db: DataSource) => db.getRepository(MiDriveFile),
@ -504,6 +510,7 @@ const $userMemosRepository: Provider = {
$followRequestsRepository,
$instancesRepository,
$emojisRepository,
$emojiRequestsRepository,
$driveFilesRepository,
$driveFoldersRepository,
$metasRepository,
@ -572,6 +579,7 @@ const $userMemosRepository: Provider = {
$followRequestsRepository,
$instancesRepository,
$emojisRepository,
$emojiRequestsRepository,
$driveFilesRepository,
$driveFoldersRepository,
$metasRepository,

View file

@ -21,6 +21,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js';
import { MiDriveFile } from '@/models/DriveFile.js';
import { MiDriveFolder } from '@/models/DriveFolder.js';
import { MiEmoji } from '@/models/Emoji.js';
import { MiEmojiRequest } from '@/models/EmojiRequest.js';
import { MiFollowing } from '@/models/Following.js';
import { MiFollowRequest } from '@/models/FollowRequest.js';
import { MiGalleryLike } from '@/models/GalleryLike.js';
@ -90,6 +91,7 @@ export {
MiDriveFile,
MiDriveFolder,
MiEmoji,
MiEmojiRequest,
MiFollowing,
MiFollowRequest,
MiGalleryLike,
@ -158,6 +160,7 @@ export type ClipFavoritesRepository = Repository<MiClipFavorite>;
export type DriveFilesRepository = Repository<MiDriveFile>;
export type DriveFoldersRepository = Repository<MiDriveFolder>;
export type EmojisRepository = Repository<MiEmoji>;
export type EmojiRequestsRepository = Repository<MiEmojiRequest>;
export type FollowingsRepository = Repository<MiFollowing>;
export type FollowRequestsRepository = Repository<MiFollowRequest>;
export type GalleryLikesRepository = Repository<MiGalleryLike>;

View file

@ -46,6 +46,36 @@ export const packedEmojiSimpleSchema = {
},
},
} as const;
export const packedEmojiRequestSimpleSchema = {
type: 'object',
properties: {
aliases: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
name: {
type: 'string',
optional: false, nullable: false,
},
category: {
type: 'string',
optional: false, nullable: true,
},
url: {
type: 'string',
optional: false, nullable: false,
},
isSensitive: {
type: 'boolean',
optional: true, nullable: false,
},
},
} as const;
export const packedEmojiDetailedSchema = {
type: 'object',
@ -108,3 +138,51 @@ export const packedEmojiDetailedSchema = {
},
},
} as const;
export const packedEmojiRequestDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
aliases: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
name: {
type: 'string',
optional: false, nullable: false,
},
category: {
type: 'string',
optional: false, nullable: true,
},
url: {
type: 'string',
optional: false, nullable: false,
},
license: {
type: 'string',
optional: false, nullable: true,
},
isSensitive: {
type: 'boolean',
optional: false, nullable: false,
},
localOnly: {
type: 'boolean',
optional: false, nullable: false,
},
fileId: {
type: 'string',
optional: false, nullable: false,
},
},
} as const;

View file

@ -29,6 +29,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js';
import { MiDriveFile } from '@/models/DriveFile.js';
import { MiDriveFolder } from '@/models/DriveFolder.js';
import { MiEmoji } from '@/models/Emoji.js';
import { MiEmojiRequest } from '@/models/EmojiRequest.js';
import { MiFollowing } from '@/models/Following.js';
import { MiFollowRequest } from '@/models/FollowRequest.js';
import { MiGalleryLike } from '@/models/GalleryLike.js';
@ -164,6 +165,7 @@ export const entities = [
MiPoll,
MiPollVote,
MiEmoji,
MiEmojiRequest,
MiHashtag,
MiSwSubscription,
MiAbuseUserReport,

View file

@ -34,18 +34,20 @@ 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_addRequest from './endpoints/admin/emoji/add-request.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';
import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js';
import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js';
import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js';
import * as ep___admin_emoji_listRequest from './endpoints/admin/emoji/list-request.js';
import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js';
import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js';
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
import * as ep___admin_emoji_updateRequest from './endpoints/admin/emoji/update-request.js';
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js';
@ -255,6 +257,7 @@ import * as ep___invite_list from './endpoints/invite/list.js';
import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emojiRequests from './endpoints/emoji-requests.js';
import * as ep___emoji from './endpoints/emoji.js';
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
@ -402,18 +405,20 @@ 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_addRequest: Provider = { provide: 'ep:admin/emoji/add-request', useClass: ep___admin_emoji_addRequest.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 };
const $admin_emoji_importZip: Provider = { provide: 'ep:admin/emoji/import-zip', useClass: ep___admin_emoji_importZip.default };
const $admin_emoji_listRemote: Provider = { provide: 'ep:admin/emoji/list-remote', useClass: ep___admin_emoji_listRemote.default };
const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: ep___admin_emoji_list.default };
const $admin_emoji_listRequest: Provider = { provide: 'ep:admin/emoji/list-request', useClass: ep___admin_emoji_listRequest.default };
const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default };
const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default };
const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default };
const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default };
const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default };
const $admin_emoji_updateRequest: Provider = { provide: 'ep:admin/emoji/update-request', useClass: ep___admin_emoji_updateRequest.default };
const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default };
const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default };
const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default };
@ -624,6 +629,7 @@ const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invit
const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default };
const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
const $emoji_requests: Provider = { provide: 'ep:emoji-requests', useClass: ep___emojiRequests.default };
const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default };
const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default };
@ -774,18 +780,20 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_emoji_setlocalOnlyBulk,
$admin_emoji_setisSensitiveBulk,
$admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_addRequest,
$admin_emoji_copy,
$admin_emoji_deleteBulk,
$admin_emoji_delete,
$admin_emoji_importZip,
$admin_emoji_listRemote,
$admin_emoji_list,
$admin_emoji_listRequest,
$admin_emoji_removeAliasesBulk,
$admin_emoji_setAliasesBulk,
$admin_emoji_setCategoryBulk,
$admin_emoji_setLicenseBulk,
$admin_emoji_update,
$admin_emoji_updateRequest,
$admin_federation_deleteAllFiles,
$admin_federation_refreshRemoteInstanceMetadata,
$admin_federation_removeAllFollowing,
@ -996,6 +1004,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$invite_limit,
$meta,
$emojis,
$emoji_requests,
$emoji,
$miauth_genToken,
$mute_create,
@ -1138,13 +1147,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_drive_showFile,
$admin_emoji_addAliasesBulk,
$admin_emoji_add,
$admin_emoji_addDraft,
$admin_emoji_addRequest,
$admin_emoji_copy,
$admin_emoji_deleteBulk,
$admin_emoji_delete,
$admin_emoji_importZip,
$admin_emoji_listRemote,
$admin_emoji_list,
$admin_emoji_listRequest,
$admin_emoji_removeAliasesBulk,
$admin_emoji_setAliasesBulk,
$admin_emoji_setCategoryBulk,
@ -1152,6 +1162,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_emoji_setlocalOnlyBulk,
$admin_emoji_setisSensitiveBulk,
$admin_emoji_update,
$admin_emoji_updateRequest,
$admin_federation_deleteAllFiles,
$i_userstats,
$admin_federation_refreshRemoteInstanceMetadata,
@ -1362,6 +1373,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$invite_limit,
$meta,
$emojis,
$emoji_requests,
$emoji,
$miauth_genToken,
$mute_create,

View file

@ -33,18 +33,20 @@ 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_addRequest from './endpoints/admin/emoji/add-request.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';
import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js';
import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js';
import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js';
import * as ep___admin_emoji_listRequest from './endpoints/admin/emoji/list-request.js';
import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js';
import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js';
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
import * as ep___admin_emoji_updateRequest from './endpoints/admin/emoji/update-request.js';
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js';
@ -255,6 +257,7 @@ import * as ep___invite_list from './endpoints/invite/list.js';
import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emojiRequests from './endpoints/emoji-requests.js';
import * as ep___emoji from './endpoints/emoji.js';
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
@ -397,13 +400,14 @@ 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/add-request', ep___admin_emoji_addRequest],
['admin/emoji/copy', ep___admin_emoji_copy],
['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk],
['admin/emoji/delete', ep___admin_emoji_delete],
['admin/emoji/import-zip', ep___admin_emoji_importZip],
['admin/emoji/list-remote', ep___admin_emoji_listRemote],
['admin/emoji/list', ep___admin_emoji_list],
['admin/emoji/list-request', ep___admin_emoji_listRequest],
['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk],
['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk],
['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk],
@ -411,6 +415,7 @@ const eps = [
['admin/emoji/set-issensitive-bulk', ep___admin_emoji_setisSensitiveBulk],
['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk],
['admin/emoji/update', ep___admin_emoji_update],
['admin/emoji/update-request', ep___admin_emoji_updateRequest],
['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles],
['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata],
['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing],
@ -621,6 +626,7 @@ const eps = [
['invite/limit', ep___invite_limit],
['meta', ep___meta],
['emojis', ep___emojis],
['emoji-requests', ep___emojiRequests],
['emoji', ep___emoji],
['miauth/gen-token', ep___miauth_genToken],
['mute/create', ep___mute_create],

View file

@ -0,0 +1,91 @@
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',
},
duplicateName: {
message: 'Duplicate name.',
code: 'DUPLICATE_NAME',
id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975',
},
},
} 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 },
isSensitive: { type: 'boolean', nullable: true },
localOnly: { type: 'boolean', nullable: true },
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['name', 'fileId'],
} as const;
// TODO: ロジックをサービスに切り出す
@Injectable()
// eslint-disable-next-line import/no-default-export
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 isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
const isRequestDuplicate = await this.customEmojiService.checkRequestDuplicate(ps.name);
if (isDuplicate || isRequestDuplicate) throw new ApiError(meta.errors.duplicateName);
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const emoji = await this.customEmojiService.request({
driveFile,
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
});
await this.moderationLogService.log(me, 'addCustomEmoji', {
emojiId: emoji.id,
emoji: emoji,
});
return {
id: emoji.id,
};
});
}
}

View file

@ -38,7 +38,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
await this.customEmojiService.delete(ps.id, me);
const emoji = await this.customEmojiService.getEmojiById(ps.id);
const RequestEmoji = await this.customEmojiService.getEmojiRequestById(ps.id);
if (emoji != null) {
await this.customEmojiService.delete(ps.id, me);
}
if (RequestEmoji != null) {
await this.customEmojiService.deleteRequest(ps.id);
}
});
}
}

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojiRequestsRepository } from '@/models/_.js';
import type { MiEmojiRequest } from '@/models/EmojiRequest.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { EmojiRequestsEntityService } from '@/core/entities/EmojiRequestsEntityService.js';
//import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireRolePolicy: 'canManageCustomEmojis',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
aliases: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
name: {
type: 'string',
optional: false, nullable: false,
},
category: {
type: 'string',
optional: false, nullable: true,
},
url: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
query: { type: 'string', 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' },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.emojiRequestsRepository)
private emojiRequestsRepository: EmojiRequestsRepository,
private emojiRequestsEntityService: EmojiRequestsEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const q = this.queryService.makePaginationQuery(this.emojiRequestsRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId);
let emojis: MiEmojiRequest[];
if (ps.query) {
//q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
//const emojis = await q.limit(ps.limit).getMany();
emojis = await q.getMany();
const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g);
if (queryarry) {
emojis = emojis.filter(emoji =>
queryarry.includes(`:${emoji.name}:`),
);
} else {
emojis = emojis.filter(emoji =>
emoji.name.includes(ps.query!) ||
emoji.aliases.some(a => a.includes(ps.query!)) ||
emoji.category?.includes(ps.query!));
}
emojis.splice(ps.limit + 1);
} else {
emojis = await q.limit(ps.limit).getMany();
}
return this.emojiRequestsEntityService.packDetailedMany(emojis);
});
}
}

View file

@ -0,0 +1,117 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import type { DriveFilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireRolePolicy: 'canManageCustomEmojis',
errors: {
noSuchEmoji: {
message: 'No such emoji.',
code: 'NO_SUCH_EMOJI',
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
},
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d',
},
sameNameEmojiExists: {
message: 'Emoji that have same name already exists.',
code: 'SAME_NAME_EMOJI_EXISTS',
id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' },
category: {
type: 'string',
nullable: true,
description: 'Use `null` to reset the category.',
},
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
Request: { type: 'boolean' },
},
required: ['id', 'name', 'aliases'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private customEmojiService: CustomEmojiService,
private driveFileEntityService: DriveFileEntityService,
) {
super(meta, paramDef, async (ps, me) => {
let driveFile;
const isRequest = !!ps.Request;
if (ps.fileId) {
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
}
const emoji = await this.customEmojiService.getEmojiRequestById(ps.id);
if (emoji != null) {
if (ps.name !== emoji.name) {
const isDuplicate = await this.customEmojiService.checkRequestDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
}
} else {
throw new ApiError(meta.errors.noSuchEmoji);
}
if (!isRequest) {
const file = await this.driveFileEntityService.getFromUrl(emoji.originalUrl);
if (file === null) throw new ApiError(meta.errors.noSuchFile);
await this.customEmojiService.add({
driveFile: file,
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
host: null,
license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
}, me);
await this.customEmojiService.deleteRequest(ps.id);
} else {
await this.customEmojiService.updateRequest(ps.id, {
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
}, me);
}
});
}
}

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import type { DriveFilesRepository , EmojisRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
@ -62,7 +63,7 @@ export const paramDef = {
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
draft: { type: 'boolean' },
Request: { type: 'boolean' },
},
required: ['id', 'name', 'draft', 'aliases'],
} as const;
@ -73,10 +74,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private customEmojiService: CustomEmojiService,
private driveFileEntityService: DriveFileEntityService,
) {
super(meta, paramDef, async (ps, me) => {
let driveFile;
const isRequest = !!ps.Request;
if (ps.fileId) {
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
@ -91,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchEmoji);
}
await this.customEmojiService.update(ps.id, {
if (!isRequest) {await this.customEmojiService.update(ps.id, {
driveFile,
name: ps.name,
category: ps.category ?? null,
@ -101,7 +103,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
draft: ps.draft,
}, me);
}, me);} else {
const file = await this.driveFileEntityService.getFromUrl(emoji.originalUrl);
if (file === null) throw new ApiError(meta.errors.noSuchFile);
await this.customEmojiService.request({
driveFile: file,
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false,
localOnly: ps.localOnly ?? false,
}, me);
await this.customEmojiService.delete(ps.id);
}
});
}
}

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { EmojiRequestsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { EmojiRequestsEntityService } from '@/core/entities/EmojiRequestsEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['meta'],
requireCredential: false,
allowGet: true,
cacheSec: 3600,
res: {
type: 'object',
optional: false, nullable: false,
properties: {
emojis: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'EmojiRequestSimple',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.emojiRequestsRepository)
private emojiRequestsRepository: EmojiRequestsRepository,
private emojiRequestsEntityService: EmojiRequestsEntityService,
) {
super(meta, paramDef, async () => {
const emojis = await this.emojiRequestsRepository.find({
order: {
category: 'ASC',
name: 'ASC',
},
});
return {
emojis: await this.emojiRequestsEntityService.packSimpleMany(emojis),
};
});
}
}

View file

@ -57,6 +57,7 @@ export const moderationLogTypes = [
'unsuspend',
'updateUserNote',
'addCustomEmoji',
'requestCustomEmoji',
'updateCustomEmoji',
'deleteCustomEmoji',
'assignRole',
@ -117,6 +118,10 @@ export type ModerationLogPayloads = {
emojiId: string;
emoji: any;
};
requestCustomEmoji: {
emojiId: string;
emoji: any;
};
updateCustomEmoji: {
emojiId: string;
before: any;

View file

@ -1,5 +1,5 @@
<template>
<MkInput v-model="query" :debounce="true" type="search">
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
@ -20,22 +20,22 @@
<MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="100">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<div :class="$style.root">
<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)">
<button v-if="emoji.request" class="_panel _button" :class="[{ selected: selectedEmojis.includes(emoji.id) },$style.emoji,$style.emojirequest]" @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>
<button v-else class="_panel _button" :class="[{ selected: selectedEmojis.includes(emoji.id) },$style.emoji]" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="emoji.url" :class="$style.img" :alt="emoji.name"/>
<div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.category }}</div>
</div>
</button>
</div>
</div>
</template>
@ -64,6 +64,14 @@ const pagination = {
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 setisSensitiveBulk = async () => {
const { canceled, result } = await os.switch1({
title: 'isSensitive',
@ -221,53 +229,48 @@ const delBulk = async () => {
};
</script>
<style lang="scss" scoped>
.ldhfsamy {
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: var(--margin);
}
.emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
width: 100%;
div > .emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
width: 100%;
&:hover {
border-color: var(--inputBorderHover);
}
&: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;
}
}
}
&.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;
}
.emoji-draft {
.info {
opacity: 0.5;
text-overflow: ellipsis;
overflow: hidden;
}
.emojirequest {
--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;

View file

@ -1,6 +1,6 @@
<template>
<FormSplit>
<MkInput v-model="queryRemote" :debounce="true" type="search">
<MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
@ -8,15 +8,15 @@
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
</FormSplit>
<MkPagination :pagination="remotePagination" :displayLimit="100">
<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.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
<div :class="$style.root">
<div v-for="emoji in items" :key="emoji.id" :class="$style.emoji" class="_panel _button" @click="remoteMenu(emoji, $event)">
<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" :class="$style.img" :alt="emoji.name"/>
<div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.host }}</div>
</div>
</div>
</div>
@ -62,49 +62,45 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
};
</script>
<style lang="scss" scoped>
.empty {
margin: var(--margin);
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
}
.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;
> .emoji {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
&:hover {
color: var(--accent);
}
}
&:hover {
color: var(--accent);
}
.img {
width: 32px;
height: 32px;
}
> .img {
width: 32px;
height: 32px;
}
.body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
}
.name {
text-overflow: ellipsis;
overflow: hidden;
}
> .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;
}
}
}
.info {
opacity: 0.5;
font-size: 90%;
text-overflow: ellipsis;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,198 @@
<template>
<MkPagination ref="emojisRequestPaginationComponent" :pagination="paginationRequest">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<template v-for="emoji in items" :key="emoji.id">
<div :class="$style.emoji" class="_panel">
<div :class="$style.img">
<div :class="$style.imgLight"><img :src="emoji.url" :alt="emoji.name"/></div>
<div :class="$style.imgDark"><img :src="emoji.url" :alt="emoji.name"/></div>
</div>
<div :class="$style.info">
<div :class="$style.name">{{ i18n.ts.name }}: {{ emoji.name }}</div>
<div :class="$style.category">{{ i18n.ts.category }}:{{ emoji.category }}</div>
<div :class="$style.aliases">{{ i18n.ts.tags }}:{{ emoji.aliases.join(' ') }}</div>
<div :class="$style.license">{{ i18n.ts.license }}:{{ emoji.license }}</div>
</div>
<div :class="$style.editbutton">
<MkButton primary :class="$style.edit" @click="editRequest(emoji)">
{{ i18n.ts.edit }}
</MkButton>
<MkButton :class="$style.request" @click="unrequested(emoji)">
{{ i18n.ts.approval }}
</MkButton>
<MkButton danger :class="$style.delete" @click="deleteRequest(emoji)">
{{ i18n.ts.delete }}
</MkButton>
</div>
</div>
</template>
</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 emojisRequestPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const query = ref(null);
const paginationRequest = {
endpoint: 'admin/emoji/list-request' as const,
limit: 30,
params: computed(() => ({
query: (query.value && query.value !== '') ? query.value : null,
})),
};
function editRequest(emoji) {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
emoji: emoji,
isRequest: true,
}, {
done: result => {
if (result.updated) {
emojisRequestPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
...oldEmoji,
...result.updated,
}));
emojisRequestPaginationComponent.value.reload();
} else if (result.deleted) {
emojisRequestPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisRequestPaginationComponent.value.reload();
}
},
}, 'closed');
}
async function unrequested(emoji) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('requestApprovalAreYouSure', { x: emoji.name }),
});
if (canceled) return;
await os.api('admin/emoji/update-request', {
id: emoji.id,
fileId: emoji.fileId,
name: emoji.name,
category: emoji.category,
aliases: emoji.aliases,
license: emoji.license,
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
isRequest: false,
});
emojisRequestPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisRequestPaginationComponent.value.reload();
}
async function deleteRequest(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(() => {
emojisRequestPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisRequestPaginationComponent.value.reload();
});
}
</script>
<style lang="scss" module>
.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;
}
.editbutton {
display: grid;
grid-template-rows: 42px;
margin-top: 6px;
}
.edit {
grid-row: 1;
width: 100%;
margin: 6px 0;
}
.request {
grid-row: 2;
width: 100%;
margin: 6px 0;
}
.delete {
grid-row: 3;
width: 100%;
margin: 6px 0;
}
</style>

View file

@ -7,12 +7,11 @@ 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-if="isRequest && !emoji" #header>{{ i18n.ts.requestCustomEmojis }}</template>
<template v-else #header>New emoji</template>
<div>
@ -64,14 +63,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo>
</div>
</MkFolder>
<MkSwitch v-model="isSensitive">isSensitive</MkSwitch>
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkSwitch v-model="isSensitive">{{ i18n.ts.isSensitive }}</MkSwitch>
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkSwitch v-model="isNotifyIsHome">
{{ i18n.ts.isNotifyIsHome }}
</MkSwitch>
<MkSwitch v-if="!isRequest" v-model="draft" >
{{ i18n.ts.draft }}
</MkSwitch>
</div>
</MkSpacer>
@ -87,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
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';
@ -99,144 +98,91 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { customEmojiCategories } from '@/custom-emojis.js';
import MkSwitch from '@/components/MkSwitch.vue';
import { selectFile } from '@/scripts/select-file.js';
import { selectFile, selectFiles } from '@/scripts/select-file.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{
emoji?: any,
isRequest: boolean,
emoji?: any,
isRequest: boolean,
}>();
const dialog = ref(null);
const name = ref<string>(props.emoji ? props.emoji.name : '');
const category = ref<string>(props.emoji ? props.emoji.category : '');
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');
const license = ref<string>(props.emoji ? (props.emoji.license ?? '') : '');
const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false);
const localOnly = ref(props.emoji ? props.emoji.localOnly : false);
const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
const rolesThatCanBeUsedThisEmojiAsReaction = ref([]);
const file = ref<Misskey.entities.DriveFile>();
let chooseFile = ref(null);
let draft = ref(props.emoji ? props.emoji.draft : false);
let isRequest = ref(props.isRequest);
let isNotifyIsHome = ref(false);
let url;
watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
let dialog = $ref(null);
let name: string = $ref(props.emoji ? props.emoji.name : '');
let category: string = $ref(props.emoji ? props.emoji.category : '');
let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : '');
let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : '');
let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref((props.emoji && props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref<Misskey.entities.DriveFile>();
let chooseFile: DriveFile|null = $ref(null);
let isRequest = $ref(props.isRequest ?? false);
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.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
const imgUrl = computed(() => file ? file.url : props.emoji && !isRequest ? `/emoji/${props.emoji.name}.webp` : props.emoji && props.emoji.url ? props.emoji.url : null);
const validation = computed(() => {
return name.value.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null;
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
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
function ok() {
if (isRequest) {
if (chooseFile.value !== null && name.value.match(/^[a-zA-Z0-9_]+$/)) {
add();
}
} else {
update();
}
}
async function add() {
const ret = await os.api('admin/emoji/add-draft', {
name: name,
category: category.value,
aliases: aliases.value.split(' '),
license: license.value === '' ? null : license.value,
fileId: chooseFile.value.id,
isNotifyIsHome: isNotifyIsHome.value,
});
emit('done', {
updated: {
id: ret.value.id,
name,
category,
aliases: aliases.value.split(' '),
license: license.value === '' ? null : license,
draft: true,
},
});
dialog.value.close();
}
async function changeImage(ev) {
file.value = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.value.name.replace(/\.(.+)$/, '');
file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) {
name.value = candidate;
name = candidate;
}
}
async function addRole() {
const roles = await os.api('admin/roles/list');
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id);
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id);
const { canceled, result: role } = await os.select({
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
});
if (canceled) return;
rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
rolesThatCanBeUsedThisEmojiAsReaction.push(role);
}
async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction.value = rolesThatCanBeUsedThisEmojiAsReaction.value.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: name.value,
category: category.value === '' ? null : category.value,
aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license.value === '' ? null : license.value,
fileId: chooseFile.value?.id,
draft: draft.value,
});
emit('done', {
updated: {
id: props.emoji.id,
name: name.value,
category: category.value === '' ? null : category.value,
aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license.value === '' ? null : license.value,
draft: draft.value,
},
});
dialog.value.close();
}
async function done() {
const params = {
name: name.value,
category: category.value === '' ? null : category.value,
aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license.value === '' ? null : license.value,
isSensitive: isSensitive.value,
draft: draft.value,
localOnly: localOnly.value,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id),
isNotifyIsHome: isNotifyIsHome.value,
};
name,
category: category === '' ? null : category,
aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''),
license: license === '' ? null : license,
Request: isRequest,
isSensitive,
localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
};
if (file.value) {
params.fileId = file.value.id;
if (file) {
params.fileId = file.id;
}
console.log(props.emoji);
if (props.emoji) {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
...params,
});
if (isRequest) {
await os.apiWithDialog('admin/emoji/update-request', {
id: props.emoji.id,
...params,
});
} else {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
...params,
});
}
emit('done', {
updated: {
@ -245,31 +191,24 @@ async function done() {
},
});
dialog.value.close();
dialog.close();
} else {
const created = isRequest
? await os.apiWithDialog('admin/emoji/add-draft', params)
? await os.apiWithDialog('admin/emoji/add-request', params)
: await os.apiWithDialog('admin/emoji/add', params);
emit('done', {
created: created,
});
dialog.value.close();
dialog.close();
}
}
function chooseFileFrom(ev) {
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
chooseFile.value = files_[0];
url = chooseFile.value.url;
});
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: name.value }),
text: i18n.t('removeAreYouSure', { x: name }),
});
if (canceled) return;
@ -279,60 +218,60 @@ async function del() {
emit('done', {
deleted: true,
});
dialog.value.close();
dialog.close();
});
}
</script>
<style lang="scss" module>
.imgs {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.imgContainer {
padding: 8px;
border-radius: 6px;
padding: 8px;
border-radius: 6px;
}
.img {
display: block;
height: 64px;
width: 64px;
object-fit: contain;
display: block;
height: 64px;
width: 64px;
object-fit: contain;
}
.roleItem {
display: flex;
display: flex;
}
.role {
flex: 1;
flex: 1;
}
.roleUnassign {
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
.footerButtons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>

View file

@ -212,7 +212,6 @@ async function init(): Promise<void> {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
if (res.length === 0 || props.pagination.noPaging) {
concatItems(res);
more.value = false;
@ -221,7 +220,6 @@ async function init(): Promise<void> {
concatItems(res);
more.value = true;
}
offset.value = res.length;
error.value = false;
fetching.value = false;

View file

@ -5,138 +5,112 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header>
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
</template>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer 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>
<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.canRequestCustomEmojis)" primary @click="edit">{{ i18n.ts.requestCustomEmojis }}</MkButton>
<div class="query" style="margin-top: 10px;">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off">
<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>
-->
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
</div>
-->
</div>
<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"/>
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :request="emoji.request"/>
</div>
</MkFoldableSection>
<MkFoldableSection v-for="category in filteredCategories" v-once :key="category">
<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 && !e.draft)" :key="emoji.name"
:emoji="emoji" :draft="emoji.draft"
/>
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/>
</div>
</MkFoldableSection>
</MkSpacer>
<MkSpacer v-if="tab === 'draft'" :contentMax="1000" :marginMin="20">
<MkSpacer v-if="tab === 'request'" :contentMax="1000" :marginMin="20">
<div :class="$style.emojis">
<XEmoji v-for="emoji in draftEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
<XEmoji v-for="emoji in requestEmojis.emojis" :key="emoji.name" :emoji="emoji" :request="true"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { watch, defineAsyncComponent, ref, computed } from 'vue';
import { watch, defineAsyncComponent, ref } 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(() => []);
let tab = $ref('emojis');
const headerActions = $computed(() => []);
const customEmojiTags = getCustomEmojiTags();
const q = ref('');
const searchEmojis = ref<Misskey.entities.EmojiSimple[]>(null);
const selectedTags = ref(new Set());
const headerTabs = computed(() => [{
key: 'emojis',
title: i18n.ts.list,
const headerTabs = $computed(() => [{
key: 'emojis',
title: i18n.ts.list,
}, {
key: 'draft',
title: i18n.ts.draftEmojis,
key: 'request',
title: i18n.ts.requestingEmojis,
}]);
const filteredCategories = computed(() => {
return customEmojiCategories.value.filter((category: any) => {
return customEmojis.value.some((em: any) => em.category === category && !em.draft);
});
});
definePageMetadata(ref({}));
const draftEmojis = customEmojis.value.filter(emoji => emoji.draft);
let q = $ref('');
let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null);
let selectedTags = $ref(new Set());
const requestEmojis = await os.apiGet('emoji-requests');
function search() {
if ((q.value === '' || q.value == null) && selectedTags.value.size === 0) {
searchEmojis.value = null;
if ((q === '' || q == null) && selectedTags.size === 0) {
searchEmojis = null;
return;
}
if (selectedTags.value.size === 0) {
const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g);
if (selectedTags.size === 0) {
const queryarry = q.match(/\:([a-z0-9_]*)\:/g);
if (queryarry) {
searchEmojis.value = customEmojis.value.filter(emoji =>
searchEmojis = customEmojis.value.filter(emoji =>
queryarry.includes(`:${emoji.name}:`),
);
} else {
searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value));
searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q));
}
} else {
searchEmojis.value = customEmojis.value.filter(emoji => (emoji.name.includes(q.value) || emoji.aliases.includes(q.value)) && [...selectedTags.value].every(t => emoji.aliases.includes(t)));
searchEmojis = customEmojis.value.filter(emoji => (emoji.name.includes(q) || emoji.aliases.includes(q)) && [...selectedTags].every(t => emoji.aliases.includes(t)));
}
}
function toggleTag(tag) {
if (selectedTags.value.has(tag)) {
selectedTags.value.delete(tag);
} else {
selectedTags.value.add(tag);
}
}
const edit = () => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
isRequest: true,
}, {
done: result => {
window.location.reload();
},
}, 'closed');
};
watch(q, () => {
watch($$(q), () => {
search();
});
const edit = () => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
isRequest: true,
}, {
done: result => {
window.location.reload();
},
}, 'closed');
};
watch(selectedTags, () => {
watch($$(selectedTags), () => {
search();
}, { deep: true });

View file

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

View file

@ -319,46 +319,6 @@ 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.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>

View file

@ -8,17 +8,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkCustomEmojiEditLocal/>
</div>
<div v-if="tab === 'request'" class="request">
<MkCustomEmojiEditDraft/>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<MkCustomEmojiEditRemote/>
</div>
</div>
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkCustomEmojiEditLocal/>
</div>
<div v-if="tab === 'request'" class="request">
<MkCustomEmojiEditRequest/>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<MkCustomEmojiEditRemote/>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref } from 'vue';
import MkCustomEmojiEditDraft from '@/components/MkCustomEmojiEditDraft.vue';
import MkCustomEmojiEditRequest from '@/components/MkCustomEmojiEditRequest.vue';
import MkCustomEmojiEditLocal from '@/components/MkCustomEmojiEditLocal.vue';
import MkCustomEmojiEditRemote from '@/components/MkCustomEmojiEditRemote.vue';
import { selectFile } from '@/scripts/select-file';
@ -34,15 +34,17 @@ import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const tab = ref('draft');
const tab = ref('request');
const add = async (ev: MouseEvent) => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.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');
};
@ -89,7 +91,7 @@ const menu = (ev: MouseEvent) => {
}], ev.currentTarget ?? ev.target);
};
const headerActions = computed(() => [{
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
text: i18n.ts.addEmoji,
@ -99,9 +101,9 @@ const headerActions = computed(() => [{
handler: menu,
}]);
const headerTabs = computed(() => [{
key: 'draft',
title: i18n.ts.draftEmojis,
const headerTabs = $computed(() => [{
key: 'request',
title: i18n.ts.requestingEmojis,
}, {
key: 'local',
title: i18n.ts.local,

View file

@ -4,36 +4,35 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<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"/>
<button v-if="request" class="_button emoji-request" :class="$style.root" @click="menu">
<img :src="emoji.url" :class="$style.img" loading="lazy"/>
<div class="body">
<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div>
<div class="name _monospace">{{ emoji.name + ' (request)' }}</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">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.aliases.join(' ') }}</div>
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div>
</div>
</button>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
emoji: {
name: string;
aliases: string[];
category: string;
url: string;
draft: boolean;
};
emoji: {
name: string;
aliases: string[];
category: string;
url: string;
};
request?: boolean;
}>();
function menu(ev) {
@ -51,10 +50,10 @@ function menu(ev) {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: () => {
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
os.apiGet('emoji-requests', { name: props.emoji.name }).then(res => {
os.alert({
type: 'info',
text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`,
text: `License: ${res.license}`,
});
});
},
@ -64,47 +63,45 @@ function menu(ev) {
<style lang="scss" module>
.root {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
background: var(--panel);
border-radius: 8px;
display: flex;
align-items: center;
padding: 12px;
text-align: left;
background: var(--panel);
border-radius: 8px;
&:hover {
border-color: var(--accent);
}
&:hover {
border-color: var(--accent);
}
}
.img {
width: 42px;
height: 42px;
object-fit: contain;
width: 42px;
height: 42px;
object-fit: contain;
}
.body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
}
.name {
text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
}
.info {
opacity: 0.5;
font-size: 0.9em;
text-overflow: ellipsis;
overflow: hidden;
opacity: 0.5;
font-size: 0.9em;
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%;
.emoji-request {
--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>