From fb505de0451685c5b88903d8cc32cbb524f8ef58 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Fri, 21 Aug 2026 02:36:37 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E3=83=81=E3=83=A3=E3=83=B3?= =?UTF-8?q?=E3=83=8D=E3=83=AB=E3=81=AE=E3=83=95=E3=82=A9=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E6=89=BF=E8=AA=8D=E3=81=A8=E7=AE=A1=E7=90=86=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- locales/ja-JP.yml | 9 + .../1787278753407-ChannelFollowApproval.js | 36 ++ .../src/core/ChannelFollowingService.ts | 120 ++++- .../src/core/entities/ChannelEntityService.ts | 28 +- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/Channel.ts | 13 + .../src/models/ChannelFollowRequest.ts | 42 ++ .../backend/src/models/RepositoryModule.ts | 9 + packages/backend/src/models/_.ts | 3 + .../backend/src/models/json-schema/channel.ts | 12 + packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/endpoint-list.ts | 5 + .../server/api/endpoints/channels/create.ts | 4 + .../server/api/endpoints/channels/featured.ts | 1 + .../channels/follow-requests/approve.ts | 53 +++ .../channels/follow-requests/list.ts | 99 +++++ .../channels/follow-requests/reject.ts | 53 +++ .../server/api/endpoints/channels/follow.ts | 22 +- .../api/endpoints/channels/followers.ts | 84 ++++ .../endpoints/channels/followers/remove.ts | 52 +++ .../server/api/endpoints/channels/search.ts | 3 +- .../server/api/endpoints/channels/update.ts | 4 + .../test/e2e/channel-follow-approval.ts | 89 ++++ .../test/unit/ChannelFollowingService.ts | 85 ++++ packages/frontend/.storybook/fakes.ts | 2 + .../MkChannelFollowButton.stories.impl.ts | 35 ++ .../src/components/MkChannelFollowButton.vue | 24 +- .../src/pages/channel-editor.followers.vue | 121 ++++++ .../frontend/src/pages/channel-editor.vue | 19 + packages/i18n/src/autogen/locale.ts | 56 +++ packages/misskey-js/etc/misskey-js.api.md | 32 ++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 55 +++ packages/misskey-js/src/autogen/endpoint.ts | 15 +- packages/misskey-js/src/autogen/entities.ts | 8 + packages/misskey-js/src/autogen/types.ts | 411 ++++++++++++++++++ 36 files changed, 1589 insertions(+), 20 deletions(-) create mode 100644 packages/backend/migration/1787278753407-ChannelFollowApproval.js create mode 100644 packages/backend/src/models/ChannelFollowRequest.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/follow-requests/approve.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/follow-requests/list.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/follow-requests/reject.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/followers.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/followers/remove.ts create mode 100644 packages/backend/test/e2e/channel-follow-approval.ts create mode 100644 packages/frontend/src/pages/channel-editor.followers.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index c7862c8b41b..02a829c31ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Feat: チャンネルの非掲載・フォロー承認制・フォロワー管理に対応 ### Client - Feat: センシティブなメディアをタップ中だけ表示するオプションを追加 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 40f909ed999..7a4fd4ad584 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2407,6 +2407,15 @@ _channel: nameOnly: "名前のみ" allowRenoteToExternal: "チャンネル外へのリノートと引用リノートを許可する" isLocalOnly: "チャンネルでの投稿をローカルのみに制限する" + isUnlisted: "チャンネル一覧に掲載しない" + isUnlistedDescription: "チャンネル一覧・検索・トレンドには表示されません。URLを知っているユーザーは引き続き閲覧できます。" + isFollowApprovalRequired: "フォローを承認制にする" + isFollowApprovalRequiredDescription: "共同管理者またはチャンネル管理者が承認するまで、フォローは申請中になります。" + followerManagement: "フォロワー管理" + followRequests: "フォロー申請" + noFollowers: "フォロワーはいません" + removeFollowerConfirm: "{name}をこのチャンネルのフォロワーから削除しますか?" + cancelFollowRequestConfirm: "{name}へのフォロー申請をキャンセルしますか?" addCollaborator: "共同管理者を追加" collaborators: "共同管理者" transferAdminConfirmTitle: "管理者権限の移譲" diff --git a/packages/backend/migration/1787278753407-ChannelFollowApproval.js b/packages/backend/migration/1787278753407-ChannelFollowApproval.js new file mode 100644 index 00000000000..0c8f67e7bf0 --- /dev/null +++ b/packages/backend/migration/1787278753407-ChannelFollowApproval.js @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ChannelFollowApproval1787278753407 { + name = 'ChannelFollowApproval1787278753407' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "channel_follow_request" ("id" character varying(32) NOT NULL, "channelId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, CONSTRAINT "PK_1946cbea7196ec22c747c688880" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_follow_request"."channelId" IS 'The channel ID.'; COMMENT ON COLUMN "channel_follow_request"."followerId" IS 'The follower user ID.'`); + await queryRunner.query(`CREATE INDEX "IDX_37dba759526d0abee0a34c8f4e" ON "channel_follow_request" ("channelId")`); + await queryRunner.query(`CREATE INDEX "IDX_d84a54e9f624e81d0eeb1984a0" ON "channel_follow_request" ("followerId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5847f632d1a682516959b97d20" ON "channel_follow_request" ("followerId", "channelId")`); + await queryRunner.query(`ALTER TABLE "channel" ADD "isUnlisted" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."isUnlisted" IS 'Whether the channel is hidden from channel discovery surfaces.'`); + await queryRunner.query(`ALTER TABLE "channel" ADD "isFollowApprovalRequired" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."isFollowApprovalRequired" IS 'Whether following this channel requires approval.'`); + await queryRunner.query(`CREATE INDEX "IDX_01d715841fcb2cc4b679950773" ON "channel" ("isUnlisted")`); + await queryRunner.query(`ALTER TABLE "channel_follow_request" ADD CONSTRAINT "FK_37dba759526d0abee0a34c8f4ed" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "channel_follow_request" ADD CONSTRAINT "FK_d84a54e9f624e81d0eeb1984a09" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel_follow_request" DROP CONSTRAINT "FK_d84a54e9f624e81d0eeb1984a09"`); + await queryRunner.query(`ALTER TABLE "channel_follow_request" DROP CONSTRAINT "FK_37dba759526d0abee0a34c8f4ed"`); + await queryRunner.query(`DROP INDEX "public"."IDX_01d715841fcb2cc4b679950773"`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."isFollowApprovalRequired" IS 'Whether following this channel requires approval.'`); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "isFollowApprovalRequired"`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."isUnlisted" IS 'Whether the channel is hidden from channel discovery surfaces.'`); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "isUnlisted"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5847f632d1a682516959b97d20"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d84a54e9f624e81d0eeb1984a0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_37dba759526d0abee0a34c8f4e"`); + await queryRunner.query(`DROP TABLE "channel_follow_request"`); + } +} diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 7dae8d10f39..3759b27d902 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -5,9 +5,12 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; +import type { DataSource } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelFollowRequestsRepository, ChannelsRepository, MiUser } from '@/models/_.js'; import { MiChannel } from '@/models/_.js'; +import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiChannelFollowRequest } from '@/models/ChannelFollowRequest.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; @@ -19,6 +22,8 @@ export class ChannelFollowingService implements OnModuleInit { public userFollowingChannelsCache: RedisKVCache>; constructor( + @Inject(DI.db) + private db: DataSource, @Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.redisForSub) @@ -27,6 +32,8 @@ export class ChannelFollowingService implements OnModuleInit { private channelsRepository: ChannelsRepository, @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.channelFollowRequestsRepository) + private channelFollowRequestsRepository: ChannelFollowRequestsRepository, private idService: IdService, private globalEventService: GlobalEventService, ) { @@ -93,13 +100,22 @@ export class ChannelFollowingService implements OnModuleInit { @bindThis public async follow( - requestUser: MiLocalUser, + requestUser: Pick, targetChannel: MiChannel, ): Promise { - await this.channelFollowingsRepository.insert({ - id: this.idService.gen(), + await this.channelFollowingsRepository.createQueryBuilder() + .insert() + .values({ + id: this.idService.gen(), + followerId: requestUser.id, + followeeId: targetChannel.id, + }) + .orIgnore() + .execute(); + + await this.channelFollowRequestsRepository.delete({ followerId: requestUser.id, - followeeId: targetChannel.id, + channelId: targetChannel.id, }); this.globalEventService.publishInternalEvent('followChannel', { @@ -109,14 +125,52 @@ export class ChannelFollowingService implements OnModuleInit { } @bindThis - public async unfollow( + public async followOrRequest( requestUser: MiLocalUser, targetChannel: MiChannel, - ): Promise { - await this.channelFollowingsRepository.delete({ - followerId: requestUser.id, - followeeId: targetChannel.id, + bypassApproval: boolean, + ): Promise<'following' | 'pending'> { + const isFollowing = await this.channelFollowingsRepository.exists({ + where: { + followerId: requestUser.id, + followeeId: targetChannel.id, + }, }); + if (isFollowing) return 'following'; + + if (!targetChannel.isFollowApprovalRequired || bypassApproval) { + await this.follow(requestUser, targetChannel); + return 'following'; + } + + await this.channelFollowRequestsRepository.createQueryBuilder() + .insert() + .values({ + id: this.idService.gen(), + followerId: requestUser.id, + channelId: targetChannel.id, + }) + .orIgnore() + .execute(); + + return 'pending'; + } + + @bindThis + public async unfollow( + requestUser: Pick, + targetChannel: MiChannel, + ): Promise { + await Promise.all([ + this.channelFollowingsRepository.delete({ + followerId: requestUser.id, + followeeId: targetChannel.id, + }), + this.channelFollowRequestsRepository.delete({ + followerId: requestUser.id, + channelId: targetChannel.id, + }), + ]); this.globalEventService.publishInternalEvent('unfollowChannel', { userId: requestUser.id, @@ -124,6 +178,52 @@ export class ChannelFollowingService implements OnModuleInit { }); } + @bindThis + public async approveRequest( + follower: Pick, + targetChannel: MiChannel, + ): Promise { + let approved = false; + await this.db.transaction(async manager => { + const requestDeleteResult = await manager.getRepository(MiChannelFollowRequest).delete({ + followerId: follower.id, + channelId: targetChannel.id, + }); + if ((requestDeleteResult.affected ?? 0) === 0) return; + + await manager.getRepository(MiChannelFollowing).createQueryBuilder() + .insert() + .values({ + id: this.idService.gen(), + followerId: follower.id, + followeeId: targetChannel.id, + }) + .orIgnore() + .execute(); + approved = true; + }); + + if (approved) { + this.globalEventService.publishInternalEvent('followChannel', { + userId: follower.id, + channelId: targetChannel.id, + }); + } + return approved; + } + + @bindThis + public async rejectRequest( + follower: Pick, + targetChannel: MiChannel, + ): Promise { + const result = await this.channelFollowRequestsRepository.delete({ + followerId: follower.id, + channelId: targetChannel.id, + }); + return (result.affected ?? 0) > 0; + } + @bindThis private async onMessage(_: string, data: string): Promise { const obj = JSON.parse(data); diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index d0524032ba9..73538ee2f32 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -8,7 +8,9 @@ import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { ChannelFavoritesRepository, - ChannelFollowingsRepository, ChannelMutingRepository, + ChannelFollowingsRepository, + ChannelFollowRequestsRepository, + ChannelMutingRepository, ChannelsRepository, DriveFilesRepository, MiDriveFile, @@ -30,6 +32,8 @@ export class ChannelEntityService { private channelsRepository: ChannelsRepository, @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.channelFollowRequestsRepository) + private channelFollowRequestsRepository: ChannelFollowRequestsRepository, @Inject(DI.channelFavoritesRepository) private channelFavoritesRepository: ChannelFavoritesRepository, @Inject(DI.channelMutingRepository) @@ -52,6 +56,7 @@ export class ChannelEntityService { opts?: { bannerFiles?: Map; followings?: Set; + followRequests?: Set; favorites?: Set; muting?: Set; pinnedNotes?: Map; @@ -66,6 +71,7 @@ export class ChannelEntityService { } let isFollowing = false; + let hasPendingFollowRequest = false; let isFavorited = false; let isMuting = false; if (me) { @@ -76,6 +82,13 @@ export class ChannelEntityService { }, }); + hasPendingFollowRequest = opts?.followRequests?.has(channel.id) ?? await this.channelFollowRequestsRepository.exists({ + where: { + followerId: me.id, + channelId: channel.id, + }, + }); + isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({ where: { userId: me.id, @@ -122,10 +135,13 @@ export class ChannelEntityService { isSensitive: channel.isSensitive, allowRenoteToExternal: channel.allowRenoteToExternal, isLocalOnly: channel.isLocalOnly, + isUnlisted: channel.isUnlisted, + isFollowApprovalRequired: channel.isFollowApprovalRequired, collaboratorIds, ...(me ? { isFollowing, + hasPendingFollowRequest, isFavorited, isMuting, hasUnreadNote: false, // 後方互換性のため @@ -169,6 +185,15 @@ export class ChannelEntityService { .then(it => new Set(it.map(it => it.followeeId))) : new Set(); + const followRequests = me + ? await this.channelFollowRequestsRepository + .findBy({ + followerId: me.id, + channelId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.channelId))) + : new Set(); + const favorites = me ? await this.channelFavoritesRepository .findBy({ @@ -198,6 +223,7 @@ export class ChannelEntityService { return Promise.all(channels.map(it => this.pack(it, me, detailed, { bannerFiles, followings, + followRequests, favorites, muting, pinnedNotes, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index f4317b165f2..8ab61f30a06 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -72,6 +72,7 @@ export const DI = { relaysRepository: Symbol('relaysRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), + channelFollowRequestsRepository: Symbol('channelFollowRequestsRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'), channelMutingRepository: Symbol('channelMutingRepository'), registryItemsRepository: Symbol('registryItemsRepository'), diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index 020c3912bb7..e3fdc26d8a9 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -105,6 +105,19 @@ export class MiChannel { }) public isLocalOnly: boolean; + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the channel is hidden from channel discovery surfaces.', + }) + public isUnlisted: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether following this channel requires approval.', + }) + public isFollowApprovalRequired: boolean; + @Column({ ...id(), array: true, default: '{}', diff --git a/packages/backend/src/models/ChannelFollowRequest.ts b/packages/backend/src/models/ChannelFollowRequest.ts new file mode 100644 index 00000000000..004eb5073f3 --- /dev/null +++ b/packages/backend/src/models/ChannelFollowRequest.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from './util/id.js'; +import { MiChannel } from './Channel.js'; +import { MiUser } from './User.js'; + +@Entity('channel_follow_request') +@Index(['followerId', 'channelId'], { unique: true }) +export class MiChannelFollowRequest { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The channel ID.', + }) + public channelId: MiChannel['id']; + + @ManyToOne(() => MiChannel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: MiChannel | null; + + @Index() + @Column({ + ...id(), + comment: 'The follower user ID.', + }) + public followerId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public follower: MiUser | null; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index b53a70fc838..7eea7c55938 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -20,6 +20,7 @@ import { MiBubbleGameRecord, MiChannel, MiChannelFavorite, + MiChannelFollowRequest, MiChannelFollowing, MiChannelMuting, MiClip, @@ -441,6 +442,12 @@ const $channelFollowingsRepository: Provider = { inject: [DI.db], }; +const $channelFollowRequestsRepository: Provider = { + provide: DI.channelFollowRequestsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChannelFollowRequest).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $channelFavoritesRepository: Provider = { provide: DI.channelFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiChannelFavorite).extend(miRepository as MiRepository), @@ -635,6 +642,7 @@ const $eventsRepository: Provider = { $relaysRepository, $channelsRepository, $channelFollowingsRepository, + $channelFollowRequestsRepository, $channelFavoritesRepository, $channelMutingRepository, $registryItemsRepository, @@ -717,6 +725,7 @@ const $eventsRepository: Provider = { $relaysRepository, $channelsRepository, $channelFollowingsRepository, + $channelFollowRequestsRepository, $channelFavoritesRepository, $channelMutingRepository, $registryItemsRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 5e16aeda6c6..615bcbde747 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -22,6 +22,7 @@ import { MiBlocking } from '@/models/Blocking.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiChannel } from '@/models/Channel.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelFollowRequest } from '@/models/ChannelFollowRequest.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; import { MiChannelMuting } from "@/models/ChannelMuting.js"; import { MiChatApproval } from '@/models/ChatApproval.js'; @@ -113,6 +114,7 @@ export { MiAuthSession, MiBlocking, MiChannelFollowing, + MiChannelFollowRequest, MiChannelFavorite, MiChannelMuting, MiClip, @@ -197,6 +199,7 @@ export type AvatarDecorationsRepository = Repository & MiRep export type AuthSessionsRepository = Repository & MiRepository; export type BlockingsRepository = Repository & MiRepository; export type ChannelFollowingsRepository = Repository & MiRepository; +export type ChannelFollowRequestsRepository = Repository & MiRepository; export type ChannelFavoritesRepository = Repository & MiRepository; export type ChannelMutingRepository = Repository & MiRepository; export type ClipsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index d94138fd63b..a8f7becd82f 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -81,6 +81,10 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + hasPendingFollowRequest: { + type: 'boolean', + optional: true, nullable: false, + }, isFavorited: { type: 'boolean', optional: true, nullable: false, @@ -110,5 +114,13 @@ export const packedChannelSchema = { type: 'boolean', optional: false, nullable: false, }, + isUnlisted: { + type: 'boolean', + optional: false, nullable: false, + }, + isFollowApprovalRequired: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 5801cdfc06d..e1f2ce6f3cc 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -23,6 +23,7 @@ import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiChannelFollowRequest } from '@/models/ChannelFollowRequest.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; import { MiChannelMuting } from '@/models/ChannelMuting.js'; import { MiClip } from '@/models/Clip.js'; @@ -239,6 +240,7 @@ export const entities = [ MiRelay, MiChannel, MiChannelFollowing, + MiChannelFollowRequest, MiChannelFavorite, MiChannelMuting, MiRegistryItem, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index bc56c708d99..350b0da5bfa 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -147,7 +147,12 @@ export * as 'channels/create' from './endpoints/channels/create.js'; export * as 'channels/favorite' from './endpoints/channels/favorite.js'; export * as 'channels/featured' from './endpoints/channels/featured.js'; export * as 'channels/follow' from './endpoints/channels/follow.js'; +export * as 'channels/follow-requests/approve' from './endpoints/channels/follow-requests/approve.js'; +export * as 'channels/follow-requests/list' from './endpoints/channels/follow-requests/list.js'; +export * as 'channels/follow-requests/reject' from './endpoints/channels/follow-requests/reject.js'; export * as 'channels/followed' from './endpoints/channels/followed.js'; +export * as 'channels/followers' from './endpoints/channels/followers.js'; +export * as 'channels/followers/remove' from './endpoints/channels/followers/remove.js'; export * as 'channels/my-favorites' from './endpoints/channels/my-favorites.js'; export * as 'channels/owned' from './endpoints/channels/owned.js'; export * as 'channels/search' from './endpoints/channels/search.js'; diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index a91f5dd8b93..4a45cb811c3 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -54,6 +54,8 @@ export const paramDef = { isSensitive: { type: 'boolean', nullable: true }, allowRenoteToExternal: { type: 'boolean', nullable: true }, isLocalOnly: { type: 'boolean', default: false }, + isUnlisted: { type: 'boolean', default: false }, + isFollowApprovalRequired: { type: 'boolean', default: false }, }, required: ['name'], } as const; @@ -93,6 +95,8 @@ export default class extends Endpoint { // eslint- ...(ps.color !== undefined ? { color: ps.color } : {}), allowRenoteToExternal: ps.allowRenoteToExternal ?? true, isLocalOnly: ps.isLocalOnly, + isUnlisted: ps.isUnlisted, + isFollowApprovalRequired: ps.isFollowApprovalRequired, } as MiChannel); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index a9a79ba8fc6..97768a7260e 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -43,6 +43,7 @@ export default class extends Endpoint { // eslint- const query = this.channelsRepository.createQueryBuilder('channel') .where('channel.lastNotedAt IS NOT NULL') .andWhere('channel.isArchived = FALSE') + .andWhere('channel.isUnlisted = FALSE') .orderBy('channel.lastNotedAt', 'DESC'); const channels = await query.limit(10).getMany(); diff --git a/packages/backend/src/server/api/endpoints/channels/follow-requests/approve.ts b/packages/backend/src/server/api/endpoints/channels/follow-requests/approve.ts new file mode 100644 index 00000000000..3c1b8c586ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow-requests/approve.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { ChannelService } from '@/core/ChannelService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import type { ChannelsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels'], + requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', + errors: { + noSuchChannel: { message: 'No such channel.', code: 'NO_SUCH_CHANNEL', id: '99490eaf-ef2c-4431-98db-7b632ec6002f' }, + accessDenied: { message: 'You do not have permission to manage this channel.', code: 'ACCESS_DENIED', id: 'f357e948-98bc-40c6-b25e-f76ebfdd75b5' }, + noSuchRequest: { message: 'No such channel follow request.', code: 'NO_SUCH_CHANNEL_FOLLOW_REQUEST', id: 'bcac94b8-f83b-48e5-84da-3281b94a10b0' }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelService: ChannelService, + private channelFollowingService: ChannelFollowingService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + const isModerator = await this.roleService.isModerator(me); + if (!await this.channelService.canEditChannel(channel, me, isModerator)) throw new ApiError(meta.errors.accessDenied); + if (!await this.channelFollowingService.approveRequest({ id: ps.userId }, channel)) throw new ApiError(meta.errors.noSuchRequest); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/follow-requests/list.ts b/packages/backend/src/server/api/endpoints/channels/follow-requests/list.ts new file mode 100644 index 00000000000..672893ad403 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow-requests/list.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ChannelService } from '@/core/ChannelService.js'; +import { IdService } from '@/core/IdService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { ChannelFollowRequestsRepository, ChannelsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels', 'account'], + requireCredential: true, + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { type: 'string', format: 'id', optional: false, nullable: false }, + createdAt: { type: 'string', format: 'date-time', optional: false, nullable: false }, + user: { type: 'object', ref: 'UserLite', optional: false, nullable: false }, + }, + }, + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '448f1a4e-9cd0-4d6b-a9b8-302c486ad552', + }, + accessDenied: { + message: 'You do not have permission to manage this channel.', + code: 'ACCESS_DENIED', + id: '34fc619d-9f19-4e52-8a29-04642575f7cf', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + required: ['channelId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + @Inject(DI.channelFollowRequestsRepository) + private channelFollowRequestsRepository: ChannelFollowRequestsRepository, + private channelService: ChannelService, + private roleService: RoleService, + private queryService: QueryService, + private userEntityService: UserEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + + const isModerator = await this.roleService.isModerator(me); + if (!await this.channelService.canEditChannel(channel, me, isModerator)) { + throw new ApiError(meta.errors.accessDenied); + } + + const query = this.queryService.makePaginationQuery( + this.channelFollowRequestsRepository.createQueryBuilder('request'), + ps.sinceId, + ps.untilId, + ).andWhere('request.channelId = :channelId', { channelId: channel.id }); + const requests = await query.limit(ps.limit).getMany(); + const users = await this.userEntityService.packMany(requests.map(request => request.followerId), me); + const usersById = new Map(users.map(user => [user.id, user])); + + return requests.map(request => ({ + id: request.id, + createdAt: this.idService.parse(request.id).date.toISOString(), + user: usersById.get(request.followerId)!, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/follow-requests/reject.ts b/packages/backend/src/server/api/endpoints/channels/follow-requests/reject.ts new file mode 100644 index 00000000000..32e11b68264 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow-requests/reject.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { ChannelService } from '@/core/ChannelService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import type { ChannelsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels'], + requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', + errors: { + noSuchChannel: { message: 'No such channel.', code: 'NO_SUCH_CHANNEL', id: 'f9bad040-6ccb-438d-a2a5-14aea3efc318' }, + accessDenied: { message: 'You do not have permission to manage this channel.', code: 'ACCESS_DENIED', id: '93f735ad-e05d-470d-8612-2aa23d2a4a45' }, + noSuchRequest: { message: 'No such channel follow request.', code: 'NO_SUCH_CHANNEL_FOLLOW_REQUEST', id: 'b06622f9-6a22-479e-914c-13b37d944eb2' }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelService: ChannelService, + private channelFollowingService: ChannelFollowingService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + const isModerator = await this.roleService.isModerator(me); + if (!await this.channelService.canEditChannel(channel, me, isModerator)) throw new ApiError(meta.errors.accessDenied); + if (!await this.channelFollowingService.rejectRequest({ id: ps.userId }, channel)) throw new ApiError(meta.errors.noSuchRequest); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 1812820ba29..7845672fb44 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { ChannelService } from '@/core/ChannelService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -19,6 +21,18 @@ export const meta = { kind: 'write:channels', + res: { + type: 'object', + optional: false, nullable: false, + properties: { + state: { + type: 'string', + optional: false, nullable: false, + enum: ['following', 'pending'], + }, + }, + }, + errors: { noSuchChannel: { message: 'No such channel.', @@ -42,6 +56,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, private channelFollowingService: ChannelFollowingService, + private channelService: ChannelService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -52,7 +68,11 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingService.follow(me, channel); + const isModerator = await this.roleService.isModerator(me); + const canManage = await this.channelService.canEditChannel(channel, me, isModerator); + const state = await this.channelFollowingService.followOrRequest(me, channel, canManage); + + return { state }; }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/followers.ts b/packages/backend/src/server/api/endpoints/channels/followers.ts new file mode 100644 index 00000000000..3683fb44abf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followers.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ChannelService } from '@/core/ChannelService.js'; +import { IdService } from '@/core/IdService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { ChannelFollowingsRepository, ChannelsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels', 'account'], + requireCredential: true, + kind: 'read:channels', + res: { + type: 'array', optional: false, nullable: false, + items: { + type: 'object', optional: false, nullable: false, + properties: { + id: { type: 'string', format: 'id', optional: false, nullable: false }, + createdAt: { type: 'string', format: 'date-time', optional: false, nullable: false }, + user: { type: 'object', ref: 'UserLite', optional: false, nullable: false }, + }, + }, + }, + errors: { + noSuchChannel: { message: 'No such channel.', code: 'NO_SUCH_CHANNEL', id: 'd662d052-7760-46d1-83cc-60f857c88c4f' }, + accessDenied: { message: 'You do not have permission to manage this channel.', code: 'ACCESS_DENIED', id: 'd9c62aa5-3331-41fe-8f73-666b549b895d' }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + required: ['channelId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private channelService: ChannelService, + private roleService: RoleService, + private queryService: QueryService, + private userEntityService: UserEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + const isModerator = await this.roleService.isModerator(me); + if (!await this.channelService.canEditChannel(channel, me, isModerator)) throw new ApiError(meta.errors.accessDenied); + + const query = this.queryService.makePaginationQuery( + this.channelFollowingsRepository.createQueryBuilder('following'), + ps.sinceId, + ps.untilId, + ).andWhere('following.followeeId = :channelId', { channelId: channel.id }); + const followings = await query.limit(ps.limit).getMany(); + const users = await this.userEntityService.packMany(followings.map(following => following.followerId), me); + const usersById = new Map(users.map(user => [user.id, user])); + + return followings.map(following => ({ + id: following.id, + createdAt: this.idService.parse(following.id).date.toISOString(), + user: usersById.get(following.followerId)!, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/followers/remove.ts b/packages/backend/src/server/api/endpoints/channels/followers/remove.ts new file mode 100644 index 00000000000..6c79d1dcc88 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followers/remove.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { ChannelService } from '@/core/ChannelService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import type { ChannelsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels'], + requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', + errors: { + noSuchChannel: { message: 'No such channel.', code: 'NO_SUCH_CHANNEL', id: '0426c4c2-11e0-4c62-8004-97e5b42b9bee' }, + accessDenied: { message: 'You do not have permission to manage this channel.', code: 'ACCESS_DENIED', id: '450ed3ae-138c-4ab3-8538-92fb50c9dec7' }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelService: ChannelService, + private channelFollowingService: ChannelFollowingService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + const isModerator = await this.roleService.isModerator(me); + if (!await this.channelService.canEditChannel(channel, me, isModerator)) throw new ApiError(meta.errors.accessDenied); + await this.channelFollowingService.unfollow({ id: ps.userId }, channel); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts index 7b6c4db91c8..2365dd9badf 100644 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -53,7 +53,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('channel.isArchived = FALSE'); + .andWhere('channel.isArchived = FALSE') + .andWhere('channel.isUnlisted = FALSE'); if (ps.query !== '') { if (ps.type === 'nameAndDescription') { diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 9c72e0fdbd6..511f0c70a26 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -65,6 +65,8 @@ export const paramDef = { isSensitive: { type: 'boolean', nullable: true }, allowRenoteToExternal: { type: 'boolean', nullable: true }, isLocalOnly: { type: 'boolean', optional: true }, + isUnlisted: { type: 'boolean', optional: true }, + isFollowApprovalRequired: { type: 'boolean', optional: true }, transferAdminUserId: { type: 'string', format: 'misskey:id', optional: true }, collaboratorIds: { type: 'array', @@ -152,6 +154,8 @@ export default class extends Endpoint { // eslint- ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), ...(typeof ps.allowRenoteToExternal === 'boolean' ? { allowRenoteToExternal: ps.allowRenoteToExternal } : {}), ...(ps.isLocalOnly !== undefined ? { isLocalOnly: ps.isLocalOnly } : {}), + ...(ps.isUnlisted !== undefined ? { isUnlisted: ps.isUnlisted } : {}), + ...(ps.isFollowApprovalRequired !== undefined ? { isFollowApprovalRequired: ps.isFollowApprovalRequired } : {}), ...(ps.transferAdminUserId !== undefined && channel.userId === ps.transferAdminUserId ? { userId: ps.transferAdminUserId } : {}), }); diff --git a/packages/backend/test/e2e/channel-follow-approval.ts b/packages/backend/test/e2e/channel-follow-approval.ts new file mode 100644 index 00000000000..1e6e852642d --- /dev/null +++ b/packages/backend/test/e2e/channel-follow-approval.ts @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'node:assert'; +import { beforeAll, describe, test } from 'vitest'; +import { api, channel, signup } from '../utils.js'; +import type * as Misskey from 'misskey-js'; + +describe('チャンネルのフォロー承認', () => { + let owner: Misskey.entities.SignupResponse; + let collaborator: Misskey.entities.SignupResponse; + let follower: Misskey.entities.SignupResponse; + let outsider: Misskey.entities.SignupResponse; + let targetChannel: Misskey.entities.Channel; + + beforeAll(async () => { + owner = await signup({ username: 'owner' }); + collaborator = await signup({ username: 'collaborator' }); + follower = await signup({ username: 'follower' }); + outsider = await signup({ username: 'outsider' }); + targetChannel = await channel(owner, { + name: 'private-channel', + isUnlisted: true, + isFollowApprovalRequired: true, + }); + + const update = await api('channels/update', { + channelId: targetChannel.id, + name: targetChannel.name, + collaboratorIds: [collaborator.id], + }, owner); + assert.strictEqual(update.status, 200, JSON.stringify(update.body)); + }, 1000 * 60 * 2); + + test('非掲載チャンネルを共同管理者が承認し、フォロワーを削除できる', async () => { + const search = await api('channels/search', { query: 'private-channel' }, follower); + assert.strictEqual(search.status, 200); + assert.strictEqual(search.body.some(channel => channel.id === targetChannel.id), false); + + const directShow = await api('channels/show', { channelId: targetChannel.id }, follower); + assert.strictEqual(directShow.status, 200); + assert.strictEqual(directShow.body.isUnlisted, true); + assert.strictEqual(directShow.body.isFollowApprovalRequired, true); + + const follow = await api('channels/follow', { channelId: targetChannel.id }, follower); + assert.strictEqual(follow.status, 200); + assert.strictEqual(follow.body.state, 'pending'); + + const pendingShow = await api('channels/show', { channelId: targetChannel.id }, follower); + assert.strictEqual(pendingShow.body.isFollowing, false); + assert.strictEqual(pendingShow.body.hasPendingFollowRequest, true); + + const unauthorizedList = await api('channels/follow-requests/list', { channelId: targetChannel.id }, outsider); + assert.strictEqual(unauthorizedList.status, 400); + + const requests = await api('channels/follow-requests/list', { channelId: targetChannel.id }, collaborator); + assert.strictEqual(requests.status, 200); + assert.strictEqual(requests.body.length, 1); + assert.strictEqual(requests.body[0].user.id, follower.id); + + const approve = await api('channels/follow-requests/approve', { + channelId: targetChannel.id, + userId: follower.id, + }, collaborator); + assert.strictEqual(approve.status, 204); + + const approvedShow = await api('channels/show', { channelId: targetChannel.id }, follower); + assert.strictEqual(approvedShow.body.isFollowing, true); + assert.strictEqual(approvedShow.body.hasPendingFollowRequest, false); + + const followers = await api('channels/followers', { channelId: targetChannel.id }, collaborator); + assert.strictEqual(followers.status, 200); + assert.strictEqual(followers.body.length, 1); + assert.strictEqual(followers.body[0].user.id, follower.id); + + const remove = await api('channels/followers/remove', { + channelId: targetChannel.id, + userId: follower.id, + }, collaborator); + assert.strictEqual(remove.status, 204); + + const removedShow = await api('channels/show', { channelId: targetChannel.id }, follower); + assert.strictEqual(removedShow.body.isFollowing, false); + }); +}); diff --git a/packages/backend/test/unit/ChannelFollowingService.ts b/packages/backend/test/unit/ChannelFollowingService.ts index 3b1ad72287f..de69c346b2d 100644 --- a/packages/backend/test/unit/ChannelFollowingService.ts +++ b/packages/backend/test/unit/ChannelFollowingService.ts @@ -12,6 +12,7 @@ import { CoreModule } from '@/core/CoreModule.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { + type ChannelFollowRequestsRepository, type ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, @@ -31,6 +32,7 @@ describe('ChannelFollowingService', () => { let service: ChannelFollowingService; let channelsRepository: ChannelsRepository; let channelFollowingsRepository: ChannelFollowingsRepository; + let channelFollowRequestsRepository: ChannelFollowRequestsRepository; let usersRepository: UsersRepository; let userProfilesRepository: UserProfilesRepository; let driveFilesRepository: DriveFilesRepository; @@ -83,6 +85,10 @@ describe('ChannelFollowingService', () => { return await channelFollowingsRepository.findBy({}); } + async function fetchChannelFollowRequests() { + return await channelFollowRequestsRepository.findBy({}); + } + async function createDriveFile(data: Partial = {}) { return await driveFilesRepository .insert({ @@ -117,6 +123,7 @@ describe('ChannelFollowingService', () => { idService = app.get(IdService); channelsRepository = app.get(DI.channelsRepository); channelFollowingsRepository = app.get(DI.channelFollowingsRepository); + channelFollowRequestsRepository = app.get(DI.channelFollowRequestsRepository); usersRepository = app.get(DI.usersRepository); userProfilesRepository = app.get(DI.userProfilesRepository); driveFilesRepository = app.get(DI.driveFilesRepository); @@ -137,6 +144,7 @@ describe('ChannelFollowingService', () => { }); afterEach(async () => { + await channelFollowRequestsRepository.deleteAll(); await channelFollowingsRepository.deleteAll(); await channelsRepository.deleteAll(); await userProfilesRepository.deleteAll(); @@ -221,6 +229,69 @@ describe('ChannelFollowingService', () => { }); }); + describe('followOrRequest', () => { + test('follows a channel that does not require approval', async () => { + const state = await service.followOrRequest(alice, channel1, false); + + expect(state).toBe('following'); + expect(await fetchChannelFollowing()).toHaveLength(1); + expect(await fetchChannelFollowRequests()).toHaveLength(0); + }); + + test('creates a request for a channel that requires approval', async () => { + channel1.isFollowApprovalRequired = true; + + const state = await service.followOrRequest(alice, channel1, false); + + expect(state).toBe('pending'); + expect(await fetchChannelFollowing()).toHaveLength(0); + const requests = await fetchChannelFollowRequests(); + expect(requests).toHaveLength(1); + expect(requests[0].channelId).toBe(channel1.id); + expect(requests[0].followerId).toBe(alice.id); + }); + + test('lets a channel manager bypass approval', async () => { + channel1.isFollowApprovalRequired = true; + + const state = await service.followOrRequest(alice, channel1, true); + + expect(state).toBe('following'); + expect(await fetchChannelFollowing()).toHaveLength(1); + expect(await fetchChannelFollowRequests()).toHaveLength(0); + }); + }); + + describe('manage requests', () => { + async function createFollowRequest() { + await channelFollowRequestsRepository.insert({ + id: idService.gen(), + channelId: channel1.id, + channel: null, + followerId: bob.id, + follower: null, + }); + } + + test('approves a request and creates a following', async () => { + await createFollowRequest(); + + expect(await service.approveRequest(bob, channel1)).toBe(true); + expect(await fetchChannelFollowRequests()).toHaveLength(0); + const followings = await fetchChannelFollowing(); + expect(followings).toHaveLength(1); + expect(followings[0].followerId).toBe(bob.id); + }); + + test('rejects a request without creating a following', async () => { + await createFollowRequest(); + + expect(await service.rejectRequest(bob, channel1)).toBe(true); + expect(await fetchChannelFollowRequests()).toHaveLength(0); + expect(await fetchChannelFollowing()).toHaveLength(0); + }); + }); + describe('unfollow', () => { test('default', async () => { await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); @@ -231,5 +302,19 @@ describe('ChannelFollowingService', () => { expect(followings).toHaveLength(0); }); + + test('cancels a pending follow request', async () => { + await channelFollowRequestsRepository.insert({ + id: idService.gen(), + channelId: channel1.id, + channel: null, + followerId: alice.id, + follower: null, + }); + + await service.unfollow(alice, channel1); + + expect(await fetchChannelFollowRequests()).toHaveLength(0); + }); }); }); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 109044ac491..123e9a78a95 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -42,6 +42,8 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: isSensitive: false, allowRenoteToExternal: false, isLocalOnly: false, + isUnlisted: false, + isFollowApprovalRequired: false, }; } diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index 095805ba955..bd8b5eb49cc 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -57,8 +57,43 @@ export const Default = { ...commonHandlers, http.post('/api/channels/follow', async ({ request }) => { action('POST /api/channels/follow')(await request.json()); + return HttpResponse.json({ state: 'following' }); + }), + http.post('/api/channels/unfollow', async ({ request }) => { + action('POST /api/channels/unfollow')(await request.json()); return HttpResponse.json({}); }), + ], + }, + }, +} satisfies StoryObj; + +export const ApprovalRequired = { + ...Default, + args: { + channel: { + ...channel(), + isFollowApprovalRequired: true, + }, + full: true, + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const buttonElement = canvas.getByRole('button'); + await expect(buttonElement).toHaveTextContent(i18n.ts.follow); + await userEvent.click(buttonElement); + await sleep(1000); + await expect(buttonElement).toHaveTextContent(i18n.ts.followRequestPending); + }, + parameters: { + ...Default.parameters, + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/channels/follow', async ({ request }) => { + action('POST /api/channels/follow')(await request.json()); + return HttpResponse.json({ state: 'pending' }); + }), http.post('/api/channels/unfollow', async ({ request }) => { action('POST /api/channels/unfollow')(await request.json()); return HttpResponse.json({}); diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 1aec8d0c070..c18a2726061 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -6,12 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only