Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## Unreleased

### General
-
- Feat: チャンネルの非掲載・フォロー承認制・フォロワー管理に対応

### Client
- Feat: センシティブなメディアをタップ中だけ表示するオプションを追加
Expand Down
9 changes: 9 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2409,6 +2409,15 @@ _channel:
nameOnly: "名前のみ"
allowRenoteToExternal: "チャンネル外へのリノートと引用リノートを許可する"
isLocalOnly: "チャンネルでの投稿をローカルのみに制限する"
isUnlisted: "チャンネル一覧に掲載しない"
isUnlistedDescription: "チャンネル一覧・トレンドには表示されません。検索やURLからは引き続き閲覧できます。"
isFollowApprovalRequired: "フォローを承認制にする"
isFollowApprovalRequiredDescription: "共同管理者またはチャンネル管理者が承認するまで、フォローは申請中になります。"
followerManagement: "フォロワー管理"
followRequests: "フォロー申請"
noFollowers: "フォロワーはいません"
removeFollowerConfirm: "{name}をこのチャンネルのフォロワーから削除しますか?"
cancelFollowRequestConfirm: "{name}へのフォロー申請をキャンセルしますか?"
addCollaborator: "共同管理者を追加"
collaborators: "共同管理者"
transferAdminConfirmTitle: "管理者権限の移譲"
Expand Down
41 changes: 41 additions & 0 deletions packages/backend/migration/1787278753407-ChannelFollowApproval.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
}
222 changes: 205 additions & 17 deletions packages/backend/src/core/ChannelFollowingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>;

constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
Expand All @@ -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,
) {
Expand Down Expand Up @@ -95,21 +101,20 @@ export class ChannelFollowingService implements OnModuleInit {

@bindThis
public async follow(
requestUser: MiLocalUser,
requestUser: Pick<MiUser, 'id'>,
targetChannel: MiChannel,
): Promise<void> {
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,
Expand All @@ -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<MiUser, 'id'>,
targetChannel: MiChannel,
): Promise<void> {
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', {
Expand All @@ -133,6 +193,134 @@ export class ChannelFollowingService implements OnModuleInit {
});
}

@bindThis
public async unfollowAll(requestUser: Pick<MiUser, 'id'>): Promise<void> {
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<MiUser, 'id'>,
targetChannel: MiChannel,
): Promise<boolean> {
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<MiUser, 'id'>,
targetChannel: MiChannel,
): Promise<boolean> {
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<void> {
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);
Comment on lines +266 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Batch pending approvals instead of issuing per-request writes

When approval is disabled on a channel with many pending requests, this loop performs a following insert and a separate counter update for every request, sequentially, while holding the channel's write lock. A popular channel with thousands of requests can therefore generate thousands of round trips, time out the channels/update call, and block follow/unfollow operations for the duration; convert the requests to followings and update the count with bounded bulk queries.

Useful? React with 👍 / 👎.

}
}
}

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<MiChannel> {
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<boolean> {
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<void> {
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<void> {
const obj = JSON.parse(data);
Expand Down
15 changes: 9 additions & 6 deletions packages/backend/src/core/ChannelService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,24 @@ export class ChannelService {
) {
}

@bindThis
public isChannelManager(
channel: MiChannel,
user: Pick<MiUser, 'id'>,
): boolean {
return channel.userId === user.id || getCollaboratorIds(channel).includes(user.id);
}

@bindThis
public async canEditChannel(
channel: MiChannel,
user: Pick<MiUser, 'id'>,
isModerator: boolean,
): Promise<boolean> {
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;
}

Expand Down
Loading
Loading