diff --git a/locales/index.d.ts b/locales/index.d.ts index 3da94d6fe..a4d8b4247 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2400,6 +2400,14 @@ export interface Locale extends ILocale { * バナーを解除しますか? */ "unsetUserBannerConfirm": string; + /** + * 相互バナーを解除 + */ + "unsetUserMutualBanner": string; + /** + * 相互バナーを解除しますか? + */ + "unsetUserMutualBannerConfirm": string; /** * すべてのファイルを削除 */ @@ -5083,6 +5091,18 @@ export interface Locale extends ILocale { * こちら */ "here": string; + /** + * 相互バナー + */ + "mutualBanner": string; + /** + * このユーザーのバナー + */ + "mutualBannerThisUser": string; + /** + * 最大 + */ + "maximum": string; "_bubbleGame": { /** * 遊び方 @@ -8212,6 +8232,10 @@ export interface Locale extends ILocale { * ユーザーのバーナーを削除する */ "write:admin:unset-user-banner": string; + /** + * ユーザーの相互バナーを削除する + */ + "write:admin:unset-user-mutual-banner": string; /** * ユーザーの凍結を解除する */ @@ -8798,6 +8822,22 @@ export interface Locale extends ILocale { * 最大{max}つまでデコレーションを付けられます。 */ "avatarDecorationMax": ParameterizedString<"max">; + /** + * 自身の相互リンクのバナーを設定 + */ + "myMutualBanner": string; + /** + * あなた自身が相互リンクのバナーとして設定してほしい画像を設定することができます。 + */ + "myMutualBannerDescription": string; + /** + * 相互リンクのバナー + */ + "mutualBanner": string; + /** + * 説明 + */ + "mutualBannerDescriptionEdit": string; }; "_exportOrImport": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7a21876d5..e4564f9dd 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -596,6 +596,8 @@ unsetUserAvatar: "アイコンを解除" unsetUserAvatarConfirm: "アイコンを解除しますか?" unsetUserBanner: "バナーを解除" unsetUserBannerConfirm: "バナーを解除しますか?" +unsetUserMutualBanner: "相互バナーを解除" +unsetUserMutualBannerConfirm: "相互バナーを解除しますか?" deleteAllFiles: "すべてのファイルを削除" deleteAllFilesConfirm: "すべてのファイルを削除しますか?" removeAllFollowing: "フォローを全解除" @@ -1266,6 +1268,9 @@ reportComplete: "通報完了" blockThisUser: "このユーザーをブロックする" muteThisUser: "このユーザーをミュートする" here: "こちら" +mutualBanner: "相互バナー" +mutualBannerThisUser: "このユーザーのバナー" +maximum: "最大" _bubbleGame: howToPlay: "遊び方" @@ -2152,6 +2157,7 @@ _permissions: "write:admin:suspend-user": "ユーザーを凍結する" "write:admin:unset-user-avatar": "ユーザーのアバターを削除する" "write:admin:unset-user-banner": "ユーザーのバーナーを削除する" + "write:admin:unset-user-mutual-banner": "ユーザーの相互バナーを削除する" "write:admin:unsuspend-user": "ユーザーの凍結を解除する" "write:admin:meta": "インスタンスのメタデータを操作する" "write:admin:user-note": "モデレーションノートを操作する" @@ -2313,6 +2319,10 @@ _profile: changeBanner: "バナー画像を変更" verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。" avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。" + myMutualBanner: "自身の相互リンクのバナーを設定" + myMutualBannerDescription: "あなた自身が相互リンクのバナーとして設定してほしい画像を設定することができます。" + mutualBanner: "相互リンクのバナー" + mutualBannerDescriptionEdit: "説明" _exportOrImport: allNotes: "全てのノート" diff --git a/packages/backend/migration/1723213482131-mutualBanner.js b/packages/backend/migration/1723213482131-mutualBanner.js new file mode 100644 index 000000000..c3bcb34ce --- /dev/null +++ b/packages/backend/migration/1723213482131-mutualBanner.js @@ -0,0 +1,27 @@ +export class MutualBanner1723213482131 { + name = 'MutualBanner1723213482131' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_banner" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "description" character varying(1024), "url" character varying(1024), "fileId" character varying(32) NOT NULL, CONSTRAINT "PK_0d9a418f048e308dbfb6562149d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_fa06ea2e2375449537ced781f1" ON "user_banner" ("userId") `); + await queryRunner.query(`CREATE TABLE "user_banner_pining" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "pinnedBannerId" character varying(32) NOT NULL, CONSTRAINT "PK_970d24f72e8d2b20f8c21ec5d11" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_3b74dc21b68da606011c81609c" ON "user_banner_pining" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_7d51b5a8ae859e0023a98837a1" ON "user_banner_pining" ("userId", "pinnedBannerId") `); + await queryRunner.query(`ALTER TABLE "user_banner" ADD CONSTRAINT "FK_fa06ea2e2375449537ced781f15" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_banner" ADD CONSTRAINT "FK_3de9f17cce2c10f6938fb261c0b" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_banner_pining" ADD CONSTRAINT "FK_3b74dc21b68da606011c81609c9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_banner_pining" ADD CONSTRAINT "FK_d13be8242980f7018d664f780f6" FOREIGN KEY ("pinnedBannerId") REFERENCES "user_banner"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_banner_pining" DROP CONSTRAINT "FK_d13be8242980f7018d664f780f6"`); + await queryRunner.query(`ALTER TABLE "user_banner_pining" DROP CONSTRAINT "FK_3b74dc21b68da606011c81609c9"`); + await queryRunner.query(`ALTER TABLE "user_banner" DROP CONSTRAINT "FK_3de9f17cce2c10f6938fb261c0b"`); + await queryRunner.query(`ALTER TABLE "user_banner" DROP CONSTRAINT "FK_fa06ea2e2375449537ced781f15"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7d51b5a8ae859e0023a98837a1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3b74dc21b68da606011c81609c"`); + await queryRunner.query(`DROP TABLE "user_banner_pining"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fa06ea2e2375449537ced781f1"`); + await queryRunner.query(`DROP TABLE "user_banner"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 3e5223944..666a462cf 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -5,6 +5,8 @@ import { Module } from '@nestjs/common'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { UserBannerEntityService } from '@/core/entities/UserBannerEntityService.js'; +import { UserBannerPiningEntityService } from '@/core/entities/UserBannerPiningEntityService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -35,6 +37,8 @@ import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; +import { UserBannerPiningService } from './UserBannerPiningService.js'; +import { UserBannerService } from './UserBannerService.js'; import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; import { PollService } from './PollService.js'; @@ -173,6 +177,8 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; +const $UserBannerPiningService: Provider = { provide: 'UserBannerPiningService', useExisting: UserBannerPiningService }; +const $UserBannerService: Provider = { provide: 'UserBannerService', useExisting: UserBannerService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; @@ -253,6 +259,8 @@ const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', use const $SigninEntityService: Provider = { provide: 'SigninEntityService', useExisting: SigninEntityService }; const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting: UserEntityService }; const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService }; +const $UserBannerEntityService: Provider = { provide: 'UserBannerEntityService', useExisting: UserBannerEntityService }; +const $UserBannerPiningEntityService: Provider = { provide: 'UserBannerPiningEntityService', useExisting: UserBannerPiningEntityService }; const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService }; const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService }; const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService }; @@ -315,6 +323,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, + UserBannerPiningService, + UserBannerService, NoteReadService, NotificationService, PollService, @@ -393,6 +403,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SigninEntityService, UserEntityService, UserListEntityService, + UserBannerEntityService, + UserBannerPiningEntityService, FlashEntityService, FlashLikeEntityService, RoleEntityService, @@ -451,6 +463,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, + $UserBannerService, + $UserBannerPiningService, $NoteReadService, $NotificationService, $PollService, @@ -529,6 +543,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SigninEntityService, $UserEntityService, $UserListEntityService, + $UserBannerEntityService, + $UserBannerPiningEntityService, $FlashEntityService, $FlashLikeEntityService, $RoleEntityService, @@ -588,6 +604,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, + UserBannerService, + UserBannerPiningService, NoteReadService, NotificationService, PollService, @@ -665,6 +683,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SigninEntityService, UserEntityService, UserListEntityService, + UserBannerEntityService, + UserBannerPiningEntityService, FlashEntityService, FlashLikeEntityService, RoleEntityService, @@ -723,6 +743,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, + $UserBannerService, + $UserBannerPiningService, $NoteReadService, $NotificationService, $PollService, @@ -800,6 +822,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SigninEntityService, $UserEntityService, $UserListEntityService, + $UserBannerEntityService, + $UserBannerPiningEntityService, $FlashEntityService, $FlashLikeEntityService, $RoleEntityService, diff --git a/packages/backend/src/core/UserBannerPiningService.ts b/packages/backend/src/core/UserBannerPiningService.ts new file mode 100644 index 000000000..1e9887678 --- /dev/null +++ b/packages/backend/src/core/UserBannerPiningService.ts @@ -0,0 +1,55 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { bindThis } from '@/decorators.js'; +import { MiUser } from '@/models/User.js'; +import { MiUserBanner } from '@/models/UserBanner.js'; +import type { MiUserBannerPining, UserBannerPiningRepository, UserBannerRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class UserBannerPiningService { + constructor( + @Inject(DI.userBannerRepository) + private userBannerRepository: UserBannerRepository, + @Inject(DI.userBannerPiningRepository) + private userBannerPiningRepository: UserBannerPiningRepository, + + private idService: IdService, + ) { + + } + + /** + * 指定したユーザーのバナーをピン留めします + * @param userId + * @param bannerIds + */ + public async addPinned(userId: MiUser['id'], bannerIds: MiUserBanner['id'][]) { + const pinsToInsert = bannerIds.map(bannerId => ({ + id: this.idService.gen(), + userId, + pinnedBannerId: bannerId, + } as MiUserBannerPining)); + await this.userBannerPiningRepository + .createQueryBuilder() + .insert() + .values(pinsToInsert) + .orIgnore() + .execute(); + } + + /** + * 指定したユーザーのバナーのピン留めを解除します + * @param userId + * @param bannerIds + */ + @bindThis + public async removePinned(userId:MiUser['id'], bannerIds:MiUserBanner['id'][]) { + await this.userBannerPiningRepository.delete({ + userId, + pinnedBannerId: In(bannerIds), + }); + } +} diff --git a/packages/backend/src/core/UserBannerService.ts b/packages/backend/src/core/UserBannerService.ts new file mode 100644 index 000000000..ff381988f --- /dev/null +++ b/packages/backend/src/core/UserBannerService.ts @@ -0,0 +1,115 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { MiUser } from '@/models/User.js'; +import { MiUserBanner } from '@/models/UserBanner.js'; +import type { DriveFilesRepository, MiDriveFile, UserBannerRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class UserBannerService { + constructor( + @Inject(DI.userBannerRepository) + private userBannerRepository: UserBannerRepository, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private idService: IdService, + ) { + + } + + /** + * 指定したユーザーのバナーを作成します + * @param userId + * @param description + * @param url + * @param fileId + */ + @bindThis + public async create(userId: MiUser['id'], description: string | null, url: string, fileId: MiDriveFile['id']) { + const banner = await this.userBannerRepository.findOneBy({ + userId, + }); + + if (banner) throw new IdentifiableError('9dab45d9-cc66-4dfa-8305-610834e7f256', 'Already exists.'); + + const file = await this.driveFilesRepository.findOneBy({ + id: fileId, + }); + + if (file == null) throw new IdentifiableError('e61187d1-9270-426b-8dc6-6b233c545133', 'No such file.'); + + return await this.userBannerRepository.insert({ + id: this.idService.gen(), + userId, + description: description ?? null, + fileId: file.id, + url: url, + } as MiUserBanner); + } + + /** + * 指定したユーザーのバナーを更新します + * @param userId + * @param bannerId + * @param description + * @param url + * @param fileId + */ + @bindThis + public async update(userId: MiUser['id'], bannerId: MiUserBanner['id'], description: string | null, url: string | null, fileId: MiDriveFile['id'] ) { + const banner = await this.userBannerRepository.findOneBy({ + id: bannerId, + }); + + if (banner == null) { + throw new IdentifiableError('ac26da32-1659-4fbb-82c2-fc11a494799f', 'No such banner.'); + } + + if (banner.userId !== userId) { + throw new IdentifiableError('dfe79730-96f7-4d65-8c2a-b0975bf3524c', 'Not this user banner.'); + } + + const file = await this.driveFilesRepository.findOneBy({ + id: fileId, + }); + + if (file == null) { + throw new IdentifiableError('e61187d1-9270-426b-8dc6-6b233c545133', 'No such file.'); + } + + await this.userBannerRepository.update({ + id: bannerId, + }, { + description: description ?? null, + fileId: file.id, + url: url ?? null, + }); + } + + /** + * 指定したユーザーのバナー削除します + * @param userId + * @param bannerId + */ + @bindThis + public async delete(userId: MiUser['id'], bannerId: MiUserBanner['id']) { + const banner = await this.userBannerRepository.findOneBy({ + id: bannerId, + }); + + if (banner == null) { + throw new IdentifiableError('f4b158a5-610f-4ed3-b228-3507ebe1bba6', 'No such banner.'); + } + + if (banner.userId !== userId) { + throw new IdentifiableError('ad84053d-0cf4-4446-ac72-209adef15835', 'Not this user banner.'); + } + + await this.userBannerRepository.delete({ + id: bannerId, + }); + } +} diff --git a/packages/backend/src/core/entities/UserBannerEntityService.ts b/packages/backend/src/core/entities/UserBannerEntityService.ts new file mode 100644 index 000000000..25f4bb27b --- /dev/null +++ b/packages/backend/src/core/entities/UserBannerEntityService.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { bindThis } from '@/decorators.js'; +import type { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import type { DriveFilesRepository, MiUserBanner, UserBannerRepository } from '@/models/_.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { Packed } from '@/misc/json-schema.js'; + +@Injectable() +export class UserBannerEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + constructor( + @Inject(DI.userBannerRepository) + private userBannerRepository: UserBannerRepository, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + private moduleRef: ModuleRef, + ) { + } + + async onModuleInit() { + this.userEntityService = this.moduleRef.get(UserEntityService.name); + } + + @bindThis + public async pack( + src: MiUserBanner | MiUserBanner['id'] | null | undefined, + me: { id: MiUser['id'] } | null | undefined, + ): Promise> { + if (!src) throw new IdentifiableError('9dab45d9-cc66-4dfa-8305-610834e7f256', 'No such banner.'); + + const banner = typeof src === 'object' ? src : await this.userBannerRepository.findOneByOrFail({ id: src }); + const file = await this.driveFilesRepository.findOneByOrFail({ id: banner.fileId }); + + return { + id: banner.id, + user: await this.userEntityService.pack(banner.userId, me), + description: banner.description, + imgUrl: file.url, + url: banner.url, + fileId: file.id, + }; + } + + @bindThis + public async packMany( + src: MiUserBanner[] | MiUserBanner['id'][], + me: { id: MiUser['id'] } | null | undefined, + ): Promise[]> { + return (await Promise.allSettled(src.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/core/entities/UserBannerPiningEntityService.ts b/packages/backend/src/core/entities/UserBannerPiningEntityService.ts new file mode 100644 index 000000000..b550875c9 --- /dev/null +++ b/packages/backend/src/core/entities/UserBannerPiningEntityService.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiUserBannerPining } from '@/models/_.js'; +import { UserBannerEntityService } from '@/core/entities/UserBannerEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; + +@Injectable() +export class UserBannerPiningEntityService { + constructor( + private userBannerEntityService: UserBannerEntityService, + ) {} + + @bindThis + public async packMany( + src: MiUserBannerPining[], + me: { id: MiUser['id'] } | null | undefined, + ) : Promise[]> { + return (await Promise.allSettled(src.map(pining => this.userBannerEntityService.pack(pining.pinnedBannerId, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 4a1368576..96e56dac7 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -28,16 +28,20 @@ import type { FollowingsRepository, FollowRequestsRepository, MiFollowing, + MiUserBanner, MiUserNotePining, MiUserProfile, MutingsRepository, NoteUnreadsRepository, RenoteMutingsRepository, + UserBannerRepository, + UserBannerPiningRepository, UserMemoRepository, UserNotePiningsRepository, UserProfilesRepository, UserSecurityKeysRepository, UsersRepository, + MiUserBannerPining, } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -49,9 +53,10 @@ import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { UserBannerEntityService } from '@/core/entities/UserBannerEntityService.js'; +import { UserBannerPiningEntityService } from '@/core/entities/UserBannerPiningEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; -import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; const Ajv = _Ajv.default; @@ -130,11 +135,19 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, + @Inject(DI.userBannerRepository) + private userBannerRepository: UserBannerRepository, + @Inject(DI.userBannerPiningRepository) + private userBannerPiningRepository: UserBannerPiningRepository, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, @Inject(DI.userMemosRepository) private userMemosRepository: UserMemoRepository, + + private userBannerEntityService: UserBannerEntityService, + private userBannerPiningEntityService: UserBannerPiningEntityService, ) { } @@ -444,6 +457,8 @@ export class UserEntityService implements OnModuleInit { } let pins: MiUserNotePining[] = []; + let myMutualBanner: MiUserBanner | null = null; + let mutualBanners: MiUserBannerPining[] = []; if (isDetailed) { if (opts.pinNotes) { pins = opts.pinNotes.get(user.id) ?? []; @@ -454,6 +469,12 @@ export class UserEntityService implements OnModuleInit { .orderBy('pin.id', 'DESC') .getMany(); } + if (user.id) { + [myMutualBanner, mutualBanners] = await Promise.all([ + this.userBannerRepository.findOneBy({ userId: user.id }), + this.userBannerPiningRepository.findBy({ userId: user.id }), + ]); + } } const followingCount = profile == null ? null : @@ -534,6 +555,8 @@ export class UserEntityService implements OnModuleInit { lang: profile!.lang, fields: profile!.fields, verifiedLinks: profile!.verifiedLinks, + mutualBanners: mutualBanners.length > 0 ? this.userBannerPiningEntityService.packMany(mutualBanners, me) : [], + myMutualBanner: myMutualBanner ? this.userBannerEntityService.pack(myMutualBanner, me) : null, followersCount: followersCount ?? 0, followingCount: followingCount ?? 0, notesCount: user.notesCount, @@ -563,7 +586,7 @@ export class UserEntityService implements OnModuleInit { isModerator: role.isModerator, isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, - })) + })), ), memo: memo, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, @@ -704,7 +727,7 @@ export class UserEntityService implements OnModuleInit { } } - return (await Promise.allSettled(_users.map(u => this.pack(u, me, { ...options, userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes })))) + return (await Promise.allSettled(_users.map(u => this.pack(u, me, { ...options, userProfile: profilesMap.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes })))) .filter(result => result.status === 'fulfilled') .map(result => (result as PromiseFulfilledResult>).value); } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index fb570d0b4..2d2b231e2 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -28,6 +28,8 @@ export const DI = { pollsRepository: Symbol('pollsRepository'), pollVotesRepository: Symbol('pollVotesRepository'), userProfilesRepository: Symbol('userProfilesRepository'), + userBannerRepository: Symbol('userBannerRepository'), + userBannerPiningRepository: Symbol('userBannerPiningRepository'), userKeypairsRepository: Symbol('userKeypairsRepository'), userPendingsRepository: Symbol('userPendingsRepository'), userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 27fd280a8..9f2b52577 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -11,6 +11,7 @@ import { packedMeDetailedSchema, packedUserDetailedSchema, packedUserSchema, + packedUserBannerSchema, } from '@/models/json-schema/user.js'; import { packedAbuseUserReportSchema } from '@/models/json-schema/abuse-user-report.js'; import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; @@ -68,6 +69,7 @@ export const refs = { MeDetailed: packedMeDetailedSchema, UserDetailed: packedUserDetailedSchema, User: packedUserSchema, + UserBanner: packedUserBannerSchema, UserList: packedUserListSchema, UserListMembership: packedUserListMembershipSchema, diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index a61094500..0910ad74c 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -76,6 +76,8 @@ import { MiWebhook, MiBubbleGameRecord, MiReversiGame, + MiUserBannerPining, + MiUserBanner, } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -206,6 +208,18 @@ const $userNotePiningsRepository: Provider = { inject: [DI.db], }; +const $userBannerRepository: Provider = { + provide: DI.userBannerRepository, + useFactory: (db: DataSource) => db.getRepository(MiUserBanner), + inject: [DI.db], +}; + +const $userBannerPiningRepository: Provider = { + provide: DI.userBannerPiningRepository, + useFactory: (db: DataSource) => db.getRepository(MiUserBannerPining), + inject: [DI.db], +}; + const $userIpsRepository: Provider = { provide: DI.userIpsRepository, useFactory: (db: DataSource) => db.getRepository(MiUserIp), @@ -525,6 +539,8 @@ const $abuseReportResolversRepository: Provider = { $userListFavoritesRepository, $userListMembershipsRepository, $userNotePiningsRepository, + $userBannerPiningRepository, + $userBannerRepository, $userIpsRepository, $usedUsernamesRepository, $followingsRepository, @@ -597,6 +613,8 @@ const $abuseReportResolversRepository: Provider = { $userListFavoritesRepository, $userListMembershipsRepository, $userNotePiningsRepository, + $userBannerPiningRepository, + $userBannerRepository, $userIpsRepository, $usedUsernamesRepository, $followingsRepository, diff --git a/packages/backend/src/models/UserBanner.ts b/packages/backend/src/models/UserBanner.ts new file mode 100644 index 000000000..108587b02 --- /dev/null +++ b/packages/backend/src/models/UserBanner.ts @@ -0,0 +1,42 @@ +import { Entity, Column, Index, JoinColumn, PrimaryColumn, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiDriveFile } from './DriveFile.js'; + +@Entity('user_banner') +export class MiUserBanner { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public description: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public url: string | null; + + @Column({ + ...id(), + }) + public fileId: MiDriveFile['id']; + + @ManyToOne(type => MiDriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public file: MiDriveFile; +} diff --git a/packages/backend/src/models/UserBannerPining.ts b/packages/backend/src/models/UserBannerPining.ts new file mode 100644 index 000000000..754de012e --- /dev/null +++ b/packages/backend/src/models/UserBannerPining.ts @@ -0,0 +1,32 @@ +import { Entity, Column, Index, JoinColumn, PrimaryColumn, ManyToOne, OneToOne } from 'typeorm'; +import { MiUserBanner } from '@/models/UserBanner.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('user_banner_pining') +@Index(['userId', 'pinnedBannerId'], { unique: true }) +export class MiUserBannerPining { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column({ + ...id(), + }) + public pinnedBannerId: MiUserBanner['id']; + + @ManyToOne(type => MiUserBanner, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public pinnedBanner: MiUserBanner; +} diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 3865c6cb1..5f1d9611e 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; +import { followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index ca1410c24..7e4455bb9 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -60,6 +60,8 @@ import { MiUserListMembership } from '@/models/UserListMembership.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; +import { MiUserBanner } from '@/models/UserBanner.js'; +import { MiUserBannerPining } from '@/models/UserBannerPining.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiUserMemo } from '@/models/UserMemo.js'; @@ -134,6 +136,8 @@ export { MiUserNotePining, MiUserPending, MiUserProfile, + MiUserBanner, + MiUserBannerPining, MiUserPublickey, MiUserSecurityKey, MiWebhook, @@ -206,6 +210,8 @@ export type UserListMembershipsRepository = Repository; export type UserNotePiningsRepository = Repository; export type UserPendingsRepository = Repository; export type UserProfilesRepository = Repository; +export type UserBannerRepository = Repository; +export type UserBannerPiningRepository = Repository; export type UserPublickeysRepository = Repository; export type UserSecurityKeysRepository = Repository; export type WebhooksRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 4531fcee0..18f9c8124 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -179,7 +179,7 @@ export const packedUserLiteSchema = { behavior: { type: 'string', nullable: false, optional: true, - } + }, }, }, }, @@ -428,6 +428,80 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + mutualBanners: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + id: { + type: 'string', + format: 'id', + nullable: false, optional: false, + }, + user: { + type: 'object', + nullable: false, optional: false, + ref: 'UserLite', + }, + description: { + type: 'string', + nullable: true, optional: false, + }, + imgUrl: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + url: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + fileId: { + type: 'string', + format: 'id', + nullable: false, optional: false, + }, + }, + }, + }, + myMutualBanner: { + type: 'object', + nullable: true, optional: false, + properties: { + id: { + type: 'string', + format: 'id', + nullable: false, optional: false, + }, + user: { + type: 'object', + nullable: false, optional: false, + ref: 'UserLite', + }, + description: { + type: 'string', + nullable: true, optional: false, + }, + imgUrl: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + url: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + fileId: { + type: 'string', + format: 'id', + nullable: false, optional: false, + }, + }, + }, //#endregion }, } as const; @@ -713,3 +787,37 @@ export const packedUserSchema = { }, ], } as const; + +export const packedUserBannerSchema = { + type: 'object', + properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + user: { + type: 'object', + nullable: false, optional: false, + ref: 'UserLite', + }, + description: { + type: 'string', + nullable: true, optional: false, + }, + imgUrl: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + url: { + type: 'string', + nullable: true, optional: false, + }, + fileId: { + type: 'string', + format: 'id', + nullable: false, optional: false, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index dfa5d86e1..bbf93eb3c 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -83,6 +83,8 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiUserBanner } from '@/models/UserBanner.js'; +import { MiUserBannerPining } from '@/models/UserBannerPining.js'; import { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; @@ -203,6 +205,8 @@ export const entities = [ MiFlash, MiFlashLike, MiUserMemo, + MiUserBanner, + MiUserBannerPining, MiBubbleGameRecord, MiReversiGame, ...charts, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index fcd5059a5..75d33b15f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -31,6 +31,7 @@ import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-dec import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; +import * as ep___admin_unsetUserMutualBanner from './endpoints/admin/unset-user-mutual-banner.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'; import * as ep___admin_drive_deleteAllFilesOfAUser from './endpoints/admin/drive/delete-all-files-of-a-user.js'; @@ -421,6 +422,7 @@ const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-deco const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; +const $admin_unsetUserMutualBanner: Provider = { provide: 'ep:admin/unset-user-mutual-banner', useClass: ep___admin_unsetUserMutualBanner.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 }; const $admin_drive_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/drive/delete-all-files-of-a-user', useClass: ep___admin_drive_deleteAllFilesOfAUser.default }; @@ -815,6 +817,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_update, $admin_unsetUserAvatar, $admin_unsetUserBanner, + $admin_unsetUserMutualBanner, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, $admin_drive_deleteAllFilesOfAUser, @@ -1203,6 +1206,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_update, $admin_unsetUserAvatar, $admin_unsetUserBanner, + $admin_unsetUserMutualBanner, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, $admin_drive_deleteAllFilesOfAUser, diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index a549e48f2..68f3d4c0f 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -18,6 +18,17 @@ const ajv = new Ajv({ }); ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); +ajv.addFormat('url', { + type: 'string', + validate: (url: string) => { + try { + new URL(url); + return true; + } catch (e) { + return false; + } + }, +}); export type Response = Record | void; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 57322d3d5..86e417382 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -31,6 +31,7 @@ import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-dec import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; +import * as ep___admin_unsetUserMutualBanner from './endpoints/admin/unset-user-mutual-banner.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'; import * as ep___admin_drive_deleteAllFilesOfAUser from './endpoints/admin/drive/delete-all-files-of-a-user.js'; @@ -419,6 +420,7 @@ const eps = [ ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], ['admin/unset-user-banner', ep___admin_unsetUserBanner], + ['admin/unset-user-mutual-banner', ep___admin_unsetUserMutualBanner], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], ['admin/drive/cleanup', ep___admin_drive_cleanup], ['admin/drive/delete-all-files-of-a-user', ep___admin_drive_deleteAllFilesOfAUser], diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-mutual-banner.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-mutual-banner.ts new file mode 100644 index 000000000..139a5e020 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-mutual-banner.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserBannerRepository, UsersRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:unset-user-mutual-banner', +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userBannerRepository) + private userBannerRepository: UserBannerRepository, + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + const mutualBanner = await this.userBannerRepository.findOneBy({ userId: user.id }); + + if (mutualBanner == null) return; + + await this.userBannerRepository.delete({ + id: mutualBanner.id, + }); + + this.moderationLogService.log(me, 'unsetUserMutualBanner', { + userId: user.id, + userUsername: user.username, + userBannerDescription: mutualBanner.description, + userBannerUrl: mutualBanner.url, + fileId: mutualBanner.fileId, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5ff035ec4..6892660b2 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -11,7 +11,14 @@ import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; -import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; +import type { + UsersRepository, + DriveFilesRepository, + UserProfilesRepository, + PagesRepository, + UserBannerRepository, + UserBannerPiningRepository, +} from '@/models/_.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; @@ -33,6 +40,8 @@ import type { Config } from '@/config.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; +import { UserBannerService } from '@/core/UserBannerService.js'; +import { UserBannerPiningService } from '@/core/UserBannerPiningService.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -56,6 +65,12 @@ export const meta = { id: '539f3a45-f215-4f81-a9a8-31293640207f', }, + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e0f0d3c7-e704-4314-a0b5-04286d69a65c', + }, + noSuchBanner: { message: 'No such banner file.', code: 'NO_SUCH_BANNER', @@ -68,6 +83,12 @@ export const meta = { id: 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191', }, + fileNotAnImage: { + message: 'The specified file is not an image.', + code: 'FILE_NOT_AN_IMAGE', + id: '2851568b-5ad1-4031-bf0d-5320afebf3a9', + }, + bannerNotAnImage: { message: 'The file specified as a banner is not an image.', code: 'BANNER_NOT_AN_IMAGE', @@ -178,8 +199,8 @@ export const paramDef = { mutedWords: { type: 'array', items: { oneOf: [ { type: 'array', items: { type: 'string' } }, - { type: 'string' } - ] + { type: 'string' }, + ], } }, mutedInstances: { type: 'array', items: { type: 'string', @@ -213,6 +234,24 @@ export const paramDef = { uniqueItems: true, items: { type: 'string' }, }, + mutualBannerPining: { + type: 'array', + nullable: true, + items: { + type: 'string', + format: 'misskey:id', + }, + }, + myMutualBanner: { + type: 'object', + nullable: true, + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + description: { type: 'string' }, + url: { type: 'string', nullable: true, format: 'url' }, + }, + required: ['fileId'], + }, }, } as const; @@ -231,10 +270,17 @@ export default class extends Endpoint { // eslint- @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + @Inject(DI.userBannerRepository) + private userBannerRepository: UserBannerRepository, + @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + @Inject(DI.userBannerPiningRepository) + private userBannerPiningRepository: UserBannerPiningRepository, + private userEntityService: UserEntityService, + private userBannerService: UserBannerService, private driveFileEntityService: DriveFileEntityService, private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, @@ -246,6 +292,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private httpRequestService: HttpRequestService, private avatarDecorationService: AvatarDecorationService, + private userBannerPiningService: UserBannerPiningService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -322,6 +369,50 @@ export default class extends Endpoint { // eslint- updates.avatarBlurhash = null; } + if (ps.mutualBannerPining) { + const bannerPiningNow = await this.userBannerPiningRepository.findBy({ userId: user.id }); + + const bannerPiningNowIds = new Set(bannerPiningNow.map(b => b.pinnedBannerId)); + const mutualBannerPiningIds = new Set(ps.mutualBannerPining); + + const bannersToAdd = [...mutualBannerPiningIds].filter(bannerId => !bannerPiningNowIds.has(bannerId)); + const bannersToRemove = [...bannerPiningNowIds].filter(bannerId => !mutualBannerPiningIds.has(bannerId)); + + if (bannersToAdd.length > 0) { + await this.userBannerPiningService.addPinned(user.id, bannersToAdd); + } + + if (bannersToRemove.length > 0) { + await this.userBannerPiningService.removePinned(user.id, bannersToRemove); + } + } + + if (ps.myMutualBanner) { + const banner = await this.userBannerRepository.findOneBy({ + userId: user.id, + }); + const file = await this.driveFilesRepository.findOneBy({ id: ps.myMutualBanner.fileId }); + const profileUrl = this.config.url + '/@' + user.username; + + if (file === null) throw new ApiError(meta.errors.noSuchFile); + if (!file.type.startsWith('image/')) throw new ApiError(meta.errors.fileNotAnImage); + + if (banner) { + await this.userBannerService.update(user.id, banner.id, ps.myMutualBanner.description ?? null, ps.myMutualBanner.url ?? profileUrl, ps.myMutualBanner.fileId); + } else { + await this.userBannerService.create(user.id, ps.myMutualBanner.description ?? null, ps.myMutualBanner.url ?? profileUrl, ps.myMutualBanner.fileId); + } + } + + if (ps.myMutualBanner === null) { + const banner = await this.userBannerRepository.findOneBy({ + userId: user.id, + }); + if (banner) { + await this.userBannerService.delete(user.id, banner.id); + } + } + if (ps.bannerId) { if (!policy.canUpdateBanner) throw new ApiError(meta.errors.restrictedByRole); const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 81529d324..fc51d2857 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -96,6 +96,7 @@ export const moderationLogTypes = [ 'deleteAvatarDecoration', 'unsetUserAvatar', 'unsetUserBanner', + 'unsetUserMutualBanner', ] as const; export type ModerationLogPayloads = { @@ -314,6 +315,13 @@ export type ModerationLogPayloads = { userHost: string | null; fileId: string; }; + unsetUserMutualBanner: { + userId: string; + userUsername: string; + userBannerDescription: string | null; + userBannerUrl: string | null; + fileId: string; + } }; export type Serialized = { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index c3c2add71..3f0f10591 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -74,6 +74,8 @@ describe('ユーザー', () => { lang: user.lang, fields: user.fields, verifiedLinks: user.verifiedLinks, + myMutualBanner: user.myMutualBanner, + mutualBanners: user.mutualBanners, followersCount: user.followersCount, followingCount: user.followingCount, notesCount: user.notesCount, diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 15cf16e16..36167e22e 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only @click="(ev: MouseEvent) => warningExternalWebsite(ev, url)" > - + @@ -34,7 +34,9 @@ const props = withDefaults(defineProps<{ url: string; rel?: null | string; navigationBehavior?: MkABehavior; + hideIcon?: boolean; }>(), { + hideIcon: false, }); const self = props.url.startsWith(local); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index f842d5290..3282eb5c3 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -66,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.resetPassword }} {{ i18n.ts.unsetUserAvatar }} {{ i18n.ts.unsetUserBanner }} + {{ i18n.ts.unsetUserMutualBanner }} @@ -292,7 +293,7 @@ function createFetcher() { watch(moderationNote, async () => { await misskeyApi('admin/update-user-note', { - userId: user.value.id, text: moderationNote.value + userId: user.value.id, text: moderationNote.value, }).then(refreshUser); }); }); @@ -304,7 +305,7 @@ function refreshUser() { async function updateRemoteUser() { await os.apiWithDialog('federation/update-remote-user', { - userId: user.value.id + userId: user.value.id, }).then(refreshUser); } @@ -335,7 +336,7 @@ async function toggleSuspend(v) { suspended.value = !v; } else { await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { - userId: user.value.id + userId: user.value.id, }).then(refreshUser); } } @@ -348,7 +349,7 @@ async function unsetUserAvatar() { if (confirm.canceled) return; await os.apiWithDialog('admin/unset-user-avatar', { - userId: user.value.id + userId: user.value.id, }).then(refreshUser); } @@ -360,7 +361,19 @@ async function unsetUserBanner() { if (confirm.canceled) return; await os.apiWithDialog('admin/unset-user-banner', { - userId: user.value.id + userId: user.value.id, + }).then(refreshUser); +} + +async function unsetUserMutualBanner() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unsetUserMutualBannerConfirm, + }); + if (confirm.canceled) return; + + await os.apiWithDialog('admin/unset-user-mutual-banner', { + userId: user.value.id, }).then(refreshUser); } @@ -378,7 +391,7 @@ async function deleteAllFiles() { if (typed.result === user.value?.username) { await os.apiWithDialog('admin/drive/delete-all-files-of-a-user', { - userId: user.value.id + userId: user.value.id, }).then(refreshUser); } else { os.alert({ @@ -447,7 +460,7 @@ async function assignRole() { : null; await os.apiWithDialog('admin/roles/assign', { - roleId, userId: user.value.id, expiresAt + roleId, userId: user.value.id, expiresAt, }).then(refreshUser); } @@ -458,7 +471,7 @@ async function unassignRole(role, ev) { danger: true, action: async () => { await os.apiWithDialog('admin/roles/unassign', { - roleId: role.id, userId: user.value.id + roleId: role.id, userId: user.value.id, }).then(refreshUser); }, }], ev.currentTarget ?? ev.target); diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 60bf9b4d3..b13b91f8e 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -87,6 +87,33 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + +
+

{{ i18n.ts._profile.mutualBanner }}

+ + {{ i18n.ts.selectFile }} +
+ + {{ i18n.ts.save }} + {{ i18n.ts.delete }} +
+ +
@@ -152,6 +179,12 @@ watch(() => profile, () => { }); const fields = ref($i.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []); +const myMutualBanner = ref<{ fileId: string; description?: string; url?: string | null; imgUrl?: string; }>({ + fileId: $i.myMutualBanner?.fileId ?? '', + description: $i.myMutualBanner?.description ?? '', + url: $i.myMutualBanner?.url ?? '', + imgUrl: $i.myMutualBanner?.imgUrl ?? '', +}); const fieldEditMode = ref(false); function addField() { @@ -177,6 +210,40 @@ function saveFields() { globalEvents.emit('requestClearPageCache'); } +function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch (_) { + return false; + } +} + +function saveMyMutualBanner() { + if ( myMutualBanner.value.fileId === '' || myMutualBanner.value.url && !isValidUrl(myMutualBanner.value.url)) { + os.alert({ + type: 'error', + title: i18n.ts.invalidParamError, + text: i18n.ts.invalidParamErrorDescription, + }); + return; + } + os.apiWithDialog('i/update', { + myMutualBanner: { + fileId: myMutualBanner.value.fileId, + description: myMutualBanner.value.description, + url: myMutualBanner.value.url === '' ? null : myMutualBanner.value.url, + }, + }); +} + +function deleteMyMutualBanner() { + os.apiWithDialog('i/update', { + myMutualBanner: null, + }); + myMutualBanner.value = { fileId: '', description: '', url: '' }; +} + function save() { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな @@ -203,6 +270,13 @@ function save() { } } +function changeMutualBannerFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.mutualBanner).then(async (file) => { + myMutualBanner.value.imgUrl = file.url; + myMutualBanner.value.fileId = file.id; + }); +} + function changeAvatar(ev) { selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { let originalOrCropped = file; @@ -350,4 +424,13 @@ definePageMetadata(() => ({ .dragItemForm { flex-grow: 1; } + +.mutualBannerImg { + max-width: 300px; + min-width: 200px; + max-height: 60px; + min-height: 40px; + object-fit: contain; +} + diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 727e5e8af..426e5b337 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -143,6 +143,28 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ {{ i18n.ts.mutualBannerThisUser }} + +
+ + + + {{ (user.myMutualBanner?.description === '' || user.myMutualBanner?.description === null) ? i18n.ts.noDescription : user.myMutualBanner?.description }} + {{ i18n.ts.follow }} + {{ i18n.ts.unfollow }} +
+
+
+ {{ i18n.ts.mutualBanner }} +
+
+ + + +
+
+
{{ number(user.notesCount) }} @@ -212,6 +234,7 @@ import { confetti } from '@/scripts/confetti.js'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { useRouter } from '@/router/supplier.js'; +import MkLink from '@/components/MkLink.vue'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -253,6 +276,7 @@ const memoDraft = ref(props.user.memo); const isEditingMemo = ref(false); const moderationNote = ref(props.user.moderationNote); const editModerationNote = ref(false); +const mutualBanners = ref(props.user.mutualBanners); watch(moderationNote, async () => { await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value }); @@ -299,6 +323,23 @@ function showMemoTextarea() { }); } +function mutualBannerFollow(id: string) { + os.apiWithDialog('i/update', { + mutualBannerPining: [ + ...($i?.mutualBanners?.map(banner => banner.id) ?? []), + id, + ], + }); +} + +function mutualBannerUnFollow(id:string) { + os.apiWithDialog('i/update', { + mutualBannerPining: [ + ...($i?.mutualBanners?.map(banner => banner.id) ?? []).filter(bannerId => bannerId !== id), + ], + }); +} + function adjustMemoTextarea() { if (!memoTextareaEl.value) return; memoTextareaEl.value.style.height = '0px'; @@ -787,4 +828,28 @@ onUnmounted(() => { color: rgb(255, 255, 255); background-color: rgb(54, 54, 54); } + +.myMutualBanner { + display: flex; + justify-content: space-around; + align-items: center; + flex-flow: column wrap; + padding: 16px; +} + +.mutualBanner { + display: flex; + justify-content: space-around; + flex-wrap: wrap; + padding: 16px; +} + +.mutualBannerImg { + max-width: 300px; + min-width: 200px; + max-height: 60px; + min-height: 40px; + object-fit: contain; +} + diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 7b27ebb8a..01f0f027c 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -11,9 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only + - + +
+
+
+ + + +

{{ (mutualBanner.description === '' || mutualBanner.description === null) ? i18n.ts.noDescription : mutualBanner.description }}

+ {{ i18n.ts.unfollow }} +
+
+
+

{{ i18n.ts.nothing }}

+
+
@@ -23,11 +38,17 @@ import * as Misskey from 'misskey-js'; import MkNotes from '@/components/MkNotes.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; +import MkLink from '@/components/MkLink.vue'; +import MkButton from '@/components/MkButton.vue'; +import { $i } from '@/account.js'; +import * as os from '@/os.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); +const mutualBanners = ref(props.user.mutualBanners); + const tab = ref('all'); const pagination = computed(() => tab.value === 'featured' ? { @@ -47,6 +68,17 @@ const pagination = computed(() => tab.value === 'featured' ? { withChannelNotes: true, }, }); + +function mutualBannerUnFollow(id:string) { + os.apiWithDialog('i/update', { + mutualBannerPining: [ + ...($i?.mutualBanners?.map(banner => banner.id) ?? []).filter(bannerId => bannerId !== id), + ], + }); + if (mutualBanners.value) { + mutualBanners.value = mutualBanners.value.filter(banner => banner.id !== id); + } +} diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 3ad240bb1..e26970d38 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -376,6 +376,9 @@ type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requ // @public (undocumented) type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminUnsetUserMutualBannerRequest = operations['admin___unset-user-mutual-banner']['requestBody']['content']['application/json']; + // @public (undocumented) type AdminUnsuspendUserRequest = operations['admin___unsuspend-user']['requestBody']['content']['application/json']; @@ -1232,6 +1235,7 @@ declare namespace entities { AdminAvatarDecorationsUpdateRequest, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, + AdminUnsetUserMutualBannerRequest, AdminDriveDeleteAllFilesOfAUserRequest, AdminDriveFilesRequest, AdminDriveFilesResponse, @@ -1783,6 +1787,7 @@ declare namespace entities { MeDetailed, UserDetailed, User, + UserBanner, UserList, UserListMembership, Ad, @@ -2502,7 +2507,7 @@ type ModerationLog = { }); // @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", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createIndieAuthClient", "updateIndieAuthClient", "deleteIndieAuthClient", "createSSOServiceProvider", "updateSSOServiceProvider", "deleteSSOServiceProvider", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner"]; +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", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createIndieAuthClient", "updateIndieAuthClient", "deleteIndieAuthClient", "createSSOServiceProvider", "updateSSOServiceProvider", "deleteSSOServiceProvider", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "unsetUserMutualBanner"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; @@ -2754,7 +2759,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content'] function parse(acct: string): Acct; // @public (undocumented) -export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; +export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unset-user-mutual-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; // @public (undocumented) type PingResponse = operations['ping']['responses']['200']['content']['application/json']; @@ -3053,6 +3058,9 @@ function toString_2(acct: Acct): string; // @public (undocumented) type User = components['schemas']['User']; +// @public (undocumented) +type UserBanner = components['schemas']['UserBanner']; + // @public (undocumented) type UserDetailed = components['schemas']['UserDetailed']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 3d65d9ddb..9419a50f9 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -278,6 +278,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:unset-user-mutual-banner* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 518d5f0e0..a1cbb26e4 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -37,6 +37,7 @@ import type { AdminAvatarDecorationsUpdateRequest, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, + AdminUnsetUserMutualBannerRequest, AdminDriveDeleteAllFilesOfAUserRequest, AdminDriveFilesRequest, AdminDriveFilesResponse, @@ -608,6 +609,7 @@ export type Endpoints = { 'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse }; 'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse }; 'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse }; + 'admin/unset-user-mutual-banner': { req: AdminUnsetUserMutualBannerRequest; res: EmptyResponse }; 'admin/drive/clean-remote-files': { req: EmptyRequest; res: EmptyResponse }; 'admin/drive/cleanup': { req: EmptyRequest; res: EmptyResponse }; 'admin/drive/delete-all-files-of-a-user': { req: AdminDriveDeleteAllFilesOfAUserRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 595977631..24080b7d7 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -40,6 +40,7 @@ export type AdminAvatarDecorationsListResponse = operations['admin___avatar-deco export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json']; export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json']; +export type AdminUnsetUserMutualBannerRequest = operations['admin___unset-user-mutual-banner']['requestBody']['content']['application/json']; export type AdminDriveDeleteAllFilesOfAUserRequest = operations['admin___drive___delete-all-files-of-a-user']['requestBody']['content']['application/json']; export type AdminDriveFilesRequest = operations['admin___drive___files']['requestBody']['content']['application/json']; export type AdminDriveFilesResponse = operations['admin___drive___files']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 6a8eccbf4..b3b2f3282 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -7,6 +7,7 @@ export type UserDetailedNotMe = components['schemas']['UserDetailedNotMe']; export type MeDetailed = components['schemas']['MeDetailed']; export type UserDetailed = components['schemas']['UserDetailed']; export type User = components['schemas']['User']; +export type UserBanner = components['schemas']['UserBanner']; export type UserList = components['schemas']['UserList']; export type UserListMembership = components['schemas']['UserListMembership']; export type Ad = components['schemas']['Ad']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 72b84f2db..709bb5ec7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -237,6 +237,15 @@ export type paths = { */ post: operations['admin___unset-user-banner']; }; + '/admin/unset-user-mutual-banner': { + /** + * admin/unset-user-mutual-banner + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:unset-user-mutual-banner* + */ + post: operations['admin___unset-user-mutual-banner']; + }; '/admin/drive/clean-remote-files': { /** * admin/drive/clean-remote-files @@ -3844,6 +3853,30 @@ export type components = { /** @enum {string} */ notify?: 'normal' | 'none'; withReplies?: boolean; + mutualBanners: (({ + /** Format: id */ + id: string; + user: components['schemas']['UserLite']; + description: string | null; + /** Format: url */ + imgUrl: string; + /** Format: url */ + url: string; + /** Format: id */ + fileId: string; + })[]) | null; + myMutualBanner: ({ + /** Format: id */ + id: string; + user: components['schemas']['UserLite']; + description: string | null; + /** Format: url */ + imgUrl: string; + /** Format: url */ + url: string; + /** Format: id */ + fileId: string; + }) | null; }; MeDetailedOnly: { /** Format: id */ @@ -4028,6 +4061,17 @@ export type components = { MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly']; UserDetailed: components['schemas']['UserDetailedNotMe'] | components['schemas']['MeDetailed']; User: components['schemas']['UserLite'] | components['schemas']['UserDetailed']; + UserBanner: { + /** Format: id */ + id: string; + user: components['schemas']['UserLite']; + description: string | null; + /** Format: url */ + imgUrl: string; + url: string | null; + /** Format: id */ + fileId: string; + }; UserList: { /** * Format: id @@ -6819,6 +6863,58 @@ export type operations = { }; }; }; + /** + * admin/unset-user-mutual-banner + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:unset-user-mutual-banner* + */ + 'admin___unset-user-mutual-banner': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + userId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/drive/clean-remote-files * @description No description provided. @@ -20212,6 +20308,14 @@ export type operations = { }; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; + mutualBannerPining?: string[] | null; + myMutualBanner?: ({ + /** Format: misskey:id */ + fileId: string; + description?: string; + /** Format: url */ + url?: string | null; + }) | null; }; }; }; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 127252497..7a9a4a938 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -62,6 +62,7 @@ export const permissions = [ 'write:admin:suspend-user', 'write:admin:unset-user-avatar', 'write:admin:unset-user-banner', + 'write:admin:unset-user-mutual-banner', 'write:admin:unsuspend-user', 'write:admin:meta', 'write:admin:user-note', @@ -144,6 +145,7 @@ export const moderationLogTypes = [ 'deleteAvatarDecoration', 'unsetUserAvatar', 'unsetUserBanner', + 'unsetUserMutualBanner', ] as const; export type ModerationLogPayloads = { @@ -362,4 +364,11 @@ export type ModerationLogPayloads = { userHost: string | null; fileId: string; }; + unsetUserMutualBanner: { + userId: string; + userUsername: string; + userBannerDescription: string | null; + userBannerUrl: string; + fileId: string; + }; };