diff --git a/CHANGELOG.md b/CHANGELOG.md index 984adc53f37..86a520b72ff 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 cb9793b8d5d..f553fa45809 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2409,6 +2409,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..29788f7b464 --- /dev/null +++ b/packages/backend/migration/1787278753407-ChannelFollowApproval.js @@ -0,0 +1,41 @@ +/* + * 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(`ALTER TABLE "channel" ADD "followersCount" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."followersCount" IS 'The count of followers.'`); + await queryRunner.query(`UPDATE "channel" SET "followersCount" = (SELECT COUNT(*) FROM "channel_following" WHERE "channel_following"."followeeId" = "channel"."id")`); + 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"."followersCount" IS 'The count of followers.'`); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "followersCount"`); + 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 54198fdc06c..43a19b8458d 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -5,22 +5,26 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; +import type { DataSource, EntityManager } 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'; import type { MiLocalUser } from '@/models/User.js'; import { RedisKVCache } from '@/misc/cache.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @Injectable() 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) @@ -29,6 +33,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, ) { @@ -95,21 +101,20 @@ export class ChannelFollowingService implements OnModuleInit { @bindThis public async follow( - requestUser: MiLocalUser, + requestUser: Pick, targetChannel: MiChannel, ): Promise { - try { - await this.channelFollowingsRepository.insert({ - id: this.idService.gen(), - followerId: requestUser.id, - followeeId: targetChannel.id, - }); - } catch (e) { - if (isDuplicateKeyValueError(e)) { + await this.db.transaction(async manager => { + await this.lockChannel(manager, targetChannel.id); + const inserted = await this.insertFollowing(manager, requestUser.id, targetChannel.id); + if (!inserted) { throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following'); } - throw e; - } + await manager.getRepository(MiChannelFollowRequest).delete({ + followerId: requestUser.id, + channelId: targetChannel.id, + }); + }); this.globalEventService.publishInternalEvent('followChannel', { userId: requestUser.id, @@ -118,13 +123,68 @@ export class ChannelFollowingService implements OnModuleInit { } @bindThis - public async unfollow( + public async followOrRequest( requestUser: MiLocalUser, targetChannel: MiChannel, + bypassApproval: boolean, + ): Promise<'following' | 'pending' | 'alreadyFollowing'> { + let followed = false; + const state = await this.db.transaction(async manager => { + const channel = await this.lockChannel(manager, targetChannel.id); + const isFollowing = await manager.getRepository(MiChannelFollowing).exists({ + where: { + followerId: requestUser.id, + followeeId: channel.id, + }, + }); + if (isFollowing) return 'alreadyFollowing' as const; + + if (!channel.isFollowApprovalRequired || bypassApproval) { + const inserted = await this.insertFollowing(manager, requestUser.id, channel.id); + if (!inserted) return 'alreadyFollowing' as const; + await manager.getRepository(MiChannelFollowRequest).delete({ + followerId: requestUser.id, + channelId: channel.id, + }); + followed = true; + return 'following' as const; + } + + await manager.getRepository(MiChannelFollowRequest).createQueryBuilder() + .insert() + .values({ + id: this.idService.gen(), + followerId: requestUser.id, + channelId: channel.id, + }) + .orIgnore() + .execute(); + + return 'pending' as const; + }); + + if (followed) this.publishFollowEvent(requestUser.id, targetChannel.id); + return state; + } + + @bindThis + public async unfollow( + requestUser: Pick, + targetChannel: MiChannel, ): Promise { - await this.channelFollowingsRepository.delete({ - followerId: requestUser.id, - followeeId: targetChannel.id, + await this.db.transaction(async manager => { + await this.lockChannel(manager, targetChannel.id); + const deleteResult = await manager.getRepository(MiChannelFollowing).delete({ + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + if ((deleteResult.affected ?? 0) > 0) { + await this.decrementFollowersCount(manager, targetChannel.id); + } + await manager.getRepository(MiChannelFollowRequest).delete({ + followerId: requestUser.id, + channelId: targetChannel.id, + }); }); this.globalEventService.publishInternalEvent('unfollowChannel', { @@ -133,6 +193,134 @@ export class ChannelFollowingService implements OnModuleInit { }); } + @bindThis + public async unfollowAll(requestUser: Pick): Promise { + const followings = await this.channelFollowingsRepository.find({ + where: { followerId: requestUser.id }, + select: { followeeId: true }, + }); + + for (const following of followings) { + const channel = await this.channelsRepository.findOneBy({ id: following.followeeId }); + if (channel != null) await this.unfollow(requestUser, channel); + } + } + + @bindThis + public async approveRequest( + follower: Pick, + targetChannel: MiChannel, + ): Promise { + let approved = false; + await this.db.transaction(async manager => { + await this.lockChannel(manager, targetChannel.id); + const requestDeleteResult = await manager.getRepository(MiChannelFollowRequest).delete({ + followerId: follower.id, + channelId: targetChannel.id, + }); + if ((requestDeleteResult.affected ?? 0) === 0) return; + + await this.insertFollowing(manager, follower.id, targetChannel.id); + 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 { + return await this.db.transaction(async manager => { + await this.lockChannel(manager, targetChannel.id); + const result = await manager.getRepository(MiChannelFollowRequest).delete({ + followerId: follower.id, + channelId: targetChannel.id, + }); + return (result.affected ?? 0) > 0; + }); + } + + @bindThis + public async setFollowApprovalRequired( + targetChannel: MiChannel, + required: boolean, + ): Promise { + const approvedFollowerIds: MiUser['id'][] = []; + await this.db.transaction(async manager => { + await this.lockChannel(manager, targetChannel.id); + + if (!required) { + const requests = await manager.getRepository(MiChannelFollowRequest).find({ + where: { channelId: targetChannel.id }, + select: { followerId: true }, + }); + await manager.getRepository(MiChannelFollowRequest).delete({ channelId: targetChannel.id }); + for (const request of requests) { + if (await this.insertFollowing(manager, request.followerId, targetChannel.id)) { + approvedFollowerIds.push(request.followerId); + } + } + } + + await manager.getRepository(MiChannel).update(targetChannel.id, { + isFollowApprovalRequired: required, + }); + }); + + for (const followerId of approvedFollowerIds) { + this.publishFollowEvent(followerId, targetChannel.id); + } + } + + private async lockChannel(manager: EntityManager, channelId: MiChannel['id']): Promise { + return await manager.getRepository(MiChannel).findOneOrFail({ + where: { id: channelId }, + lock: { mode: 'pessimistic_write' }, + }); + } + + private async insertFollowing( + manager: EntityManager, + followerId: MiUser['id'], + channelId: MiChannel['id'], + ): Promise { + const result = await manager.getRepository(MiChannelFollowing).createQueryBuilder() + .insert() + .values({ + id: this.idService.gen(), + followerId, + followeeId: channelId, + }) + .orIgnore() + .returning('id') + .execute(); + const inserted = Array.isArray(result.raw) && result.raw.length > 0; + if (inserted) { + await manager.getRepository(MiChannel).increment({ id: channelId }, 'followersCount', 1); + } + return inserted; + } + + private async decrementFollowersCount(manager: EntityManager, channelId: MiChannel['id']): Promise { + await manager.getRepository(MiChannel).createQueryBuilder() + .update() + .set({ followersCount: () => 'GREATEST("followersCount" - 1, 0)' }) + .where('id = :channelId', { channelId }) + .execute(); + } + + private publishFollowEvent(userId: MiUser['id'], channelId: MiChannel['id']): void { + this.globalEventService.publishInternalEvent('followChannel', { userId, channelId }); + } + @bindThis private async onMessage(_: string, data: string): Promise { const obj = JSON.parse(data); diff --git a/packages/backend/src/core/ChannelService.ts b/packages/backend/src/core/ChannelService.ts index 2326edf38d4..16c6db8a9b8 100644 --- a/packages/backend/src/core/ChannelService.ts +++ b/packages/backend/src/core/ChannelService.ts @@ -21,21 +21,24 @@ export class ChannelService { ) { } + @bindThis + public isChannelManager( + channel: MiChannel, + user: Pick, + ): boolean { + return channel.userId === user.id || getCollaboratorIds(channel).includes(user.id); + } + @bindThis public async canEditChannel( channel: MiChannel, user: Pick, isModerator: boolean, ): Promise { - if (channel.userId === user.id) { - return true; - } + if (this.isChannelManager(channel, user)) return true; if (isModerator) { return true; } - if (getCollaboratorIds(channel).includes(user.id)) { - return true; - } return false; } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index d0524032ba9..d19f918e694 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, @@ -118,14 +131,18 @@ export class ChannelEntityService { color: channel.color, isArchived: channel.isArchived, usersCount: channel.usersCount, + followersCount: channel.followersCount, notesCount: channel.notesCount, 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 +186,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 +224,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..2ef7e294b8f 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -89,6 +89,12 @@ export class MiChannel { }) public usersCount: number; + @Column('integer', { + default: 0, + comment: 'The count of followers.', + }) + public followersCount: number; + @Column('boolean', { default: false, }) @@ -105,6 +111,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 602d4c5ec68..08b7dadffa8 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, @@ -440,6 +441,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), @@ -634,6 +641,7 @@ const $eventsRepository: Provider = { $relaysRepository, $channelsRepository, $channelFollowingsRepository, + $channelFollowRequestsRepository, $channelFavoritesRepository, $channelMutingRepository, $registryItemsRepository, @@ -716,6 +724,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..505f91621b2 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -65,6 +65,10 @@ export const packedChannelSchema = { type: 'number', nullable: false, optional: false, }, + followersCount: { + type: 'number', + nullable: false, optional: false, + }, notesCount: { type: 'number', nullable: false, optional: false, @@ -81,6 +85,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 +118,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/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index b643c2a6d0e..705940bb3d7 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -15,6 +15,7 @@ import { EmailService } from '@/core/EmailService.js'; import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; import { PageService } from '@/core/PageService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; @@ -44,6 +45,7 @@ export class DeleteAccountProcessorService { private emailService: EmailService, private queueLoggerService: QueueLoggerService, private searchService: SearchService, + private channelFollowingService: ChannelFollowingService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -152,6 +154,7 @@ export class DeleteAccountProcessorService { if (job.data.soft) { // nop } else { + await this.channelFollowingService.unfollowAll(user); await this.usersRepository.delete(job.data.user.id); } 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..d968552d98f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow-requests/approve.ts @@ -0,0 +1,50 @@ +/* + * 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 { 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, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + if (!this.channelService.isChannelManager(channel, me)) 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..87f680adbe0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow-requests/list.ts @@ -0,0 +1,96 @@ +/* + * 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 { 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 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); + + if (!this.channelService.isChannelManager(channel, me)) { + 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..7e6f728c054 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow-requests/reject.ts @@ -0,0 +1,50 @@ +/* + * 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 { 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, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + if (!this.channelService.isChannelManager(channel, me)) 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 b46552281f4..cf66cd0aabd 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -8,6 +8,7 @@ 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 { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; @@ -20,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.', @@ -48,6 +61,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, private channelFollowingService: ChannelFollowingService, + private channelService: ChannelService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -59,7 +73,11 @@ export default class extends Endpoint { // eslint- } try { - await this.channelFollowingService.follow(me, channel); + const canManage = this.channelService.isChannelManager(channel, me); + const state = await this.channelFollowingService.followOrRequest(me, channel, canManage); + if (state === 'alreadyFollowing') throw new ApiError(meta.errors.alreadyFollowing); + + return { state }; } catch (e) { if (e instanceof IdentifiableError) { if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing); 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..61967dd2bdb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followers.ts @@ -0,0 +1,81 @@ +/* + * 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 { 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 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); + if (!this.channelService.isChannelManager(channel, me)) 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..bd10e92e3ef --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followers/remove.ts @@ -0,0 +1,50 @@ +/* + * 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 { 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, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (channel == null) throw new ApiError(meta.errors.noSuchChannel); + if (!this.channelService.isChannelManager(channel, me)) throw new ApiError(meta.errors.accessDenied); + if (this.channelService.isChannelManager(channel, { id: ps.userId })) 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..34af41814f0 100644 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -65,6 +65,8 @@ export default class extends Endpoint { // eslint- } else { query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); } + } else { + query.andWhere('channel.isUnlisted = FALSE'); } const channels = await query diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 9c72e0fdbd6..88469f95aad 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -11,6 +11,7 @@ import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ChannelService } from '@/core/ChannelService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,6 +66,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', @@ -90,6 +93,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private channelService: ChannelService, + private channelFollowingService: ChannelFollowingService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -142,7 +146,7 @@ export default class extends Endpoint { // eslint- channel.userId = ps.transferAdminUserId; } - await this.channelsRepository.update(channel.id, { + const updates = { ...(ps.name ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), ...(ps.pinnedNoteIds ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), @@ -152,8 +156,13 @@ 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.transferAdminUserId !== undefined && channel.userId === ps.transferAdminUserId ? { userId: ps.transferAdminUserId } : {}), - }); + }; + if (Object.keys(updates).length > 0) await this.channelsRepository.update(channel.id, updates); + if (ps.isFollowApprovalRequired !== undefined) { + await this.channelFollowingService.setFollowApprovalRequired(channel, ps.isFollowApprovalRequired); + } return await this.channelEntityService.pack(channel.id, me); }); 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..f14d8eb3889 --- /dev/null +++ b/packages/backend/test/e2e/channel-follow-approval.ts @@ -0,0 +1,125 @@ +/* + * 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 list = await api('channels/search', { query: '' }, follower); + assert.strictEqual(list.status, 200); + assert.strictEqual(list.body.some(channel => channel.id === targetChannel.id), false); + + 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), true); + + const featured = await api('channels/featured', {}, follower); + assert.strictEqual(featured.status, 200); + assert.strictEqual(featured.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); + assert.strictEqual(pendingShow.body.followersCount, 0); + + const unauthorizedList = await api('channels/follow-requests/list', { channelId: targetChannel.id }, outsider); + assert.strictEqual(unauthorizedList.status, 400); + + const collaboratorFollow = await api('channels/follow', { channelId: targetChannel.id }, collaborator); + assert.strictEqual(collaboratorFollow.status, 200); + assert.strictEqual(collaboratorFollow.body.state, 'following'); + const collaboratorFollowingShow = await api('channels/show', { channelId: targetChannel.id }, collaborator); + assert.strictEqual(collaboratorFollowingShow.body.followersCount, 1); + const removeCollaborator = await api('channels/followers/remove', { + channelId: targetChannel.id, + userId: collaborator.id, + }, owner); + assert.strictEqual(removeCollaborator.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); + assert.strictEqual(approvedShow.body.followersCount, 2); + + const followers = await api('channels/followers', { channelId: targetChannel.id }, collaborator); + assert.strictEqual(followers.status, 200); + assert.strictEqual(followers.body.some(following => following.user.id === follower.id), true); + assert.strictEqual(followers.body.some(following => following.user.id === collaborator.id), true); + + 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); + assert.strictEqual(removedShow.body.followersCount, 1); + + const secondFollow = await api('channels/follow', { channelId: targetChannel.id }, follower); + assert.strictEqual(secondFollow.body.state, 'pending'); + const disableApproval = await api('channels/update', { + channelId: targetChannel.id, + isFollowApprovalRequired: false, + }, collaborator); + assert.strictEqual(disableApproval.status, 200); + + const approvalDisabledShow = await api('channels/show', { channelId: targetChannel.id }, follower); + assert.strictEqual(approvalDisabledShow.body.isFollowApprovalRequired, false); + assert.strictEqual(approvalDisabledShow.body.isFollowing, true); + assert.strictEqual(approvalDisabledShow.body.hasPendingFollowRequest, false); + assert.strictEqual(approvalDisabledShow.body.followersCount, 2); + }); +}); diff --git a/packages/backend/test/e2e/channel.ts b/packages/backend/test/e2e/channel.ts index c2fd98cbf41..34a8eca225f 100644 --- a/packages/backend/test/e2e/channel.ts +++ b/packages/backend/test/e2e/channel.ts @@ -26,10 +26,13 @@ describe('Channel', () => { test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => { const res1 = await api('channels/follow', { channelId: channel.id }, alice); - assert.strictEqual(res1.status, 204); + assert.strictEqual(res1.status, 200); + assert.strictEqual(res1.body.state, 'following'); + const followedChannel = await api('channels/show', { channelId: channel.id }, alice); + assert.strictEqual(followedChannel.body.isFollowing, true, JSON.stringify(followedChannel.body)); const res2 = await api('channels/follow', { channelId: channel.id }, alice); - assert.strictEqual(res2.status, 400); + assert.strictEqual(res2.status, 400, JSON.stringify(res2.body)); assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING'); }); }); diff --git a/packages/backend/test/unit/ChannelFollowingService.ts b/packages/backend/test/unit/ChannelFollowingService.ts index 3b1ad72287f..efce55ab1bc 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,14 @@ describe('ChannelFollowingService', () => { return await channelFollowingsRepository.findBy({}); } + async function fetchChannelFollowRequests() { + return await channelFollowRequestsRepository.findBy({}); + } + + async function fetchFollowersCount(channel = channel1) { + return (await channelsRepository.findOneByOrFail({ id: channel.id })).followersCount; + } + async function createDriveFile(data: Partial = {}) { return await driveFilesRepository .insert({ @@ -117,6 +127,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 +148,7 @@ describe('ChannelFollowingService', () => { }); afterEach(async () => { + await channelFollowRequestsRepository.deleteAll(); await channelFollowingsRepository.deleteAll(); await channelsRepository.deleteAll(); await userProfilesRepository.deleteAll(); @@ -218,18 +230,158 @@ describe('ChannelFollowingService', () => { expect(followings).toHaveLength(1); expect(followings[0].followeeId).toBe(channel1.id); expect(followings[0].followerId).toBe(alice.id); + expect(await fetchFollowersCount()).toBe(1); + + await expect(service.follow(alice, channel1)).rejects.toMatchObject({ + id: '6e335e39-0203-4418-a936-b3f2dc987845', + }); + expect(await fetchFollowersCount()).toBe(1); + }); + }); + + 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('reports when the channel is already followed', async () => { + await service.followOrRequest(alice, channel1, false); + + const state = await service.followOrRequest(alice, channel1, false); + + expect(state).toBe('alreadyFollowing'); + expect(await fetchChannelFollowing()).toHaveLength(1); + expect(await fetchFollowersCount()).toBe(1); + }); + + test('creates a request for a channel that requires approval', async () => { + await channelsRepository.update(channel1.id, { isFollowApprovalRequired: true }); + + const state = await service.followOrRequest(alice, channel1, false); + + expect(state).toBe('pending'); + expect(await fetchChannelFollowing()).toHaveLength(0); + expect(await fetchFollowersCount()).toBe(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 () => { + await channelsRepository.update(channel1.id, { 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); + expect(await fetchFollowersCount()).toBe(1); + }); + + 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); + }); + + test('cancellation wins over a concurrent approval', async () => { + for (let i = 0; i < 5; i++) { + await createFollowRequest(); + + await Promise.all([ + service.approveRequest(bob, channel1), + service.unfollow(bob, channel1), + ]); + + expect(await fetchChannelFollowRequests()).toHaveLength(0); + expect(await fetchChannelFollowing()).toHaveLength(0); + expect(await fetchFollowersCount()).toBe(0); + } + }); + + test('approves pending requests when approval is disabled', async () => { + await createFollowRequest(); + const requestId = (await fetchChannelFollowRequests())[0].id; + + await service.setFollowApprovalRequired(channel1, false); + + expect(await fetchChannelFollowRequests()).toHaveLength(0); + const followings = await fetchChannelFollowing(); + expect(followings).toHaveLength(1); + expect(followings[0].followerId).toBe(bob.id); + expect(followings[0].id).not.toBe(requestId); + expect(await fetchFollowersCount()).toBe(1); }); }); describe('unfollow', () => { test('default', async () => { - await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await service.follow(alice, channel1); await service.unfollow(alice, channel1); const followings = await fetchChannelFollowing(); expect(followings).toHaveLength(0); + expect(await fetchFollowersCount()).toBe(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); + }); + }); + + describe('unfollowAll', () => { + test('removes all channel followings and updates their counts', async () => { + await service.follow(alice, channel1); + await service.follow(alice, channel2); + expect(await fetchFollowersCount(channel1)).toBe(1); + expect(await fetchFollowersCount(channel2)).toBe(1); + + await service.unfollowAll(alice); + + expect(await fetchChannelFollowing()).toHaveLength(0); + expect(await fetchFollowersCount(channel1)).toBe(0); + expect(await fetchFollowersCount(channel2)).toBe(0); }); }); }); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 109044ac491..74e012e908f 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -38,10 +38,13 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: color: '#000', isArchived: false, usersCount: 1, + followersCount: 1, notesCount: 1, 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..a068a18ff9f 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -45,10 +45,14 @@ export const Default = { const canvas = within(canvasElement); const buttonElement = canvas.getByRole('button'); await expect(buttonElement).toHaveTextContent(i18n.ts.follow); + await expect(buttonElement).toHaveTextContent('1'); await userEvent.click(buttonElement); await sleep(1000); await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow); + await expect(buttonElement).toHaveTextContent('2'); await userEvent.click(buttonElement); + await sleep(1000); + await expect(buttonElement).toHaveTextContent('1'); }, parameters: { layout: 'centered', @@ -57,8 +61,44 @@ 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); + await expect(buttonElement).toHaveTextContent('1'); + }, + 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..652d39d6265 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