From 755695900dbd2e6f8d20da354fed02993daff4c7 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sun, 4 Oct 2026 21:56:25 +0000 Subject: [PATCH 01/11] feat(calls): add channel chat for public rooms --- .../1791148387720-CallsRoomChannel.js | 18 ++++ .../src/core/calls/CallsRoomService.ts | 91 ++++++++++++------- .../src/core/entities/CallsEntityService.ts | 1 + packages/backend/src/models/CallsRoom.ts | 8 ++ .../src/models/json-schema/calls-room.ts | 1 + .../server/api/endpoints/channels/search.ts | 3 +- .../test/unit/core/calls/CallsRoomService.ts | 64 ++++++++++++- .../MkCallsRoomCard.stories.impl.ts | 1 + .../MkCallsRoomWindow.stories.impl.ts | 1 + .../src/components/MkCallsRoomWindow.vue | 26 +++++- .../test/unit/calls-join-confirmation.test.ts | 18 +++- packages/misskey-js/src/autogen/types.ts | 2 + 12 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 packages/backend/migration/1791148387720-CallsRoomChannel.js diff --git a/packages/backend/migration/1791148387720-CallsRoomChannel.js b/packages/backend/migration/1791148387720-CallsRoomChannel.js new file mode 100644 index 00000000000..920bb51809d --- /dev/null +++ b/packages/backend/migration/1791148387720-CallsRoomChannel.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CallsRoomChannel1791148387720 { + name = 'CallsRoomChannel1791148387720'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "calls_room" ADD "channelId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "calls_room" ADD CONSTRAINT "FK_calls_room_channel" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "calls_room" DROP CONSTRAINT "FK_calls_room_channel"`); + await queryRunner.query(`ALTER TABLE "calls_room" DROP COLUMN "channelId"`); + } +} diff --git a/packages/backend/src/core/calls/CallsRoomService.ts b/packages/backend/src/core/calls/CallsRoomService.ts index 877afc9afe7..6d9e29c59fd 100644 --- a/packages/backend/src/core/calls/CallsRoomService.ts +++ b/packages/backend/src/core/calls/CallsRoomService.ts @@ -13,11 +13,15 @@ import type { CallsRoomsRepository, MiCallsModerationLog, ChatRoomsRepository, + ChannelsRepository, MiCallsParticipant, MiCallsRoom, MiChatRoom, MiUser, } from '@/models/_.js'; +import { MiChannel } from '@/models/Channel.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import type { CallsModerationAction } from '@/models/CallsModerationLog.js'; import type { CallsParticipantRole } from '@/models/CallsParticipant.js'; import type { CallsRoomMode, CallsRoomVisibility } from '@/models/CallsRoom.js'; @@ -79,6 +83,9 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { private callsEventService: CallsEventService, private callsMediaRevocationService: CallsMediaRevocationService, private callsTelemetryService: CallsTelemetryService, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelFollowingService: ChannelFollowingService, ) {} public async onModuleInit(): Promise { @@ -164,38 +171,49 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { if (activeAttachment != null) throw new CallsRoomError('active-attachment'); const now = new Date(); - const room = await this.callsRoomsRepository.insertOne({ - id: this.idService.gen(), - attachmentType: params.attachmentType, - ownerUserId: owner.id, - chatRoomId: params.attachmentType === 'chatRoom' ? params.chatRoomId : null, - title, - description, - mode: params.mode ?? 'open', - visibility: params.attachmentType === 'chatRoom' ? 'specified' : (params.visibility ?? 'specified'), - visibleUserIds: params.attachmentType === 'personal' && params.visibility === 'specified' ? [...new Set(params.visibleUserIds ?? [])] : [], - moderatorUserIds: [], - state: 'scheduled', - scheduledAt: params.scheduledAt ?? null, - startedAt: null, - endedAt: null, - revision: 0, - createdAt: now, - updatedAt: now, - }); + const room = await this.callsRoomsRepository.manager.transaction(async manager => { + const channel = params.attachmentType === 'personal' && params.visibility === 'public' + ? await manager.getRepository(MiChannel).save({ + id: this.idService.gen(), userId: owner.id, name: title.slice(0, 128), + description, isUnlisted: true, isLocalOnly: true, allowRenoteToExternal: false, + }) + : null; + const room = await manager.getRepository(this.callsRoomsRepository.target).save({ + id: this.idService.gen(), + attachmentType: params.attachmentType, + ownerUserId: owner.id, + chatRoomId: params.attachmentType === 'chatRoom' ? params.chatRoomId : null, + channelId: channel?.id ?? null, + title, + description, + mode: params.mode ?? 'open', + visibility: params.attachmentType === 'chatRoom' ? 'specified' : (params.visibility ?? 'specified'), + visibleUserIds: params.attachmentType === 'personal' && params.visibility === 'specified' ? [...new Set(params.visibleUserIds ?? [])] : [], + moderatorUserIds: [], + state: 'scheduled', + scheduledAt: params.scheduledAt ?? null, + startedAt: null, + endedAt: null, + revision: 0, + createdAt: now, + updatedAt: now, + }); - await this.callsParticipantsRepository.insertOne({ - id: this.idService.gen(), - roomId: room.id, - userId: owner.id, - role: 'host', - state: 'active', - isMuted: false, - joinedAt: now, - leftAt: null, - speakerRequestedAt: null, - updatedAt: now, + await manager.getRepository(this.callsParticipantsRepository.target).save({ + id: this.idService.gen(), + roomId: room.id, + userId: owner.id, + role: 'host', + state: 'active', + isMuted: false, + joinedAt: now, + leftAt: null, + speakerRequestedAt: null, + updatedAt: now, + }); + return room; }); + await this.followRoomChannel(owner, room); this.callsTelemetryService.lifecycle({ action: 'room-created', roomId: room.id }); this.callsEventService.publishRoomsList('created', { roomId: room.id }); @@ -395,7 +413,10 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { if (room.state !== 'open') throw new CallsRoomError('invalid-state'); const current = await this.callsParticipantsRepository.findOneBy({ roomId, userId: user.id }); - if (current?.state === 'active') return current; + if (current?.state === 'active') { + await this.followRoomChannel(user, room); + return current; + } const role = current?.role === 'host' ? 'host' : room.mode === 'open' ? 'speaker' : 'listener'; await this.assertCapacity(room, role); @@ -426,12 +447,19 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { updatedAt: now, }); } + await this.followRoomChannel(user, room); const revision = await this.bumpRevision(roomId); await this.callsEventService.publish(roomId, revision, 'participant', { participantId: joined.id, action: 'joined' }); this.callsTelemetryService.lifecycle({ action: 'participant-joined', roomId, participantId: joined.id }); return joined; } + private async followRoomChannel(user: MiUser, room: MiCallsRoom): Promise { + if (room.channelId == null) return; + const channel = await this.channelsRepository.findOneByOrFail({ id: room.channelId }); + await this.channelFollowingService.followOrRequest(user as MiLocalUser, channel, true); + } + @bindThis public async leave(user: MiUser, roomId: string, identity?: CallsConnectionIdentity & { token?: string }): Promise { if (identity?.token != null) { @@ -646,6 +674,7 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { .returning('*').execute(); if (result.affected !== 1) throw new CallsRoomError('stale-revision'); const updated = result.raw[0] as MiCallsRoom; + if (updated.channelId != null) await this.channelsRepository.update(updated.channelId, { name: updated.title.slice(0, 128) }); await this.callsEventService.publish(roomId, updated.revision, 'title', { title: updated.title }); this.callsEventService.publishRoomsList('updated', { roomId, action: 'title' }); return updated; diff --git a/packages/backend/src/core/entities/CallsEntityService.ts b/packages/backend/src/core/entities/CallsEntityService.ts index 87043a9806e..a6e9604452c 100644 --- a/packages/backend/src/core/entities/CallsEntityService.ts +++ b/packages/backend/src/core/entities/CallsEntityService.ts @@ -20,6 +20,7 @@ export class CallsEntityService { } : { type: 'chatRoom', ownerUserId: room.ownerUserId, chatRoomId: room.chatRoomId!, }, + channelId: room.channelId ?? null, title: room.title, description: room.description, moderatorUserIds: room.moderatorUserIds, diff --git a/packages/backend/src/models/CallsRoom.ts b/packages/backend/src/models/CallsRoom.ts index 040b26ee1a6..3a4c52dc590 100644 --- a/packages/backend/src/models/CallsRoom.ts +++ b/packages/backend/src/models/CallsRoom.ts @@ -4,6 +4,7 @@ */ import { Check, Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { MiChannel } from './Channel.js'; import { MiChatRoom } from './ChatRoom.js'; import { MiUser } from './User.js'; import { id } from './util/id.js'; @@ -54,6 +55,13 @@ export class MiCallsRoom { @JoinColumn({ name: 'chatRoomId' }) public chatRoom: MiChatRoom | null; + @Column({ ...id(), nullable: true }) + public channelId: MiChannel['id'] | null; + + @ManyToOne(() => MiChannel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'channelId', foreignKeyConstraintName: 'FK_calls_room_channel' }) + public channel: MiChannel | null; + @Column('varchar', { length: 256 }) public title: string; diff --git a/packages/backend/src/models/json-schema/calls-room.ts b/packages/backend/src/models/json-schema/calls-room.ts index 68393fca373..f6b4e9fbcaf 100644 --- a/packages/backend/src/models/json-schema/calls-room.ts +++ b/packages/backend/src/models/json-schema/calls-room.ts @@ -29,6 +29,7 @@ export const packedCallsRoomSchema = { { type: 'object', properties: { type: { type: 'string', enum: ['chatRoom'], optional: false, nullable: false }, ownerUserId: { type: 'string', format: 'id', optional: false, nullable: false }, chatRoomId: { type: 'string', format: 'id', optional: false, nullable: false } } }, ], }, + channelId: { type: 'string', format: 'id', optional: false, nullable: true }, title: { type: 'string', optional: false, nullable: false }, description: { type: 'string', optional: false, nullable: false }, moderatorUserIds: { type: 'array', items: { type: 'string', format: 'id' }, optional: false, nullable: false }, diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts index 34af41814f0..13237f066f2 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('NOT EXISTS (SELECT 1 FROM calls_room WHERE calls_room."channelId" = channel.id)'); if (ps.query !== '') { if (ps.type === 'nameAndDescription') { diff --git a/packages/backend/test/unit/core/calls/CallsRoomService.ts b/packages/backend/test/unit/core/calls/CallsRoomService.ts index c94276ea5f6..cbc16365a70 100644 --- a/packages/backend/test/unit/core/calls/CallsRoomService.ts +++ b/packages/backend/test/unit/core/calls/CallsRoomService.ts @@ -8,6 +8,7 @@ import { QueryFailedError } from 'typeorm'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import type { MiCallsRoom, MiUser } from '@/models/_.js'; +import { MiChannel } from '@/models/Channel.js'; import { CallsMediaRevocationService } from '@/core/calls/CallsMediaRevocationService.js'; import { CallsFeatureDisabledError, CallsRoomError, CallsRoomService, isCallsRoomTransitionAllowed } from '@/core/calls/CallsRoomService.js'; @@ -20,6 +21,7 @@ function createAccessFixture(options?: { enabled?: boolean; chatMember?: boolean { cloudflareRealtime: options?.enabled === false ? undefined : { enabled: true } } as Config, {} as never, {} as never, {} as never, chatRooms as never, cache as never, chat as never, {} as never, role as never, {} as never, {} as never, {} as never, {} as never, + {} as never, {} as never, ); return service; } @@ -45,11 +47,62 @@ function createConnectionFixture(role: 'host' | 'listener') { { isModerator: async () => false, getUserPolicies: async () => ({ ...DEFAULT_POLICIES }) } as never, live as never, { publish: vi.fn(), publishRoomsList: vi.fn() } as never, { revokeRoom, revokeParticipant, revokeDisconnectedGeneration, closeGeneration: vi.fn() } as never, { lifecycle: vi.fn() } as never, + {} as never, {} as never, ); return { service, room, live, update, execute, revokeRoom, revokeParticipant, revokeDisconnectedGeneration }; } describe('CallsRoomService lifecycle', () => { + test.each(['public', 'followers', 'specified'] as const)('creates a hidden chat channel only for public Calls (visibility: %s)', async visibility => { + const service = createAccessFixture(); + const saveChannel = vi.fn(async (channel: object) => channel); + const saveRoom = vi.fn(async (room: object) => room); + const saveParticipant = vi.fn(); + const followOrRequest = vi.fn(); + Object.assign(service, { + callsRoomsRepository: { target: 'rooms', findOneBy: async () => null, manager: { + transaction: async (callback: (manager: unknown) => Promise) => callback({ + getRepository: (target: unknown) => ({ save: target === MiChannel ? saveChannel : target === 'rooms' ? saveRoom : saveParticipant }), + }), + } }, + channelsRepository: { findOneByOrFail: async () => ({ id: 'created' }) }, + channelFollowingService: { followOrRequest }, + idService: { gen: () => 'created' }, + callsEventService: { publishRoomsList: vi.fn() }, + callsTelemetryService: { lifecycle: vi.fn() }, + }); + const room = await service.create(viewer, { attachmentType: 'personal', title: 'Call chat', visibility }); + expect(room.channelId).toBe(visibility === 'public' ? 'created' : null); + if (visibility === 'public') { + expect(saveChannel).toHaveBeenCalledWith(expect.objectContaining({ name: 'Call chat', isUnlisted: true, isLocalOnly: true, allowRenoteToExternal: false })); + expect(followOrRequest).toHaveBeenCalledWith(viewer, { id: 'created' }, true); + } else { + expect(saveChannel).not.toHaveBeenCalled(); + expect(followOrRequest).not.toHaveBeenCalled(); + } + expect(saveParticipant).toHaveBeenCalledWith(expect.objectContaining({ role: 'host', userId: viewer.id })); + }); + + test.each([false, true])('automatically follows the Calls channel when a listener joins, including reconnects (active: %s)', async active => { + const service = createAccessFixture(); + const room = { ...baseRoom, state: 'open', mode: 'stage', visibility: 'public', channelId: 'channel-a' }; + const participant = { id: 'listener-a', role: 'listener', state: 'active' }; + const followOrRequest = vi.fn().mockResolvedValue(active ? 'alreadyFollowing' : 'following'); + const builder = { update: () => builder, set: () => builder, where: () => builder, returning: () => builder, execute: async () => ({ affected: 1, raw: [{ revision: 1 }] }) }; + Object.assign(service, { + callsRoomsRepository: { findOneBy: async () => room, createQueryBuilder: () => builder }, + callsParticipantsRepository: { findOneBy: async () => active ? participant : null, findBy: async () => [], insertOne: async () => participant }, + channelsRepository: { findOneByOrFail: async () => ({ id: room.channelId }) }, + channelFollowingService: { followOrRequest }, + callsLiveConnectionService: { withRoomLock: async (_roomId: string, callback: () => Promise) => callback() }, + idService: { gen: () => participant.id }, + callsEventService: { publish: vi.fn() }, + callsTelemetryService: { lifecycle: vi.fn() }, + }); + await expect(service.join(viewer, room.id)).resolves.toMatchObject({ role: 'listener' }); + expect(followOrRequest).toHaveBeenCalledWith(viewer, { id: room.channelId }, true); + }); + test('filters followed hosts and active participants before limiting rooms, preserving access checks', async () => { const service = createAccessFixture({ followings: { 'followed-host': {}, 'followed-listener': {} } }); const hosted = { ...baseRoom, id: 'hosted', ownerUserId: 'followed-host', state: 'open', visibility: 'public' }; @@ -123,10 +176,11 @@ describe('CallsRoomService lifecycle', () => { const insertParticipant = vi.fn(); const service = new CallsRoomService( { cloudflareRealtime: { enabled: true } } as Config, - { findOneBy: async () => null, insertOne: insertRoom } as never, + { findOneBy: async () => null, target: 'rooms', manager: { transaction: async (callback: (manager: unknown) => Promise) => callback({ getRepository: (target: string) => ({ save: target === 'rooms' ? insertRoom : insertParticipant }) }) } } as never, { insertOne: insertParticipant } as never, {} as never, {} as never, {} as never, {} as never, { gen: () => 'created-a' } as never, { getUserPolicies: async () => ({ ...DEFAULT_POLICIES }) } as never, {} as never, { publishRoomsList: vi.fn() } as never, {} as never, { lifecycle: vi.fn() } as never, + {} as never, {} as never, ); const operation = service.create(viewer, { attachmentType, chatRoomId: 'chat-a', title: 'Call' }); if (attachmentType === 'chatRoom') { @@ -157,6 +211,7 @@ describe('CallsRoomService lifecycle', () => { { isModerator: async () => false, getUserPolicies: async (id: string) => ({ ...DEFAULT_POLICIES, callsRoomSpeakerLimit: id === room.ownerUserId ? limit : 0, callsRoomListenerLimit: id === room.ownerUserId ? limit : 0 }) } as never, { withRoomLock: async (_roomId: string, callback: () => Promise) => callback(), get: async () => ({}) } as never, { publish: vi.fn() } as never, {} as never, { lifecycle: vi.fn() } as never, + {} as never, {} as never, ); const operation = service.join(viewer, room.id); if (allowed) { @@ -264,6 +319,7 @@ describe('CallsRoomService lifecycle', () => { { findOneBy: async () => room, find: async () => [room], insertOne: roomInsert } as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, { isModerator: async () => false, getUserPolicies: async () => ({ ...DEFAULT_POLICIES }) } as never, {} as never, {} as never, {} as never, {} as never, + {} as never, {} as never, ); const owner = { id: 'owner-a', host: null } as MiUser; await expect(service.create(owner, { attachmentType: 'personal', title: 'Next room' })).rejects.toMatchObject({ code: 'active-attachment' }); @@ -303,6 +359,7 @@ describe('CallsRoomService lifecycle', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, { withRoomLock: async (_roomId: string, callback: () => Promise) => callback(), isReconnectTokenConsumed: async () => reconnectConsumed, get: async () => connection, clear: async () => true } as never, { publish } as never, { revokeDisconnectedGeneration, closeGeneration: vi.fn() } as never, { lifecycle: () => undefined } as never, + {} as never, {} as never, ); const reconnect = { token: crypto.randomUUID(), connectionId: connection.connectionId, generation: connection.generation }; @@ -375,6 +432,7 @@ function createModerationFixture() { { insert: insertLog } as never, {} as never, {} as never, {} as never, { gen: () => 'log-a' } as never, { isModerator, getUserPolicies: async () => ({ ...DEFAULT_POLICIES }) } as never, { withRoomLock: async (_roomId: string, callback: () => Promise) => callback() } as never, { publish, publishRoomsList } as never, { revokeParticipant, stopParticipantVideo } as never, { lifecycle: vi.fn() } as never, + {} as never, {} as never, ); return { service, config, isModerator, room, participant, existsBy, update, set, execute, revokeParticipant, stopParticipantVideo, insertLog, publish, publishRoomsList }; } @@ -383,8 +441,12 @@ describe('Calls room titles', () => { test.each(['scheduled', 'open'] as const)('the host can rename a %s room and notify viewers', async state => { const fixture = createModerationFixture(); fixture.room.state = state; + fixture.room.channelId = 'channel-a'; + const updateChannel = vi.fn(); + Object.assign(fixture.service, { channelsRepository: { update: updateChannel } }); fixture.execute.mockResolvedValue({ affected: 1, raw: [{ ...fixture.room, title: 'New title', revision: 2 }] }); await expect(fixture.service.updateTitle({ id: 'owner-a' } as MiUser, fixture.room.id, ' \u0000New title ', 1)).resolves.toMatchObject({ title: 'New title', revision: 2 }); + expect(updateChannel).toHaveBeenCalledWith('channel-a', { name: 'New title' }); expect(fixture.set).toHaveBeenCalledWith(expect.objectContaining({ title: 'New title' })); expect(fixture.publish).toHaveBeenCalledWith(fixture.room.id, 2, 'title', { title: 'New title' }); expect(fixture.publishRoomsList).toHaveBeenCalledWith('updated', { roomId: fixture.room.id, action: 'title' }); diff --git a/packages/frontend/src/components/MkCallsRoomCard.stories.impl.ts b/packages/frontend/src/components/MkCallsRoomCard.stories.impl.ts index 9db4529136d..0a0fa48be1c 100644 --- a/packages/frontend/src/components/MkCallsRoomCard.stories.impl.ts +++ b/packages/frontend/src/components/MkCallsRoomCard.stories.impl.ts @@ -14,6 +14,7 @@ import type * as Misskey from 'misskey-js'; const host = { ...userLite(), isFollowing: false, isFollowed: false }; const room = { id: 'calls-room-story', attachment: { type: 'personal', ownerUserId: host.id }, + channelId: null, title: '今日のMisskeyについて話そう', description: '', mode: 'stage', visibility: 'public', moderatorUserIds: [], state: 'open', scheduledAt: null, startedAt: '2026-10-02T00:00:00.000Z', endedAt: null, revision: 1, createdAt: '2026-10-02T00:00:00.000Z', updatedAt: '2026-10-02T00:00:00.000Z', diff --git a/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts b/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts index 4832785f6f1..4dece04c247 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts @@ -12,6 +12,7 @@ import type * as Misskey from 'misskey-js'; const host = { ...userLite(), isFollowing: false, isFollowed: false }; const room = { id: 'calls-window-story', attachment: { type: 'personal', ownerUserId: host.id }, + channelId: null, title: '今日のMisskeyについて話そう', description: '', mode: 'stage', visibility: 'public', moderatorUserIds: [], state: 'open', scheduledAt: null, startedAt: '2026-10-02T00:00:00.000Z', endedAt: null, revision: 1, createdAt: '2026-10-02T00:00:00.000Z', updatedAt: '2026-10-02T00:00:00.000Z', diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 5afd7645a55..60d05eae366 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only

{{ room?.title ?? i18n.ts._calls.title }}

{{ session.elapsedTime.value }} + @@ -27,7 +28,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ failureText }} {{ i18n.ts._calls.speakerRequestRejected }} -
+
+ {{ i18n.ts.somethingHappened }} + {{ i18n.ts.note }} + +
+
@@ -110,6 +116,7 @@ import MkModal from '@/components/MkModal.vue'; import MkCallsControls from '@/components/MkCallsControls.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import { createCallsRoomConnection } from '@/composables/use-calls-room.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; @@ -211,6 +218,22 @@ const canJoinCalls = computed(() => $i?.policies.canJoinCalls !== false); const isSessionRoom = computed(() => session.currentRoomId.value === props.roomId); const pageConnection = shallowRef | null>(isSessionRoom.value ? null : createCallsRoomConnection(props.roomId)); const room = computed(() => isSessionRoom.value ? session.room.value : pageConnection.value?.room.value ?? null); +const showChat = shallowRef(false); +const chatChannel = shallowRef(null); +const chatLoadFailed = shallowRef(false); +watch(() => room.value?.channelId, async (channelId, _, onCleanup) => { + let cancelled = false; + onCleanup(() => { cancelled = true; }); + chatChannel.value = null; + chatLoadFailed.value = false; + if (channelId == null) return; + try { + const channel = await misskeyApi('channels/show', { channelId }); + if (!cancelled) chatChannel.value = channel; + } catch { + if (!cancelled) chatLoadFailed.value = true; + } +}, { immediate: true }); const participants = computed(() => isSessionRoom.value ? session.participants.value : pageConnection.value?.participants.value ?? []); const connected = computed(() => isSessionRoom.value ? session.connected.value : pageConnection.value?.connected.value ?? false); const speakingParticipantIds = computed(() => isSessionRoom.value ? session.speakingParticipantIds.value : pageConnection.value?.speakingParticipantIds.value ?? new Set()); @@ -529,6 +552,7 @@ watch(roomVideos, videos => { if (!videos.some(video => video.id === focusedVide .elapsedTime { font-variant-numeric: tabular-nums; color: var(--MI_THEME-fgTransparentWeak); } .menuButton { width: 36px; height: 36px; border-radius: 50%; font-size: 20px; } .body { display: flex; flex: 1; min-height: 0; flex-direction: column; gap: 16px; padding: 16px; background: var(--MI_THEME-bg); } +.chat { flex: 1; min-height: 0; overflow: auto; } .callLayout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; } .stage { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; overflow: auto; } .participantArea { grid-column: 1; grid-row: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 24px; padding: 16px; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } diff --git a/packages/frontend/test/unit/calls-join-confirmation.test.ts b/packages/frontend/test/unit/calls-join-confirmation.test.ts index fa432158cd6..48d34a58380 100644 --- a/packages/frontend/test/unit/calls-join-confirmation.test.ts +++ b/packages/frontend/test/unit/calls-join-confirmation.test.ts @@ -16,9 +16,10 @@ import type { MenuButton } from '@/types/menu.js'; const fixture = vi.hoisted(() => ({ confirm: vi.fn(), inputText: vi.fn(), session: null as any, connection: null as any, policies: { canJoinCalls: true } })); vi.mock('@/i.js', () => ({ $i: { id: 'viewer', policies: fixture.policies } })); vi.mock('@/components/MkModal.vue', () => ({ default: { template: '
', methods: { close() {} } } })); +vi.mock('@/components/MkStreamingNotesTimeline.vue', () => ({ default: { props: ['src', 'channel'], template: '
' } })); vi.mock('@/components/MkButton.vue', () => ({ default: { template: '' } })); vi.mock('@/components/global/MkA.vue', () => ({ default: { props: ['to'], template: '' } })); -vi.mock('@/os.js', () => ({ confirm: fixture.confirm, inputText: fixture.inputText, toast: vi.fn(), alert: vi.fn(), popupMenu: vi.fn(), contextMenu: vi.fn() })); +vi.mock('@/os.js', () => ({ __v_isRef: false, post: vi.fn(), confirm: fixture.confirm, inputText: fixture.inputText, toast: vi.fn(), alert: vi.fn(), popupMenu: vi.fn(), contextMenu: vi.fn() })); vi.mock('@/utility/calls-session.js', () => ({ useCallsSession: () => fixture.session })); vi.mock('@/composables/use-calls-room.js', () => ({ createCallsRoomConnection: () => fixture.connection })); vi.mock('@/utility/misskey-api.js', () => ({ misskeyApi: vi.fn().mockResolvedValue({ id: 'host', username: 'host', name: 'Host' }) })); @@ -27,7 +28,7 @@ const stubs = { MkModal: { template: '
', methods: { close() {} } }, MkButton: { template: '' }, MkA: { props: ['to'], template: '' }, - MkInfo: true, MkAvatar: true, MkUserName: true, MkLoading: true, MkCallsControls: true, CallsVideo: true, + MkInfo: true, MkAvatar: true, MkUserName: true, MkLoading: true, MkCallsControls: true, CallsVideo: true, MkStreamingNotesTimeline: true, }; beforeEach(() => { @@ -53,6 +54,19 @@ beforeEach(() => { afterEach(cleanup); describe('Calls room window', () => { + test('opens the Calls channel timeline and composes a post to that channel', async () => { + const channel = { id: 'calls-channel', name: 'Call chat' }; + fixture.connection.room.value = { ...fixture.connection.room.value, channelId: channel.id }; + vi.mocked(misskeyApi).mockImplementation(async endpoint => endpoint === 'channels/show' ? channel : { id: 'host', username: 'host' }); + const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs: { ...stubs, MkStreamingNotesTimeline: false } } }); + await fireEvent.click(view.getByRole('button', { name: i18n.ts.chat })); + expect(view.getByTestId('calls-chat-timeline').getAttribute('data-channel')).toBe(channel.id); + await waitFor(() => expect(view.getByRole('button', { name: i18n.ts.note })).toBeTruthy()); + await fireEvent.click(view.getByRole('button', { name: i18n.ts.note })); + expect(os.post).toHaveBeenCalledWith({ channel }); + }); + + test.each([true, false])('puts unmuted speakers first and updates their order (joined: %s)', async joined => { const participants = [ { id: 'zed', userId: 'zed', role: 'host', isMuted: true }, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 48f4f653e56..80f2c7310d9 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -6483,6 +6483,8 @@ export type components = { /** Format: id */ chatRoomId: string; }; + /** Format: id */ + channelId: string | null; title: string; description: string; moderatorUserIds: string[]; From 7d025fb548e4d48cd43ca1c4e7633fddeec6ee22 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sun, 4 Oct 2026 22:13:01 +0000 Subject: [PATCH 02/11] test(calls): add channel chat UI preview story --- .../MkCallsRoomWindow.stories.impl.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts b/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts index 4dece04c247..d2026fbc887 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts @@ -4,7 +4,8 @@ */ import { http, HttpResponse } from 'msw'; -import { userLite } from '../../.storybook/fakes.js'; +import { within } from '@storybook/test'; +import { channel, note, userLite } from '../../.storybook/fakes.js'; import MkCallsRoomWindow from './MkCallsRoomWindow.vue'; import type { StoryObj } from '@storybook/vue3'; import type * as Misskey from 'misskey-js'; @@ -28,3 +29,33 @@ export const Default = { ] }, }, } satisfies StoryObj; + +const chatChannel = { ...channel('calls-chat-story', room.title, null), isUnlisted: true, isLocalOnly: true }; +const listener = userLite('calls-listener-story', 'listener', null, '聞き専の人'); +const chatNotes = [ + { ...note('calls-chat-note-2'), text: '聞き専ですが、ここから参加しています!', user: listener, userId: listener.id }, + { ...note('calls-chat-note-1'), text: '通話の感想や質問は、このチャンネルにどうぞ。', user: host, userId: host.id }, +].map(item => ({ ...item, channelId: chatChannel.id, channel: { id: chatChannel.id, name: chatChannel.name, color: chatChannel.color }, localOnly: true, createdAt: room.startedAt })); + +export const ChannelChat = { + ...Default, + parameters: { + ...Default.parameters, + msw: { handlers: [ + http.post('/api/calls/rooms/show', () => HttpResponse.json({ room: { ...room, channelId: chatChannel.id }, participants: [ + { id: 'calls-host-story', roomId: room.id, userId: host.id, role: 'host', state: 'active', isMuted: true, joinedAt: room.startedAt, leftAt: null, speakerRequestedAt: null, user: host }, + { id: 'calls-listener-story', roomId: room.id, userId: listener.id, role: 'listener', state: 'active', isMuted: true, joinedAt: room.startedAt, leftAt: null, speakerRequestedAt: null, user: listener }, + ] })), + http.post('/api/channels/show', () => HttpResponse.json(chatChannel)), + http.post('/api/channels/timeline', async ({ request }) => { + const params = await request.json() as { untilId?: string }; + return HttpResponse.json(params.untilId == null ? chatNotes : []); + }), + ] }, + }, + async play() { + const body = within(document.body); + (await body.findByRole('button', { name: 'キャンセル' })).click(); + (await body.findByRole('button', { name: 'チャット' })).click(); + }, +} satisfies StoryObj; From 8dd45f20cbf3f6b880b15807b49047e098f74879 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sun, 4 Oct 2026 22:16:52 +0000 Subject: [PATCH 03/11] fix(calls): use window document in preview story --- .../frontend/src/components/MkCallsRoomWindow.stories.impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts b/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts index d2026fbc887..ef78c12ce1a 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkCallsRoomWindow.stories.impl.ts @@ -54,7 +54,7 @@ export const ChannelChat = { ] }, }, async play() { - const body = within(document.body); + const body = within(window.document.body); (await body.findByRole('button', { name: 'キャンセル' })).click(); (await body.findByRole('button', { name: 'チャット' })).click(); }, From 287ab783cb0747426102d1b28213a56e88c37249 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Tue, 6 Oct 2026 15:05:42 +0000 Subject: [PATCH 04/11] feat(calls): show closable chat sidebar with fixed composer --- .../src/components/MkCallsRoomWindow.vue | 30 ++++++++++++++----- .../test/unit/calls-join-confirmation.test.ts | 13 +++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 8e930ced624..f1df4681891 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -34,12 +34,20 @@ SPDX-License-Identifier: AGPL-3.0-only {{ failureText }} {{ i18n.ts._calls.speakerRequestRejected }} -
- {{ i18n.ts.somethingHappened }} - {{ i18n.ts.note }} - -
-
+
+
@@ -50,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
@@ -235,6 +248,7 @@ const canJoinCalls = computed(() => $i?.policies.canJoinCalls !== false); const isSessionRoom = computed(() => session.currentRoomId.value === props.roomId); const pageConnection = shallowRef | null>(isSessionRoom.value ? null : createCallsRoomConnection(props.roomId)); const room = computed(() => isSessionRoom.value ? session.room.value : pageConnection.value?.room.value ?? null); +const showParticipants = shallowRef(true); const showChat = shallowRef(false); const chatChannel = shallowRef(null); const chatLoadFailed = shallowRef(false); @@ -568,18 +582,24 @@ watch(roomVideos, videos => { if (!videos.some(video => video.id === focusedVide .elapsedTime { font-variant-numeric: tabular-nums; color: var(--MI_THEME-fgTransparentWeak); } .menuButton { width: 36px; height: 36px; border-radius: 50%; font-size: 20px; } .body { display: flex; flex: 1; min-height: 0; flex-direction: column; gap: 16px; padding: 16px; background: var(--MI_THEME-bg); } -.chat { grid-column: 2; grid-row: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } +.chat { height: 100%; width: var(--chat-width); box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } .chatHeader { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; border-bottom: 1px solid var(--MI_THEME-divider); } .chatComposer { flex-shrink: 0; max-height: 50%; overflow: auto; border-bottom: 1px solid var(--MI_THEME-divider); } .chatTimeline { flex: 1; min-height: 0; overflow: auto; } .endedBody { overflow: auto; } .summary { width: min(100%, 560px); box-sizing: border-box; margin: auto; padding: 24px; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } .summaryTitle { display: flex; align-items: center; gap: 8px; margin: 0 0 24px; font-size: 1.1rem; } -.callLayout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; } -.withChat { grid-template-columns: minmax(0, 1fr) min(380px, 50%); } -.withChat .stage { grid-column: 1; } -.stage { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; overflow: auto; } -.participantArea { grid-column: 1; grid-row: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 24px; padding: 16px; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } +.callLayout { --participants-width: 200px; --chat-width: min(380px, 40vw); --sidebar-gap: 16px; display: flex; flex: 1; min-height: 0; min-width: 0; overflow: hidden; } +.stage { order: 2; flex: 1; min-width: 0; min-height: 0; overflow: auto; } +.participantsSidebar, .chatSidebar { flex-shrink: 0; width: 0; overflow: hidden; transition: width 0.25s ease, margin 0.25s ease; } +.participantsSidebar { order: 1; } +.chatSidebar { order: 3; } +.participantsOpen { width: var(--participants-width); margin-right: var(--sidebar-gap); } +.chatOpen { width: var(--chat-width); margin-left: var(--sidebar-gap); } +.sidebarTransition { transition: transform 0.25s ease; } +.participantsHidden { transform: translateX(-100%); } +.chatHidden { transform: translateX(100%); } +.participantArea { width: var(--participants-width); height: 100%; box-sizing: border-box; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 24px; padding: 16px; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } .groupLabel { margin-bottom: 12px; font-size: 0.85rem; font-weight: 600; opacity: 0.65; } .people { display: flex; flex-direction: column; gap: 12px; } .person { position: relative; display: grid; min-width: 0; grid-template-columns: 40px minmax(0, 1fr); align-items: center; column-gap: 8px; row-gap: 2px; } @@ -612,15 +632,21 @@ watch(roomVideos, videos => { if (!videos.some(video => video.id === focusedVide @media (max-width: 800px) { .header { padding: 12px; } .body { padding: 12px; } - .callLayout { grid-template-columns: 120px minmax(0, 1fr); gap: 8px; } - .withChat { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); } + .callLayout { --participants-width: 120px; --sidebar-gap: 8px; } .participantArea { padding: 8px; } .person { grid-template-columns: minmax(0, 1fr); justify-items: start; } .person > .avatarWrap, .person > .listenerAvatar { grid-row: auto; } .person > small { grid-column: 1; } .footer { padding-left: 12px; padding-right: 12px; } } +@media (max-width: 600px) { + .callLayout { position: relative; --participants-width: min(240px, 80vw); --chat-width: min(380px, 85vw); } + .participantsSidebar, .chatSidebar { position: absolute; top: 0; bottom: 0; z-index: 1; } + .participantsSidebar { left: 0; } + .chatSidebar { right: 0; z-index: 2; } + .participantsOpen, .chatOpen { margin: 0; } +} @media (prefers-reduced-motion: reduce) { - .avatarWrap, .personMove { transition: none; } + .avatarWrap, .personMove, .participantsSidebar, .chatSidebar, .sidebarTransition { transition: none; } } diff --git a/packages/frontend/test/unit/calls-join-confirmation.test.ts b/packages/frontend/test/unit/calls-join-confirmation.test.ts index 5bf24cd4ef8..fed18b97cae 100644 --- a/packages/frontend/test/unit/calls-join-confirmation.test.ts +++ b/packages/frontend/test/unit/calls-join-confirmation.test.ts @@ -67,8 +67,13 @@ describe('Calls room window', () => { expect(view.getByTestId('calls-chat-composer').getAttribute('data-fixed')).toBe('true'); expect(view.getByRole('region', { name: i18n.ts._calls.title })).toBeTruthy(); expect(view.getByRole('complementary', { name: i18n.ts.chat })).toBeTruthy(); - await fireEvent.click(view.getByRole('button', { name: i18n.ts.close })); - expect(view.queryByTestId('calls-chat-timeline')).toBeNull(); + expect(view.getByRole('complementary', { name: i18n.ts.users })).toBeTruthy(); + await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button')!); + expect(view.getByRole('button', { name: i18n.ts.users }).getAttribute('aria-pressed')).toBe('false'); + expect(view.getByRole('complementary', { name: i18n.ts.chat })).toBeTruthy(); + await fireEvent.click(view.getByRole('button', { name: i18n.ts.users })); + await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.chat }).querySelector('button')!); + expect(view.getByRole('button', { name: i18n.ts.chat }).getAttribute('aria-pressed')).toBe('false'); expect(view.getByRole('complementary', { name: i18n.ts.users })).toBeTruthy(); }); @@ -147,7 +152,7 @@ describe('Calls room window', () => { fixture.session.isActive.value = true; } const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - const names = () => Array.from(view.getByRole('complementary', { name: i18n.ts.users }).querySelectorAll('strong'), element => element.textContent); + const names = () => Array.from(view.getByRole('complementary', { name: i18n.ts.users }).querySelectorAll('section strong'), element => element.textContent); expect(names()).toEqual(['alice', 'carol', 'zed', 'bob', 'eve']); source.participants.value = participants.map(participant => participant.id === 'bob' ? { ...participant, isMuted: false } : participant); await nextTick(); @@ -194,7 +199,7 @@ describe('Calls room window', () => { { id: 'listener-participant', userId: 'listener', role: 'listener', isMuted: true }, ]; const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button')!); + await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button[aria-haspopup="menu"]')!); await waitFor(() => expect(os.popupMenu).toHaveBeenCalled()); const menu = vi.mocked(os.popupMenu).mock.calls[0][0] as MenuButton[]; vi.mocked(misskeyApi).mockRejectedValueOnce(new Error('Request failed')); @@ -220,7 +225,7 @@ describe('Calls room window', () => { { id: 'listener-participant', userId: 'listener', role: 'listener', isMuted: true }, ]; const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button')!); + await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button[aria-haspopup="menu"]')!); await waitFor(() => expect(os.popupMenu).toHaveBeenCalled()); const menu = vi.mocked(os.popupMenu).mock.calls[0][0] as MenuButton[]; await menu.find(item => item.text === i18n.ts._calls.transferHost)!.action(new PointerEvent('click')); @@ -309,7 +314,7 @@ describe('Calls room window', () => { fixture.connection.participants.value = fixture.session.participants.value; vi.mocked(misskeyApi).mockResolvedValue({ roomRevision: 1, publications: ['camera', 'screen'].map(mediaSource => ({ id: mediaSource, participantId: 'speaker-participant', mediaKind: 'video', mediaSource })) } as never); const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button')!); + await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button[aria-haspopup="menu"]')!); await waitFor(() => expect(os.popupMenu).toHaveBeenCalled()); expect(misskeyApi).toHaveBeenCalledWith('calls/media/reconcile', { roomId: 'room' }); const menu = vi.mocked(os.popupMenu).mock.calls[0][0] as MenuButton[]; @@ -343,7 +348,7 @@ describe('Calls room window', () => { let finish!: (result: { canceled: boolean }) => void; fixture.confirm.mockReturnValue(new Promise(resolve => { finish = resolve; })); const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button')!); + await fireEvent.click(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button[aria-haspopup="menu"]')!); await waitFor(() => expect(os.popupMenu).toHaveBeenCalled()); const menu = vi.mocked(os.popupMenu).mock.calls[0][0] as MenuButton[]; vi.mocked(misskeyApi).mockClear(); @@ -363,7 +368,7 @@ describe('Calls room window', () => { { id: 'other-participant', userId: 'other', role: 'speaker', isMuted: false }, ]; const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - expect(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button')).toBeNull(); + expect(view.getByRole('complementary', { name: i18n.ts.users }).querySelector('button[aria-haspopup="menu"]')).toBeNull(); }); test.each([false, true])('hides video stop for absent sources with screen sharing %s', async hasScreen => { From b442114d4d14777bec5b694fc51447416c0f2f94 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Tue, 6 Oct 2026 15:23:47 +0000 Subject: [PATCH 07/11] fix(calls): address channel lifecycle review findings --- .../src/core/calls/CallsRoomService.ts | 36 ++++++--- .../test/unit/core/calls/CallsRoomService.ts | 77 +++++++++++++++++-- .../src/components/MkCallsRoomWindow.vue | 6 +- .../test/unit/calls-join-confirmation.test.ts | 6 +- 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/core/calls/CallsRoomService.ts b/packages/backend/src/core/calls/CallsRoomService.ts index 7d6321bba7f..923539d7c04 100644 --- a/packages/backend/src/core/calls/CallsRoomService.ts +++ b/packages/backend/src/core/calls/CallsRoomService.ts @@ -213,7 +213,15 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { }); return room; }); - await this.followRoomChannel(owner, room); + try { + await this.followRoomChannel(owner, room); + } catch (error) { + await this.callsRoomsRepository.manager.transaction(async manager => { + await manager.getRepository(this.callsRoomsRepository.target).delete(room.id); + if (room.channelId != null) await manager.getRepository(MiChannel).delete(room.channelId); + }); + throw error; + } this.callsTelemetryService.lifecycle({ action: 'room-created', roomId: room.id }); this.callsEventService.publishRoomsList('created', { roomId: room.id }); @@ -425,6 +433,7 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { const role = current?.role === 'host' ? 'host' : room.mode === 'open' ? 'speaker' : 'listener'; await this.assertCapacity(room, role); + await this.followRoomChannel(user, room); const now = new Date(); let joined: MiCallsParticipant; if (current != null) { @@ -452,7 +461,6 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { updatedAt: now, }); } - await this.followRoomChannel(user, room); await this.bumpRevision(roomId); return joined; } @@ -687,13 +695,16 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { if (room.state !== 'scheduled' && room.state !== 'open') throw new CallsRoomError('invalid-state'); const sanitizedTitle = this.sanitizeMetadata(title); if (sanitizedTitle.length === 0) throw new CallsRoomError('invalid-metadata'); - const result = await this.callsRoomsRepository.createQueryBuilder().update() - .set({ title: sanitizedTitle, revision: () => '"revision" + 1', updatedAt: new Date() }) - .where('id = :roomId AND revision = :expectedRevision AND state = :state', { roomId, expectedRevision, state: room.state }) - .returning('*').execute(); - if (result.affected !== 1) throw new CallsRoomError('stale-revision'); - const updated = result.raw[0] as MiCallsRoom; - if (updated.channelId != null) await this.channelsRepository.update(updated.channelId, { name: updated.title.slice(0, 128) }); + const updated = await this.callsRoomsRepository.manager.transaction(async manager => { + const result = await manager.getRepository(this.callsRoomsRepository.target).createQueryBuilder().update() + .set({ title: sanitizedTitle, revision: () => '"revision" + 1', updatedAt: new Date() }) + .where('id = :roomId AND revision = :expectedRevision AND state = :state', { roomId, expectedRevision, state: room.state }) + .returning('*').execute(); + if (result.affected !== 1) throw new CallsRoomError('stale-revision'); + const updated = result.raw[0] as MiCallsRoom; + if (updated.channelId != null) await manager.getRepository(MiChannel).update(updated.channelId, { name: updated.title.slice(0, 128) }); + return updated; + }); await this.callsEventService.publish(roomId, updated.revision, 'title', { title: updated.title }); this.callsEventService.publishRoomsList('updated', { roomId, action: 'title' }); return updated; @@ -736,6 +747,13 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { const promoted = await participants.update({ id: participant.id, state: 'active' }, { role: 'host', speakerRequestedAt: null, updatedAt: now }); if (promoted.affected !== 1) throw new CallsRoomError('participant-not-found'); const updated = result.raw[0] as MiCallsRoom; + if (room.channelId != null) { + const channels = manager.getRepository(MiChannel); + const channel = await channels.findOneByOrFail({ id: room.channelId }); + const collaboratorIds = channel.collaboratorIds.filter(id => id !== participant.userId); + if (channel.userId != null && !collaboratorIds.includes(channel.userId)) collaboratorIds.push(channel.userId); + await channels.update(channel.id, { userId: participant.userId, collaboratorIds }); + } await logs.insert({ id: this.idService.gen(), roomId, actorUserId: host.id, targetParticipantId: participant.id, action: 'promote', previousRole: participant.role, nextRole: 'host', reason: 'host-transfer', roomRevision: updated.revision, createdAt: now }); return { room: updated, formerHostParticipantId: formerHost.id, formerHostRole: 'speaker' as const }; }); diff --git a/packages/backend/test/unit/core/calls/CallsRoomService.ts b/packages/backend/test/unit/core/calls/CallsRoomService.ts index d07bbc45832..6ed5b244a19 100644 --- a/packages/backend/test/unit/core/calls/CallsRoomService.ts +++ b/packages/backend/test/unit/core/calls/CallsRoomService.ts @@ -53,6 +53,43 @@ function createConnectionFixture(role: 'host' | 'listener') { } describe('CallsRoomService lifecycle', () => { + test('removes a newly created room and channel if following fails', async () => { + const service = createAccessFixture(); + const deleteRoom = vi.fn(); + const deleteChannel = vi.fn(); + const publishRoomsList = vi.fn(); + Object.assign(service, { + callsRoomsRepository: { target: 'rooms', findOneBy: async () => null, manager: { + transaction: async (callback: (manager: unknown) => Promise) => callback({ getRepository: (target: unknown) => ({ + save: async (value: unknown) => value, + delete: target === MiChannel ? deleteChannel : deleteRoom, + }) }), + } }, + channelsRepository: { findOneByOrFail: async () => ({ id: 'created' }) }, + channelFollowingService: { followOrRequest: vi.fn().mockRejectedValue(new Error('follow failed')) }, + idService: { gen: () => 'created' }, + callsEventService: { publishRoomsList }, + }); + await expect(service.create(viewer, { attachmentType: 'personal', title: 'Call chat', visibility: 'public' })).rejects.toThrow('follow failed'); + expect(deleteRoom).toHaveBeenCalledWith('created'); + expect(deleteChannel).toHaveBeenCalledWith('created'); + expect(publishRoomsList).not.toHaveBeenCalled(); + }); + + test('does not save a joining participant when following fails', async () => { + const service = createAccessFixture(); + const insertOne = vi.fn(); + Object.assign(service, { + callsRoomsRepository: { findOneBy: async () => ({ ...baseRoom, state: 'open', mode: 'stage', visibility: 'public', channelId: 'channel-a' }) }, + callsParticipantsRepository: { findOneBy: async () => null, findBy: async () => [], insertOne }, + channelsRepository: { findOneByOrFail: async () => ({ id: 'channel-a' }) }, + channelFollowingService: { followOrRequest: vi.fn().mockRejectedValue(new Error('follow failed')) }, + callsLiveConnectionService: { withRoomLock: async (_roomId: string, callback: () => Promise) => callback() }, + }); + await expect(service.join(viewer, baseRoom.id)).rejects.toThrow('follow failed'); + expect(insertOne).not.toHaveBeenCalled(); + }); + test.each(['public', 'followers', 'specified'] as const)('creates a hidden chat channel only for public Calls (visibility: %s)', async visibility => { const service = createAccessFixture(); const saveChannel = vi.fn(async (channel: object) => channel); @@ -464,16 +501,28 @@ function createModerationFixture() { { withRoomLock: async (_roomId: string, callback: () => Promise) => callback() } as never, { publish, publishRoomsList } as never, { revokeParticipant, stopParticipantVideo } as never, { lifecycle: vi.fn() } as never, {} as never, {} as never, ); - return { service, config, isModerator, room, participant, existsBy, update, set, execute, revokeParticipant, stopParticipantVideo, insertLog, publish, publishRoomsList }; + const updateChannel = vi.fn(); + const rooms = (service as any).callsRoomsRepository; + Object.assign(rooms, { target: 'rooms', manager: { transaction: async (callback: (manager: unknown) => Promise) => callback({ getRepository: (target: unknown) => target === MiChannel ? { update: updateChannel } : rooms }) } }); + return { service, config, isModerator, room, participant, existsBy, update, set, execute, revokeParticipant, stopParticipantVideo, insertLog, publish, publishRoomsList, updateChannel }; } describe('Calls room titles', () => { + test('does not announce a rename when its transactional channel update fails', async () => { + const fixture = createModerationFixture(); + fixture.room.channelId = 'channel-a'; + fixture.execute.mockResolvedValue({ affected: 1, raw: [{ ...fixture.room, title: 'New title', revision: 2 }] }); + fixture.updateChannel.mockRejectedValue(new Error('channel update failed')); + await expect(fixture.service.updateTitle({ id: 'owner-a' } as MiUser, fixture.room.id, 'New title', 1)).rejects.toThrow('channel update failed'); + expect(fixture.publish).not.toHaveBeenCalled(); + expect(fixture.publishRoomsList).not.toHaveBeenCalled(); + }); + test.each(['scheduled', 'open'] as const)('the host can rename a %s room and notify viewers', async state => { const fixture = createModerationFixture(); fixture.room.state = state; fixture.room.channelId = 'channel-a'; - const updateChannel = vi.fn(); - Object.assign(fixture.service, { channelsRepository: { update: updateChannel } }); + const updateChannel = fixture.updateChannel; fixture.execute.mockResolvedValue({ affected: 1, raw: [{ ...fixture.room, title: 'New title', revision: 2 }] }); await expect(fixture.service.updateTitle({ id: 'owner-a' } as MiUser, fixture.room.id, ' \u0000New title ', 1)).resolves.toMatchObject({ title: 'New title', revision: 2 }); expect(updateChannel).toHaveBeenCalledWith('channel-a', { name: 'New title' }); @@ -679,7 +728,9 @@ function createHostTransferFixture(role: 'speaker' | 'listener' = 'speaker') { // The room builder's set method must continue with this transaction's builder. fixture.set.mockImplementation(() => builder); fixture.execute.mockImplementation(async () => ({ affected: 1, raw: [Object.assign(fixture.room, fixture.set.mock.calls[0][0], { revision: 2 })] })); - const transaction = vi.fn(async (callback: (manager: unknown) => Promise) => callback({ getRepository: (target: string) => target === 'rooms' ? rooms : target === 'participants' ? participants : logs })); + const channel = { id: 'channel-a', userId: 'owner-a', collaboratorIds: [] as string[] }; + const channels = { findOneByOrFail: vi.fn(async () => channel), update: vi.fn() }; + const transaction = vi.fn(async (callback: (manager: unknown) => Promise) => callback({ getRepository: (target: unknown) => target === MiChannel ? channels : target === 'rooms' ? rooms : target === 'participants' ? participants : logs })); const touchHost = vi.fn(); Object.assign(fixture.service, { callsRoomsRepository: { target: 'rooms', manager: { transaction } }, @@ -687,10 +738,26 @@ function createHostTransferFixture(role: 'speaker' | 'listener' = 'speaker') { callsModerationLogsRepository: { target: 'logs' }, callsLiveConnectionService: { withRoomLock: async (_roomId: string, callback: () => Promise) => callback(), touchHost }, }); - return { ...fixture, participants, transaction, touchHost, where }; + return { ...fixture, participants, transaction, touchHost, where, channel, channels }; } describe('Calls host transfer', () => { + test('transfers the Calls channel to the new host and retains the former owner as a collaborator', async () => { + const fixture = createHostTransferFixture(); + Object.assign(fixture.room, { channelId: fixture.channel.id }); + fixture.channel.collaboratorIds = [fixture.participant.userId, 'collaborator-a', fixture.channel.userId]; + const actor = { id: 'owner-a' } as MiUser; + await fixture.service.transferHost(actor, fixture.room.id, fixture.participant.id, 1); + expect(fixture.channels.findOneByOrFail).toHaveBeenCalledWith({ id: fixture.channel.id }); + expect(fixture.channels.update).toHaveBeenCalledExactlyOnceWith(fixture.channel.id, { + userId: fixture.participant.userId, + collaboratorIds: ['collaborator-a', 'owner-a'], + }); + fixture.participant.role = 'host'; + await fixture.service.transferHost(actor, fixture.room.id, fixture.participant.id, 1); + expect(fixture.channels.update).toHaveBeenCalledTimes(1); + }); + test('rejects a target whose Calls participation permission was revoked', async () => { const fixture = createHostTransferFixture(); Object.assign(fixture.service, { roleService: { getUserPolicies: async () => ({ ...DEFAULT_POLICIES, canJoinCalls: false }) } }); diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 9bee3697233..76a609b2dba 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ session.elapsedTime.value }} - + @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
diff --git a/packages/frontend/src/components/MkCallsParticipantVolume.vue b/packages/frontend/src/components/MkCallsParticipantVolume.vue new file mode 100644 index 00000000000..d5b945f6eb3 --- /dev/null +++ b/packages/frontend/src/components/MkCallsParticipantVolume.vue @@ -0,0 +1,26 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 11c9d2374a3..3e5cf351efd 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only

{{ room?.title ?? i18n.ts._calls.title }}

{{ session.elapsedTime.value }} - - + + @@ -35,13 +35,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ failureText }} {{ i18n.ts._calls.speakerRequestRejected }} -
+