From 2c0a139da60ddf33e82353acbb985230c71c78da Mon Sep 17 00:00:00 2001 From: syuilo Date: Sat, 21 Oct 2023 18:38:07 +0900 Subject: [PATCH] feat: Avatar decoration (#12096) * wip * Update ja-JP.yml * Update profile.vue * .js * Update home.test.ts --- locales/index.d.ts | 4 + locales/ja-JP.yml | 4 + .../1697847397844-avatar-decoration.js | 18 +++ .../src/core/AvatarDecorationService.ts | 129 ++++++++++++++++++ packages/backend/src/core/CoreModule.ts | 6 + .../backend/src/core/GlobalEventService.ts | 5 +- packages/backend/src/core/RoleService.ts | 6 + .../src/core/entities/UserEntityService.ts | 13 +- packages/backend/src/di-symbols.ts | 1 + .../backend/src/models/AvatarDecoration.ts | 39 ++++++ .../backend/src/models/RepositoryModule.ts | 10 +- packages/backend/src/models/User.ts | 5 + packages/backend/src/models/_.ts | 3 + .../backend/src/models/json-schema/user.ts | 20 +++ packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 20 +++ packages/backend/src/server/api/endpoints.ts | 10 ++ .../admin/avatar-decorations/create.ts | 44 ++++++ .../admin/avatar-decorations/delete.ts | 39 ++++++ .../admin/avatar-decorations/list.ts | 101 ++++++++++++++ .../admin/avatar-decorations/update.ts | 50 +++++++ .../api/endpoints/get-avatar-decorations.ts | 79 +++++++++++ .../src/server/api/endpoints/i/update.ts | 16 +++ packages/backend/src/types.ts | 16 +++ packages/backend/test/e2e/users.ts | 2 + packages/frontend/.storybook/fakes.ts | 1 + .../src/components/global/MkAvatar.vue | 15 +- .../src/pages/admin/avatar-decorations.vue | 103 ++++++++++++++ packages/frontend/src/pages/admin/index.vue | 5 + .../src/pages/admin/modlog.ModLog.vue | 12 +- .../frontend/src/pages/settings/profile.vue | 55 ++++++++ packages/frontend/src/router.ts | 4 + packages/frontend/test/home.test.ts | 8 +- packages/frontend/test/note.test.ts | 4 +- packages/frontend/test/url-preview.test.ts | 4 +- packages/misskey-js/etc/misskey-js.api.md | 22 ++- packages/misskey-js/src/consts.ts | 16 +++ packages/misskey-js/src/entities.ts | 16 +++ 38 files changed, 888 insertions(+), 19 deletions(-) create mode 100644 packages/backend/migration/1697847397844-avatar-decoration.js create mode 100644 packages/backend/src/core/AvatarDecorationService.ts create mode 100644 packages/backend/src/models/AvatarDecoration.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts create mode 100644 packages/backend/src/server/api/endpoints/get-avatar-decorations.ts create mode 100644 packages/frontend/src/pages/admin/avatar-decorations.vue diff --git a/locales/index.d.ts b/locales/index.d.ts index 363032eaa..11be41235 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1142,6 +1142,7 @@ export interface Locale { "privacyPolicy": string; "privacyPolicyUrl": string; "tosAndPrivacyPolicy": string; + "avatarDecorations": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -2295,6 +2296,9 @@ export interface Locale { "createAd": string; "deleteAd": string; "updateAd": string; + "createAvatarDecoration": string; + "updateAvatarDecoration": string; + "deleteAvatarDecoration": string; }; "_fileViewer": { "title": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f1b57f8bd..11b083392 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1139,6 +1139,7 @@ impressumDescription: "ドイツなどの一部の国と地域では表示が義 privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" +avatarDecorations: "アイコンデコレーション" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -2208,6 +2209,9 @@ _moderationLogTypes: createAd: "広告を作成" deleteAd: "広告を削除" updateAd: "広告を更新" + createAvatarDecoration: "アイコンデコレーションを作成" + updateAvatarDecoration: "アイコンデコレーションを更新" + deleteAvatarDecoration: "アイコンデコレーションを削除" _fileViewer: title: "ファイルの詳細" diff --git a/packages/backend/migration/1697847397844-avatar-decoration.js b/packages/backend/migration/1697847397844-avatar-decoration.js new file mode 100644 index 000000000..1f2213974 --- /dev/null +++ b/packages/backend/migration/1697847397844-avatar-decoration.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AvatarDecoration1697847397844 { + name = 'AvatarDecoration1697847397844' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "avatar_decoration" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "url" character varying(1024) NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(2048) NOT NULL, "roleIdsThatCanBeUsedThisDecoration" character varying(128) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_b6de9296f6097078e1dc53f7603" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`DROP TABLE "avatar_decoration"`); + } +} diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts new file mode 100644 index 000000000..e97946f9d --- /dev/null +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { MemorySingleCache } from '@/misc/cache.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +@Injectable() +export class AvatarDecorationService implements OnApplicationShutdown { + public cache: MemorySingleCache; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.avatarDecorationsRepository) + private avatarDecorationsRepository: AvatarDecorationsRepository, + + private idService: IdService, + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + this.cache = new MemorySingleCache(1000 * 60 * 30); + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'avatarDecorationCreated': + case 'avatarDecorationUpdated': + case 'avatarDecorationDeleted': { + this.cache.delete(); + break; + } + default: + break; + } + } + } + + @bindThis + public async create(options: Partial, moderator?: MiUser): Promise { + const created = await this.avatarDecorationsRepository.insert({ + id: this.idService.gen(), + ...options, + }).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0])); + + this.globalEventService.publishInternalEvent('avatarDecorationCreated', created); + + if (moderator) { + this.moderationLogService.log(moderator, 'createAvatarDecoration', { + avatarDecorationId: created.id, + avatarDecoration: created, + }); + } + + return created; + } + + @bindThis + public async update(id: MiAvatarDecoration['id'], params: Partial, moderator?: MiUser): Promise { + const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); + + const date = new Date(); + await this.avatarDecorationsRepository.update(avatarDecoration.id, { + updatedAt: date, + ...params, + }); + + const updated = await this.avatarDecorationsRepository.findOneByOrFail({ id: avatarDecoration.id }); + this.globalEventService.publishInternalEvent('avatarDecorationUpdated', updated); + + if (moderator) { + this.moderationLogService.log(moderator, 'updateAvatarDecoration', { + avatarDecorationId: avatarDecoration.id, + before: avatarDecoration, + after: updated, + }); + } + } + + @bindThis + public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise { + const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); + + await this.avatarDecorationsRepository.delete({ id: avatarDecoration.id }); + this.globalEventService.publishInternalEvent('avatarDecorationDeleted', avatarDecoration); + + if (moderator) { + this.moderationLogService.log(moderator, 'deleteAvatarDecoration', { + avatarDecorationId: avatarDecoration.id, + avatarDecoration: avatarDecoration, + }); + } + } + + @bindThis + public async getAll(noCache = false): Promise { + if (noCache) { + this.cache.delete(); + } + return this.cache.fetch(() => this.avatarDecorationsRepository.find()); + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index e7e66646f..b46afb190 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -11,6 +11,7 @@ import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; +import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; @@ -140,6 +141,7 @@ const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExis const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; +const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; @@ -273,6 +275,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AntennaService, AppLockService, AchievementService, + AvatarDecorationService, CaptchaService, CreateSystemUserService, CustomEmojiService, @@ -399,6 +402,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AntennaService, $AppLockService, $AchievementService, + $AvatarDecorationService, $CaptchaService, $CreateSystemUserService, $CustomEmojiService, @@ -526,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AntennaService, AppLockService, AchievementService, + AvatarDecorationService, CaptchaService, CreateSystemUserService, CustomEmojiService, @@ -651,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AntennaService, $AppLockService, $AchievementService, + $AvatarDecorationService, $CaptchaService, $CreateSystemUserService, $CustomEmojiService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index b74fbbe58..bfbdecf68 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -18,7 +18,7 @@ import type { MiSignin } from '@/models/Signin.js'; import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -188,6 +188,9 @@ export interface InternalEventTypes { antennaCreated: MiAntenna; antennaDeleted: MiAntenna; antennaUpdated: MiAntenna; + avatarDecorationCreated: MiAvatarDecoration; + avatarDecorationDeleted: MiAvatarDecoration; + avatarDecorationUpdated: MiAvatarDecoration; metaUpdated: MiMeta; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2c2ff7af1..ef05920d5 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -227,6 +227,12 @@ export class RoleService implements OnApplicationShutdown { } } + @bindThis + public async getRoles() { + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); + return roles; + } + @bindThis public async getUserAssigns(userId: MiUser['id']) { const now = Date.now(); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index b0577fc1a..66facce4c 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -21,9 +21,10 @@ import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { IdService } from '@/core/IdService.js'; +import type { AnnouncementService } from '@/core/AnnouncementService.js'; +import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import type { OnModuleInit } from '@nestjs/common'; -import type { AnnouncementService } from '../AnnouncementService.js'; -import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; @@ -62,6 +63,7 @@ export class UserEntityService implements OnModuleInit { private roleService: RoleService; private federatedInstanceService: FederatedInstanceService; private idService: IdService; + private avatarDecorationService: AvatarDecorationService; constructor( private moduleRef: ModuleRef, @@ -126,6 +128,7 @@ export class UserEntityService implements OnModuleInit { this.roleService = this.moduleRef.get('RoleService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); + this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); } //#region Validators @@ -328,8 +331,6 @@ export class UserEntityService implements OnModuleInit { ...announcement, })) : null; - const falsy = opts.detail ? false : undefined; - const packed = { id: user.id, name: user.name, @@ -337,6 +338,10 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({ + id: decoration.id, + url: decoration.url, + }))) : [], isBot: user.isBot, isCat: user.isCat, instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index edcdd21d6..8411cb822 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -18,6 +18,7 @@ export const DI = { announcementsRepository: Symbol('announcementsRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), + avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts new file mode 100644 index 000000000..08ebbdeac --- /dev/null +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { id } from './util/id.js'; + +@Entity('avatar_decoration') +export class MiAvatarDecoration { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public updatedAt: Date | null; + + @Column('varchar', { + length: 1024, + }) + public url: string; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 2048, + }) + public description: string; + + // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public roleIdsThatCanBeUsedThisDecoration: string[]; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 9efd6841b..866fdfe6d 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -39,6 +39,12 @@ const $appsRepository: Provider = { inject: [DI.db], }; +const $avatarDecorationsRepository: Provider = { + provide: DI.avatarDecorationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), @@ -402,6 +408,7 @@ const $userMemosRepository: Provider = { $announcementsRepository, $announcementReadsRepository, $appsRepository, + $avatarDecorationsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -468,6 +475,7 @@ const $userMemosRepository: Provider = { $announcementsRepository, $announcementReadsRepository, $appsRepository, + $avatarDecorationsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 796d7c835..c98426a7b 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -138,6 +138,11 @@ export class MiUser { }) public bannerBlurhash: string | null; + @Column('varchar', { + length: 512, array: true, default: '{}', + }) + public avatarDecorations: string[]; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index f974f95ed..d7c327f16 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -10,6 +10,7 @@ import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; @@ -77,6 +78,7 @@ export { MiAnnouncementRead, MiAntenna, MiApp, + MiAvatarDecoration, MiAuthSession, MiBlocking, MiChannelFollowing, @@ -143,6 +145,7 @@ export type AnnouncementsRepository = Repository; export type AnnouncementReadsRepository = Repository; export type AntennasRepository = Repository; export type AppsRepository = Repository; +export type AvatarDecorationsRepository = Repository; export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 57d2d976f..bf283fbeb 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -37,6 +37,26 @@ export const packedUserLiteSchema = { type: 'string', nullable: true, optional: false, }, + avatarDecorations: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + url: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + }, + }, + }, isAdmin: { type: 'boolean', nullable: false, optional: true, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index d4c6ad82c..cd611839a 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -18,6 +18,7 @@ import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; @@ -129,6 +130,7 @@ export const entities = [ MiMeta, MiInstance, MiApp, + MiAvatarDecoration, MiAuthSession, MiAccessToken, MiUser, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index f83456145..f234a2637 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -18,6 +18,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; +import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; +import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; +import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; +import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; @@ -176,6 +180,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -368,6 +373,10 @@ const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; +const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default }; +const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; +const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; +const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; @@ -526,6 +535,7 @@ const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useCla const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; +const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default }; const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; @@ -722,6 +732,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_announcements_delete, $admin_announcements_list, $admin_announcements_update, + $admin_avatarDecorations_create, + $admin_avatarDecorations_delete, + $admin_avatarDecorations_list, + $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, @@ -880,6 +894,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $gallery_posts_unlike, $gallery_posts_update, $getOnlineUsersCount, + $getAvatarDecorations, $hashtags_list, $hashtags_search, $hashtags_show, @@ -1070,6 +1085,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_announcements_delete, $admin_announcements_list, $admin_announcements_update, + $admin_avatarDecorations_create, + $admin_avatarDecorations_delete, + $admin_avatarDecorations_list, + $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, @@ -1228,6 +1247,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $gallery_posts_unlike, $gallery_posts_update, $getOnlineUsersCount, + $getAvatarDecorations, $hashtags_list, $hashtags_search, $hashtags_show, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d12a035af..8d34edca9 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -18,6 +18,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; +import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; +import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; +import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; +import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; @@ -176,6 +180,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -366,6 +371,10 @@ const eps = [ ['admin/announcements/delete', ep___admin_announcements_delete], ['admin/announcements/list', ep___admin_announcements_list], ['admin/announcements/update', ep___admin_announcements_update], + ['admin/avatar-decorations/create', ep___admin_avatarDecorations_create], + ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], + ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], + ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], ['admin/drive/cleanup', ep___admin_drive_cleanup], @@ -524,6 +533,7 @@ const eps = [ ['gallery/posts/unlike', ep___gallery_posts_unlike], ['gallery/posts/update', ep___gallery_posts_update], ['get-online-users-count', ep___getOnlineUsersCount], + ['get-avatar-decorations', ep___getAvatarDecorations], ['hashtags/list', ep___hashtags_list], ['hashtags/search', ep___hashtags_search], ['hashtags/show', ep___hashtags_show], diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts new file mode 100644 index 000000000..c1869b141 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + description: { type: 'string' }, + url: { type: 'string', minLength: 1 }, + roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['name', 'description', 'url'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.create({ + name: ps.name, + description: ps.description, + url: ps.url, + roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts new file mode 100644 index 000000000..5aba24b42 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -0,0 +1,39 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.delete(ps.id, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts new file mode 100644 index 000000000..9a32a5908 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; +import type { MiAnnouncement } from '@/models/Announcement.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const avatarDecorations = await this.avatarDecorationService.getAll(true); + + return avatarDecorations.map(avatarDecoration => ({ + id: avatarDecoration.id, + createdAt: this.idService.parse(avatarDecoration.id).date.toISOString(), + updatedAt: avatarDecoration.updatedAt?.toISOString() ?? null, + name: avatarDecoration.name, + description: avatarDecoration.description, + url: avatarDecoration.url, + roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts new file mode 100644 index 000000000..564014a3d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -0,0 +1,50 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1 }, + description: { type: 'string' }, + url: { type: 'string', minLength: 1 }, + roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.update(ps.id, { + name: ps.name, + description: ps.description, + url: ps.url, + roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts new file mode 100644 index 000000000..ec602a0dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + const decorations = await this.avatarDecorationService.getAll(true); + + return decorations.map(decoration => ({ + id: decoration.id, + name: decoration.name, + description: decoration.description, + url: decoration.url, + roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 431bb4c60..f1837e708 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -32,6 +32,7 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -131,6 +132,9 @@ export const paramDef = { birthday: { ...birthdaySchema, nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, + avatarDecorations: { type: 'array', maxItems: 1, items: { + type: 'string', + } }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, fields: { type: 'array', @@ -207,6 +211,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private cacheService: CacheService, private httpRequestService: HttpRequestService, + private avatarDecorationService: AvatarDecorationService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -296,6 +301,17 @@ export default class extends Endpoint { // eslint- updates.bannerBlurhash = null; } + if (ps.avatarDecorations) { + const decorations = await this.avatarDecorationService.getAll(true); + const myRoles = await this.roleService.getUserRoles(user.id); + const allRoles = await this.roleService.getRoles(); + const decorationIds = decorations + .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) + .map(d => d.id); + + updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id)); + } + if (ps.pinnedPageId) { const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 316073c99..69224360b 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -60,6 +60,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createAvatarDecoration', + 'updateAvatarDecoration', + 'deleteAvatarDecoration', ] as const; export type ModerationLogPayloads = { @@ -221,6 +224,19 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; + updateAvatarDecoration: { + avatarDecorationId: string; + before: any; + after: any; + }; + deleteAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; }; export type Serialized = { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 53db1ac28..520d9b14e 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -68,6 +68,7 @@ describe('ユーザー', () => { host: user.host, avatarUrl: user.avatarUrl, avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations, isBot: user.isBot, isCat: user.isCat, instance: user.instance, @@ -349,6 +350,7 @@ describe('ユーザー', () => { assert.strictEqual(response.host, null); assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); assert.strictEqual(response.avatarBlurhash, null); + assert.deepStrictEqual(response.avatarDecorations, []); assert.strictEqual(response.isBot, false); assert.strictEqual(response.isCat, false); assert.strictEqual(response.instance, undefined); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 811c24392..c2e6ee52f 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -74,6 +74,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi onlineStatus: 'unknown', avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + avatarDecorations: [], emojis: [], bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', bannerColor: '#000000', diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 27c25b949..de684425a 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -47,6 +48,7 @@ const props = withDefaults(defineProps<{ link?: boolean; preview?: boolean; indicator?: boolean; + decoration?: string; }>(), { target: null, link: false, @@ -134,7 +136,7 @@ watch(() => props.user.avatarBlurhash, () => { .indicator { position: absolute; - z-index: 1; + z-index: 2; bottom: 0; left: 0; width: 20%; @@ -278,4 +280,13 @@ watch(() => props.user.avatarBlurhash, () => { } } } + +.decoration { + position: absolute; + z-index: 1; + top: -50%; + left: -50%; + width: 200%; + pointer-events: none; +} diff --git a/packages/frontend/src/pages/admin/avatar-decorations.vue b/packages/frontend/src/pages/admin/avatar-decorations.vue new file mode 100644 index 000000000..b4007e6d2 --- /dev/null +++ b/packages/frontend/src/pages/admin/avatar-decorations.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index a508c20cf..b304edbf5 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -115,6 +115,11 @@ const menuDef = $computed(() => [{ text: i18n.ts.customEmojis, to: '/admin/emojis', active: currentPage?.route.name === 'emojis', + }, { + icon: 'ti ti-sparkles', + text: i18n.ts.avatarDecorations, + to: '/admin/avatar-decorations', + active: currentPage?.route.name === 'avatarDecorations', }, { icon: 'ti ti-whirl', text: i18n.ts.federation, diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 0af226f02..bceefcf6c 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only +
raw diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 5e4889f61..c44a58d04 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -83,6 +83,23 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+
+
{{ avatarDecoration.name }}
+ +
+
+
+ @@ -126,6 +143,7 @@ import MkInfo from '@/components/MkInfo.vue'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); +let avatarDecorations: any[] = $ref([]); const profile = reactive({ name: $i.name, @@ -146,6 +164,10 @@ watch(() => profile, () => { const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []); const fieldEditMode = ref(false); +os.api('get-avatar-decorations').then(_avatarDecorations => { + avatarDecorations = _avatarDecorations; +}); + function addField() { fields.value.push({ id: Math.random().toString(), @@ -244,6 +266,20 @@ function changeBanner(ev) { }); } +function toggleDecoration(avatarDecoration) { + if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) { + os.apiWithDialog('i/update', { + avatarDecorations: [], + }); + $i.avatarDecorations = []; + } else { + os.apiWithDialog('i/update', { + avatarDecorations: [avatarDecoration.id], + }); + $i.avatarDecorations.push(avatarDecoration); + } +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); @@ -338,4 +374,23 @@ definePageMetadata({ .dragItemForm { flex-grow: 1; } + +.avatarDecoration { + cursor: pointer; + padding: 16px 16px 24px 16px; + border: solid 2px var(--divider); + border-radius: 8px; + text-align: center; +} + +.avatarDecorationActive { + border-color: var(--accent); +} + +.avatarDecorationName { + position: relative; + z-index: 10; + font-weight: bold; + margin-bottom: 16px; +} diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 6c33d0d8e..2258edebb 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -343,6 +343,10 @@ export const routes = [{ path: '/emojis', name: 'emojis', component: page(() => import('./pages/custom-emojis-manager.vue')), + }, { + path: '/avatar-decorations', + name: 'avatarDecorations', + component: page(() => import('./pages/admin/avatar-decorations.vue')), }, { path: '/queue', name: 'queue', diff --git a/packages/frontend/test/home.test.ts b/packages/frontend/test/home.test.ts index 80b26c081..6d38b7e52 100644 --- a/packages/frontend/test/home.test.ts +++ b/packages/frontend/test/home.test.ts @@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type * as Misskey from 'misskey-js'; -import { directives } from '@/directives'; -import { components } from '@/components/index'; +import { directives } from '@/directives/index.js'; +import { components } from '@/components/index.js'; import XHome from '@/pages/user/home.vue'; describe('XHome', () => { @@ -34,6 +34,8 @@ describe('XHome', () => { createdAt: '1970-01-01T00:00:00.000Z', fields: [], pinnedNotes: [], + avatarUrl: 'https://example.com', + avatarDecorations: [], }); const anchor = home.container.querySelector('a[href^="https://example.com/"]'); @@ -54,6 +56,8 @@ describe('XHome', () => { createdAt: '1970-01-01T00:00:00.000Z', fields: [], pinnedNotes: [], + avatarUrl: 'https://example.com', + avatarDecorations: [], }); const anchor = home.container.querySelector('a[href^="https://example.com/"]'); diff --git a/packages/frontend/test/note.test.ts b/packages/frontend/test/note.test.ts index 3e4faad28..8ccc05ff3 100644 --- a/packages/frontend/test/note.test.ts +++ b/packages/frontend/test/note.test.ts @@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type * as Misskey from 'misskey-js'; -import { components } from '@/components'; -import { directives } from '@/directives'; +import { components } from '@/components/index.js'; +import { directives } from '@/directives/index.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; describe('MkMediaImage', () => { diff --git a/packages/frontend/test/url-preview.test.ts b/packages/frontend/test/url-preview.test.ts index 0cf3a417e..811f07d9c 100644 --- a/packages/frontend/test/url-preview.test.ts +++ b/packages/frontend/test/url-preview.test.ts @@ -7,8 +7,8 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type { summaly } from 'summaly'; -import { components } from '@/components'; -import { directives } from '@/directives'; +import { components } from '@/components/index.js'; +import { directives } from '@/directives/index.js'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; type SummalyResult = Awaited>; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 0136df203..4fabc195d 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2634,10 +2634,22 @@ type ModerationLog = { } | { type: 'deleteAd'; info: ModerationLogPayloads['deleteAd']; +} | { + type: 'createAvatarDecoration'; + info: ModerationLogPayloads['createAvatarDecoration']; +} | { + type: 'updateAvatarDecoration'; + info: ModerationLogPayloads['updateAvatarDecoration']; +} | { + type: 'deleteAvatarDecoration'; + info: ModerationLogPayloads['deleteAvatarDecoration']; +} | { + type: 'resolveAbuseReport'; + info: ModerationLogPayloads['resolveAbuseReport']; }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration"]; // @public (undocumented) export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"]; @@ -2965,6 +2977,10 @@ type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + avatarDecorations: { + id: ID; + url: string; + }[]; emojis: { name: string; url: string; @@ -2989,8 +3005,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:109:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:605:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:609:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index c4ddead82..48a36a31d 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -78,6 +78,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createAvatarDecoration', + 'updateAvatarDecoration', + 'deleteAvatarDecoration', ] as const; export type ModerationLogPayloads = { @@ -239,4 +242,17 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; + updateAvatarDecoration: { + avatarDecorationId: string; + before: any; + after: any; + }; + deleteAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 50b4a40c4..a2a283d23 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -16,6 +16,10 @@ export type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + avatarDecorations: { + id: ID; + url: string; + }[]; emojis: { name: string; url: string; @@ -693,4 +697,16 @@ export type ModerationLog = { } | { type: 'deleteAd'; info: ModerationLogPayloads['deleteAd']; +} | { + type: 'createAvatarDecoration'; + info: ModerationLogPayloads['createAvatarDecoration']; +} | { + type: 'updateAvatarDecoration'; + info: ModerationLogPayloads['updateAvatarDecoration']; +} | { + type: 'deleteAvatarDecoration'; + info: ModerationLogPayloads['deleteAvatarDecoration']; +} | { + type: 'resolveAbuseReport'; + info: ModerationLogPayloads['resolveAbuseReport']; });