diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c3789c3900b..0fb4c380e86 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2721,6 +2721,7 @@ _permissions: "write:calls": "Misskey Callsへの参加と音声通話を操作する" _calls: + openChat: "チャットを開く" audioSettings: "通話設定" settings: "Callsの設定" systemDefaultDevice: "システムの既定" 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 d9dde1ca0f5..923539d7c04 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,57 @@ 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; }); + 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 }); @@ -400,10 +426,14 @@ 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); + await this.followRoomChannel(user, room); const now = new Date(); let joined: MiCallsParticipant; if (current != null) { @@ -435,6 +465,12 @@ export class CallsRoomService implements OnModuleInit, OnApplicationShutdown { 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 confirmReady(user: MiUser, roomId: string, identity: CallsConnectionIdentity): Promise { await this.callsLiveConnectionService.withRoomLock(roomId, async () => { @@ -659,12 +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; + 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; @@ -707,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/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 213d3f10ba0..6ed5b244a19 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,99 @@ 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('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); + 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('hides connecting participants from snapshots and user presence while retaining them for negotiation', async () => { const service = createAccessFixture(); const room = { ...baseRoom, state: 'open', visibility: 'public' }; @@ -153,10 +243,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') { @@ -187,6 +278,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) { @@ -294,6 +386,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' }); @@ -333,6 +426,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 }; @@ -405,16 +499,33 @@ 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 }; + 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 = 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' }); 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' }); @@ -617,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 } }, @@ -625,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/MkCallsControls.vue b/packages/frontend/src/components/MkCallsControls.vue index 42e784c3e0b..59e3a75f349 100644 --- a/packages/frontend/src/components/MkCallsControls.vue +++ b/packages/frontend/src/components/MkCallsControls.vue @@ -25,6 +25,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/MkCallsRoomCard.stories.impl.ts b/packages/frontend/src/components/MkCallsRoomCard.stories.impl.ts index b3ae94c9391..f1613119cd0 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 cac1853ec9c..6733c66794a 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'; @@ -12,6 +13,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', @@ -28,6 +30,35 @@ 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(window.document.body); + (await body.findByRole('button', { name: 'チャット' })).click(); + }, +} satisfies StoryObj; + export const Ended = { ...Default, parameters: { diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 2afa10be596..f4f3d2f9c6d 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -13,6 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only

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

{{ session.elapsedTime.value }} + + @@ -33,58 +35,83 @@ SPDX-License-Identifier: AGPL-3.0-only {{ failureText }} {{ i18n.ts._calls.speakerRequestRejected }} -
+
+
+ + + +
- -
+ +
- +
+ + + +
@@ -92,7 +119,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.close }} + @@ -118,6 +150,9 @@ import MkCallsControls from '@/components/MkCallsControls.vue'; import MkCallsRoomSummary from '@/components/MkCallsRoomSummary.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkCallsParticipantVolume from '@/components/MkCallsParticipantVolume.vue'; +import MkPostForm from '@/components/MkPostForm.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'; @@ -219,6 +254,25 @@ 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 chatHasOpened = shallowRef(false); +watch(showChat, value => { if (value) chatHasOpened.value = true; }); +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()); @@ -320,20 +374,59 @@ async function changeTitle(): Promise { } } +let participantPressTimer: number | null = null; +let participantPressStart: { x: number; y: number } | null = null; +let participantPressOpened = false; + +function cancelParticipantPress(): void { + if (participantPressTimer != null) window.clearTimeout(participantPressTimer); + participantPressTimer = null; + participantPressStart = null; +} + +function startParticipantPress(event: PointerEvent): void { + cancelParticipantPress(); + participantPressOpened = false; + if (event.pointerType !== 'touch' || !(event.target instanceof HTMLElement) || event.target.closest('input, label') != null) return; + const participantId = event.target.closest('[data-participant-id]')?.dataset.participantId; + if (participantId == null) return; + participantPressStart = { x: event.clientX, y: event.clientY }; + participantPressTimer = window.setTimeout(() => { + participantPressOpened = true; + void openParticipantMenu(participants.value.find(item => item.id === participantId), new PointerEvent('contextmenu', { clientX: event.clientX, clientY: event.clientY })); + }, 500); +} + +function moveParticipantPress(event: PointerEvent): void { + if (participantPressStart != null && Math.hypot(event.clientX - participantPressStart.x, event.clientY - participantPressStart.y) > 10) cancelParticipantPress(); +} + +function suppressParticipantClick(event: MouseEvent): void { + if (!participantPressOpened) return; + participantPressOpened = false; + event.preventDefault(); + event.stopPropagation(); +} + async function openParticipantMenu(participant: (typeof participants.value)[number] | undefined, event: PointerEvent): Promise { + if (participantPressOpened && event.isTrusted && event.type === 'contextmenu') return; + cancelParticipantPress(); if (popoutWindow != null) window.focus(); - if (!canModerateParticipants.value || room.value == null || participant == null || participant.role === 'host' || participant.userId === $i?.id) return; + if (room.value == null || participant == null || participant.userId === $i?.id) return; const target = event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined; - let publications: Misskey.entities.CallsMediaReconcileResponse['publications']; - try { - ({ publications } = await misskeyApi('calls/media/reconcile', { roomId: props.roomId })); - } catch (error) { - console.error('[Calls] Participant menu failed', error); - await os.alert({ type: 'error', text: i18n.ts.somethingHappened }); - return; - } - if (disposed || !canModerateParticipants.value || room.value == null) return; - const items: MenuItem[] = [ + const items: MenuItem[] = []; + if (sessionIsCurrent.value) items.push({ type: 'component', component: MkCallsParticipantVolume, props: { userId: participant.userId, label: videoLabel(participant.id) } }); + if (canModerateParticipants.value && participant.role !== 'host') { + let publications: Misskey.entities.CallsMediaReconcileResponse['publications']; + try { + ({ publications } = await misskeyApi('calls/media/reconcile', { roomId: props.roomId })); + } catch (error) { + console.error('[Calls] Participant menu failed', error); + await os.alert({ type: 'error', text: i18n.ts.somethingHappened }); + return; + } + if (disposed || !canModerateParticipants.value || room.value == null) return; + items.push( ...(isHost.value && room.value.mode === 'stage' ? [ { text: participant.role === 'listener' ? participant.speakerRequestedAt != null ? i18n.ts.approve : i18n.ts._calls.promoteSpeaker : i18n.ts._calls.demoteListener, icon: 'ti ti-microphone', action: () => setRole(participant.id, participant.role === 'listener' ? 'speaker' : 'listener') }, ...(participant.speakerRequestedAt != null ? [{ text: i18n.ts.reject, icon: 'ti ti-x', action: () => setRole(participant.id, 'listener') }] : []), @@ -344,12 +437,11 @@ async function openParticipantMenu(participant: (typeof participants.value)[numb ...(publications.some(publication => publication.participantId === participant.id && publication.mediaSource === 'camera') ? [{ text: i18n.ts._calls.stopCamera, icon: 'ti ti-camera-off', action: () => stopParticipantVideo(participant.id, 'camera') }] : []), ...(publications.some(publication => publication.participantId === participant.id && publication.mediaSource === 'screen') ? [{ text: i18n.ts._calls.stopScreenSharing, icon: 'ti ti-screen-share-off', action: () => stopParticipantVideo(participant.id, 'screen') }] : []), { text: i18n.ts._calls.removeParticipant, icon: 'ti ti-user-x', danger: true, action: () => removeParticipant(participant.id) }, - ]; - if (event.type === 'contextmenu') { - os.contextMenu(items, event); - } else { - os.popupMenu(items, target); + ); } + if (items.length === 0) return; + if (event.type === 'contextmenu') os.contextMenu(items, event); + else os.popupMenu(items, target); } async function showScreenWindow(stream: MediaStream, participantId: string): Promise { @@ -375,14 +467,11 @@ async function cancelRoom(): Promise { if (room.value != null) { await mis async function endRoom(): Promise { if (room.value != null) { await misskeyApi('calls/rooms/end', { roomId: props.roomId, expectedRevision: room.value.revision }); await refreshRoom(); } } -async function joinRoom(startMuted = false, requestConfirmation = false): Promise { +async function joinRoom(startMuted = false): Promise { if (!canJoinCalls.value) return; if (session.currentRoomId.value != null && session.currentRoomId.value !== props.roomId) { const { canceled } = await os.confirm({ type: 'warning', text: i18n.ts._calls.switchRoomConfirm }); if (canceled) return; - } else if (requestConfirmation) { - const { canceled } = await os.confirm({ type: 'question', title: room.value?.title, text: i18n.ts._calls.joinRoomConfirm }); - if (canceled) return; } if (disposed || !canJoinCalls.value || room.value?.state !== 'open' || sessionIsCurrent.value || session.joining.value) return; @@ -510,15 +599,13 @@ watch(() => room.value?.state, state => { }); onMounted(() => { window.addEventListener('pagehide', cleanupPopout); - void refreshRoom().then(() => { - if (room.value?.state === 'open' && myParticipant.value != null && myParticipant.value.role !== 'listener') void session.prepareMicrophones(); - if (!disposed && room.value?.state === 'open' && !sessionIsCurrent.value && !session.joining.value) void joinRoom(isHost.value || room.value.mode === 'open', true); - }).catch(error => { + void refreshRoom().catch(error => { console.error('[Calls] Room loading failed', error); loadFailed.value = true; }); }); onUnmounted(() => { + cancelParticipantPress(); disposed = true; window.removeEventListener('pagehide', cleanupPopout); pageConnection.value?.dispose(); @@ -542,12 +629,25 @@ function openConnectionInfo(): void { .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 { 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); } +.mobileChatToggle { display: none; } +.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; } -.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; } @@ -569,6 +669,7 @@ function openConnectionInfo(): void { .personMove { transition: transform 0.2s ease; } .videoGrid { display: grid; gap: 12px; height: 100%; min-height: 0; } .focusedVideo { grid-column: 1 / -1; order: -1; } +.voiceTile, .person { -webkit-touch-callout: none; } .voiceTile { position: relative; display: grid; place-items: center; min-width: 0; min-height: 0; overflow: hidden; border: 2px solid transparent; border-radius: var(--MI-radius); background: var(--MI_THEME-panel); } .voiceTile.speaking { border-color: var(--MI_THEME-accent); } .stageAvatar { width: 80px; height: 80px; } @@ -580,14 +681,27 @@ function openConnectionInfo(): void { @media (max-width: 800px) { .header { padding: 12px; } .body { padding: 12px; } - .callLayout { grid-template-columns: 120px minmax(0, 1fr); gap: 8px; } + .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: 100%; } + .participantsSidebar, .chatSidebar { position: absolute; top: 0; bottom: 0; z-index: 1; } + .participantsSidebar, .participantsToggle, .desktopChatToggle { display: none; } + .mobileChatToggle { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 40px; height: 48px; padding: 0 8px; border-radius: 14px; font-size: 20px; } + .mobileChatToggle[aria-pressed="true"] { color: var(--MI_THEME-accent); } + .footer { flex-direction: row; flex-wrap: wrap; justify-content: center; } + .joinButton { width: auto; } + .chatSidebar { right: 0; width: 100%; z-index: 2; pointer-events: none; transition: none; } + .chatOpen { pointer-events: auto; } + .chatHidden { transform: translateY(100%); } + .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/src/utility/calls-session.ts b/packages/frontend/src/utility/calls-session.ts index 92bcbfae0cb..0755f54ad18 100644 --- a/packages/frontend/src/utility/calls-session.ts +++ b/packages/frontend/src/utility/calls-session.ts @@ -79,7 +79,8 @@ const reconnectCandidate = ref(null); const reconnectRoomState = ref<'checking' | 'open' | 'unavailable'>('checking'); const reconnectSecondsRemaining = ref(0); const speakerRequestResult = ref<'rejected' | null>(null); -const remoteAudio = new Map; screenPublicationId?: string }>(); +let volumeAudioContext: AudioContext | null = null; +const remoteAudio = new Map; screenPublicationId?: string; source?: MediaStreamAudioSourceNode; gain?: GainNode; output?: MediaStreamAudioDestinationNode }>(); const participantVolumes = shallowRef(new Map()); const localVideos = shallowRef(new Map()); const screenVolumes = shallowRef(new Map()); @@ -210,7 +211,11 @@ function disposeConnection(): void { } function removeRemoteTrack(publicationId: string): void { - const audio = remoteAudio.get(publicationId)?.element; + const item = remoteAudio.get(publicationId); + item?.source?.disconnect(); + item?.gain?.disconnect(); + item?.output?.stream.getTracks().forEach(track => track.stop()); + const audio = item?.element; if (audio != null) { audio.pause(); audio.srcObject = null; audio.remove(); } remoteAudio.delete(publicationId); screenAudioIds.value = new Set([...remoteAudio.values()].flatMap(item => item.screenPublicationId == null ? [] : [item.screenPublicationId])); @@ -224,9 +229,22 @@ function getParticipantVolume(userId: string): number { } function applyParticipantVolumes(): void { - for (const { participantId, element, screenPublicationId } of remoteAudio.values()) { - const participant = participants.value.find(item => item.id === participantId); - element.volume = (outputVolume.value / 100) * (screenPublicationId != null ? getScreenVolume(screenPublicationId) / 100 : participant == null ? 1 : getParticipantVolume(participant.userId) / 100); + for (const item of remoteAudio.values()) { + const participant = participants.value.find(entry => entry.id === item.participantId); + const volume = item.screenPublicationId != null ? getScreenVolume(item.screenPublicationId) : participant == null ? 100 : getParticipantVolume(participant.userId); + if (item.screenPublicationId == null && volume !== 100 && item.gain == null) { + volumeAudioContext ??= new AudioContext(); + item.source = volumeAudioContext.createMediaStreamSource(item.element.srcObject as MediaStream); + item.gain = volumeAudioContext.createGain(); + item.output = volumeAudioContext.createMediaStreamDestination(); + item.source.connect(item.gain); + item.gain.connect(item.output); + item.element.srcObject = item.output.stream; + void item.element.play().catch(() => { needsAudioResume.value = true; }); + } + const effectiveVolume = (outputVolume.value / 100) * (volume / 100); + item.element.volume = item.gain != null ? 1 : effectiveVolume; + if (item.gain != null) item.gain.gain.value = effectiveVolume; } } @@ -240,8 +258,9 @@ function setScreenVolume(publicationId: string, volume: number): void { } function setParticipantVolume(userId: string, volume: number): void { - participantVolumes.value = new Map(participantVolumes.value).set(userId, Math.max(0, Math.min(100, volume))); + participantVolumes.value = new Map(participantVolumes.value).set(userId, Math.max(0, Math.min(200, volume))); applyParticipantVolumes(); + void volumeAudioContext?.resume().catch(() => { needsAudioResume.value = true; }); } function addRemoteTrack(track: MediaStreamTrack, publication: CallsRemotePublication): void { @@ -255,13 +274,14 @@ function addRemoteTrack(track: MediaStreamTrack, publication: CallsRemotePublica const audio = new Audio(); audio.hidden = true; audio.srcObject = new MediaStream([track]); - const playback = (async () => { - if (supportsOutputDevice) await audio.setSinkId(selectedOutputDevice.value); - await audio.play(); - })().catch(error => { console.warn('[Calls] Audio playback failed', error); needsAudioResume.value = true; }); - remoteAudio.set(publication.id, { participantId: publication.participantId, element: audio, playback, screenPublicationId: publication.screenPublicationId }); + const item = { participantId: publication.participantId, element: audio, playback: Promise.resolve(), screenPublicationId: publication.screenPublicationId }; + remoteAudio.set(publication.id, item); if (publication.screenPublicationId != null) screenAudioIds.value = new Set(screenAudioIds.value).add(publication.screenPublicationId); applyParticipantVolumes(); + item.playback = (async () => { + if (supportsOutputDevice) await audio.setSinkId(selectedOutputDevice.value); + await Promise.all([audio.play(), volumeAudioContext?.resume()]); + })().catch(error => { console.warn('[Calls] Audio playback failed', error); needsAudioResume.value = true; }); window.document.body.append(audio); track.addEventListener('ended', () => { removeRemoteTrack(publication.id); @@ -371,11 +391,15 @@ async function clearSession(): Promise { videoBusy.value = false; for (const id of remoteAudio.keys()) removeRemoteTrack(id); remoteAudio.clear(); + const previousVolumeContext = volumeAudioContext; + volumeAudioContext = null; participantVolumes.value = new Map(); localVideos.value = new Map(); remoteVideos.value = new Map(); needsAudioResume.value = false; + const closingVolumeContext = previousVolumeContext?.close(); await controller?.close().catch(() => undefined); + await closingVolumeContext; } function attachConnection(roomId: string): CallsRoomConnection { @@ -808,7 +832,7 @@ function openScreenSettings(event: MouseEvent): void { } async function resumeAudio(): Promise { - await Promise.all([...remoteAudio.values()].map(({ element }) => element.play())); + await Promise.all([volumeAudioContext?.resume(), ...[...remoteAudio.values()].map(({ element }) => element.play())]); needsAudioResume.value = false; if (media.value != null) await announceMediaReady(media.value, sessionGeneration); } diff --git a/packages/frontend/test/unit/calls-join-confirmation.test.ts b/packages/frontend/test/unit/calls-join-confirmation.test.ts index 00cbf4a62f1..b6748bd4a3c 100644 --- a/packages/frontend/test/unit/calls-join-confirmation.test.ts +++ b/packages/frontend/test/unit/calls-join-confirmation.test.ts @@ -16,9 +16,11 @@ import type { MenuButton } from '@/types/menu.js'; const fixture = vi.hoisted(() => ({ close: vi.fn(), 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: { emits: ['click', 'closed', 'esc'], template: '
', methods: { close() { fixture.close(); } } } })); +vi.mock('@/components/MkPostForm.vue', () => ({ default: { props: { channel: Object, fixed: Boolean }, template: '
' } })); +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 +29,7 @@ const stubs = { MkModal: { emits: ['click', 'closed', 'esc'], template: '
', methods: { close() { fixture.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(() => { @@ -55,15 +57,55 @@ beforeEach(() => { afterEach(cleanup); describe('Calls room window', () => { + test('long-pressing the host opens a personal volume menu for a listener', async () => { + vi.useFakeTimers(); + try { + fixture.session.currentRoomId.value = 'room'; + fixture.session.isActive.value = true; + const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); + const tile = view.getByRole('region', { name: i18n.ts._calls.title }).querySelector('[data-participant-id="host-participant"]')!; + await fireEvent(tile, new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch', clientX: 30, clientY: 40 })); + expect(os.contextMenu).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + expect(os.contextMenu).toHaveBeenCalledWith([expect.objectContaining({ type: 'component', props: { userId: 'host', label: 'Host' } })], expect.any(PointerEvent)); + expect(misskeyApi).not.toHaveBeenCalledWith('calls/media/reconcile', expect.anything()); + } finally { + vi.useRealTimers(); + } + }); + + test('opens a closable chat sidebar with a fixed composer while keeping the call stage visible', 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 } } }); + expect(view.queryByTestId('calls-chat-timeline')).toBeNull(); + 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.getByTestId('calls-chat-composer').getAttribute('data-channel')).toBe(channel.id)); + 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(); + 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(); + }); + test('keeps an initially ended room open with its final timing and no join prompt', async () => { fixture.connection.room.value = null; fixture.connection.refresh.mockImplementation(async () => { - fixture.connection.room.value = { id: 'room', title: 'Ended call', state: 'ended', moderatorUserIds: [], startedAt: '2026-10-04T23:30:00.000Z', endedAt: '2026-10-05T00:32:03.000Z' }; + fixture.connection.room.value = { id: 'room', title: 'Ended call', state: 'ended', channelId: 'calls-channel', moderatorUserIds: [], startedAt: '2026-10-04T23:30:00.000Z', endedAt: '2026-10-05T00:32:03.000Z' }; }); const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); await waitFor(() => expect(view.getByText('01:02:03')).toBeTruthy()); expect(view.getByRole('heading', { name: i18n.ts._calls.ended })).toBeTruthy(); expect(view.container.querySelectorAll('time')).toHaveLength(2); + expect(view.queryByRole('button', { name: i18n.ts.chat })).toBeNull(); expect(view.queryByRole('button', { name: i18n.ts._calls.joinRoom })).toBeNull(); expect(view.queryByRole('complementary')).toBeNull(); expect(fixture.close).not.toHaveBeenCalled(); @@ -130,7 +172,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(); @@ -162,7 +204,7 @@ describe('Calls room window', () => { await waitFor(() => expect(os.contextMenu).toHaveBeenCalledOnce()); expect(vi.mocked(os.contextMenu).mock.calls[0][1]).toBe(event); const menu = vi.mocked(os.contextMenu).mock.calls[0][0] as MenuButton[]; - expect(menu.map(item => item.text)).toContain(i18n.ts._calls.removeParticipant); + expect(menu.filter(item => item.text != null).map(item => item.text)).toContain(i18n.ts._calls.removeParticipant); await menu.find(item => item.text === i18n.ts._calls.assignVcModerator)!.action(new PointerEvent('click')); expect(misskeyApi).toHaveBeenCalledWith('calls/rooms/set-moderator', { roomId: 'room', participantId: 'other-participant', isModerator: true, expectedRevision: 1 }); expect(os.popupMenu).not.toHaveBeenCalled(); @@ -177,7 +219,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')); @@ -203,7 +245,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')); @@ -292,7 +334,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[]; @@ -311,7 +353,7 @@ describe('Calls room window', () => { { type: 'warning', title: 'Speaker', text: i18n.ts._calls.stopParticipantScreenSharingConfirm }, { type: 'warning', title: 'Speaker', text: i18n.ts._calls.removeParticipantConfirm }, ]); - if (actor === 'moderator') expect(menu.map(item => item.text)).toEqual([i18n.ts._calls.mute, i18n.ts._calls.stopCamera, i18n.ts._calls.stopScreenSharing, i18n.ts._calls.removeParticipant]); + if (actor === 'moderator') expect(menu.filter(item => item.text != null).map(item => item.text)).toEqual([i18n.ts._calls.mute, i18n.ts._calls.stopCamera, i18n.ts._calls.stopScreenSharing, i18n.ts._calls.removeParticipant]); }); test.each(['mute', 'stopCamera', 'stopScreenSharing', 'removeParticipant'] as const)('waits for confirmation and cancels %s without a moderation API call', async action => { @@ -326,7 +368,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(); @@ -346,7 +388,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 => { @@ -361,7 +403,7 @@ describe('Calls room window', () => { const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); await fireEvent.click(view.getByRole('button', { name: i18n.ts.details })); await waitFor(() => expect(os.popupMenu).toHaveBeenCalled()); - expect((vi.mocked(os.popupMenu).mock.calls[0][0] as MenuButton[]).map(item => item.text)).toEqual([...(hasScreen ? [i18n.ts._calls.stopScreenSharing] : []), i18n.ts._calls.removeParticipant]); + expect((vi.mocked(os.popupMenu).mock.calls[0][0] as MenuButton[]).filter(item => item.text != null).map(item => item.text)).toEqual([...(hasScreen ? [i18n.ts._calls.stopScreenSharing] : []), i18n.ts._calls.removeParticipant]); }); test('does not prompt or offer to join when the role disallows participation', async () => { @@ -434,7 +476,6 @@ describe('Calls room window', () => { let finishCopy!: () => void; const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(() => new Promise(resolve => { finishCopy = resolve; })); const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - await waitFor(() => expect(fixture.confirm).toHaveBeenCalled()); await fireEvent.click(view.getByRole('button', { name: i18n.ts.copyLink })); expect(writeText).toHaveBeenCalledWith(`${url}/calls/room`); expect(os.toast).not.toHaveBeenCalled(); @@ -468,18 +509,16 @@ describe('Calls room window', () => { expect(view.getAllByRole('slider')).toHaveLength(1); }); - test.each([true, false])('opening another user’s room joins only after consent (cancelled: %s)', async canceled => { - let respond!: (result: { canceled: boolean }) => void; - fixture.confirm.mockImplementation(() => new Promise(resolve => { respond = resolve; })); - render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); - await waitFor(() => expect(fixture.confirm).toHaveBeenCalledWith(expect.objectContaining({ type: 'question', title: 'Another user’s room' }))); + test.each(['stage', 'open'])('opening a %s room waits for the bottom join button without prompting', async mode => { + fixture.connection.room.value.mode = mode; + const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); + await waitFor(() => expect(fixture.connection.refresh).toHaveBeenCalled()); + expect(fixture.confirm).not.toHaveBeenCalled(); expect(fixture.session.join).not.toHaveBeenCalled(); expect(fixture.session.prepareMicrophones).not.toHaveBeenCalled(); - respond({ canceled }); - if (canceled) { - await Promise.resolve(); - expect(fixture.session.join).not.toHaveBeenCalled(); - } else await waitFor(() => expect(fixture.session.join).toHaveBeenCalledWith('room', false, undefined, false)); + await fireEvent.click(view.getByRole('button', { name: i18n.ts._calls.joinRoom })); + await waitFor(() => expect(fixture.session.join).toHaveBeenCalledWith('room', false, undefined, mode === 'open')); + expect(fixture.confirm).not.toHaveBeenCalled(); }); test('reopening the current call does not ask to join again', async () => { @@ -494,7 +533,10 @@ describe('Calls room window', () => { test('does not join if the room ends while confirmation is open', async () => { let respond!: (result: { canceled: boolean }) => void; fixture.confirm.mockImplementation(() => new Promise(resolve => { respond = resolve; })); - render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); + fixture.session.currentRoomId.value = 'other-room'; + const view = render(MkCallsRoomWindow, { props: { roomId: 'room' }, global: { stubs } }); + expect(fixture.confirm).not.toHaveBeenCalled(); + await fireEvent.click(view.getByRole('button', { name: i18n.ts._calls.joinRoom })); await waitFor(() => expect(fixture.confirm).toHaveBeenCalled()); fixture.connection.room.value.state = 'ended'; respond({ canceled: false }); diff --git a/packages/frontend/test/unit/calls-session.test.ts b/packages/frontend/test/unit/calls-session.test.ts index 5f78a50bc41..907d482d69a 100644 --- a/packages/frontend/test/unit/calls-session.test.ts +++ b/packages/frontend/test/unit/calls-session.test.ts @@ -362,6 +362,16 @@ describe('Calls session device handoff', () => { }); test('adjusts only the selected user audio, retains volume for replacement tracks and clears it on leaving', async () => { + const gain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + const close = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('AudioContext', class { + destination = {}; + createMediaStreamSource = vi.fn(() => ({ connect: vi.fn(), disconnect: vi.fn() })); + createGain = vi.fn(() => gain); + createMediaStreamDestination = () => ({ stream: Object.assign(new MediaStream(), { getTracks: () => [] }) }); + resume = vi.fn().mockResolvedValue(undefined); + close = close; + }); const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(); const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); try { @@ -382,29 +392,55 @@ describe('Calls session device handoff', () => { session.setOutputVolume(100); expect(audioB.volume).toBe(1); session.setParticipantVolume('user-b', 25); - expect(audioB.volume).toBe(0.25); + expect(audioB.volume).toBe(1); + expect(gain.gain.value).toBe(0.25); + expect(audioC.volume).toBe(1); + session.setParticipantVolume('user-b', 200); + expect(gain.gain.value).toBe(2); + expect(audioB.volume).toBe(1); expect(audioC.volume).toBe(1); + session.setOutputVolume(50); + expect(gain.gain.value).toBe(1); + expect(audioC.volume).toBe(0.5); + session.setOutputVolume(100); + expect(gain.gain.value).toBe(2); + session.setParticipantVolume('user-b', 25); + expect(gain.gain.value).toBe(0.25); const replacementB = receive('publication-b', 'participant-b'); - expect(replacementB.volume).toBe(0.25); + expect(replacementB.volume).toBe(1); + expect(gain.gain.value).toBe(0.25); expect(audioB.isConnected).toBe(false); session.setParticipantVolume('user-b', 0); expect(audioC.volume).toBe(1); expect(session.getParticipantVolume('user-b')).toBe(0); - expect(replacementB.volume).toBe(0); + expect(replacementB.volume).toBe(1); + expect(gain.gain.value).toBe(0); await session.resumeAudio(); - expect(replacementB.volume).toBe(0); + expect(replacementB.volume).toBe(1); + expect(gain.gain.value).toBe(0); session.setParticipantVolume('user-b', 100); expect(replacementB.volume).toBe(1); await session.leave(); expect(document.querySelector('audio')).toBeNull(); expect(session.getParticipantVolume('user-b')).toBe(100); + expect(close).toHaveBeenCalledOnce(); } finally { play.mockRestore(); pause.mockRestore(); + vi.unstubAllGlobals(); } }); test('sets each screen audio volume independently from the microphone and cleans up on leave', async () => { + const gain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + vi.stubGlobal('AudioContext', class { + destination = {}; + createMediaStreamSource = () => ({ connect: vi.fn(), disconnect: vi.fn() }); + createGain = () => gain; + createMediaStreamDestination = () => ({ stream: Object.assign(new MediaStream(), { getTracks: () => [] }) }); + resume = vi.fn().mockResolvedValue(undefined); + close = vi.fn().mockResolvedValue(undefined); + }); const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(); const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); try { @@ -424,7 +460,8 @@ describe('Calls session device handoff', () => { session.setParticipantVolume('user-b', 50); expect(first.audio.volume).toBe(0.25); expect(second.audio.volume).toBe(0.75); - expect(microphone.audio.volume).toBe(0.5); + expect(microphone.audio.volume).toBe(1); + expect(gain.gain.value).toBe(0.5); const replacement = receive('audio-a', 'screen-a'); expect(replacement.audio.volume).toBe(0.25); replacement.track.dispatchEvent(new Event('ended')); @@ -437,6 +474,7 @@ describe('Calls session device handoff', () => { } finally { play.mockRestore(); pause.mockRestore(); + vi.unstubAllGlobals(); } }); @@ -546,6 +584,15 @@ describe('Calls session device handoff', () => { }); test('routes existing and new remote audio to the selected output device', async () => { + const outputStream = Object.assign(new MediaStream(), { getTracks: () => [] }); + const gain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + vi.stubGlobal('AudioContext', class { + createMediaStreamSource = () => ({ connect: vi.fn(), disconnect: vi.fn() }); + createGain = () => gain; + createMediaStreamDestination = () => ({ stream: outputStream }); + resume = vi.fn().mockResolvedValue(undefined); + close = vi.fn().mockResolvedValue(undefined); + }); const original = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'setSinkId'); const setSinkId = vi.fn().mockResolvedValue(undefined); Object.defineProperty(HTMLMediaElement.prototype, 'setSinkId', { configurable: true, value: setSinkId }); @@ -559,12 +606,22 @@ describe('Calls session device handoff', () => { receive('first'); await session.setDevice('output', 'headphones'); expect(setSinkId).toHaveBeenLastCalledWith('headphones'); + const audio = document.querySelector('audio')!; + session.setParticipantVolume(session.participants.value[0].userId, 200); + expect(audio.srcObject).toBe(outputStream); + expect(gain.gain.value).toBe(2); + expect(gain.connect).toHaveBeenCalledWith(expect.objectContaining({ stream: outputStream })); + expect(setSinkId.mock.contexts.at(-1)).toBe(audio); + await session.setDevice('output', 'speakers'); + expect(setSinkId).toHaveBeenLastCalledWith('speakers'); + await session.setDevice('output', 'headphones'); receive('second'); expect(setSinkId).toHaveBeenLastCalledWith('headphones'); expect(session.getAudioSettings().outputDeviceId).toBe('headphones'); await session.leave(); } finally { play.mockRestore(); + vi.unstubAllGlobals(); if (original) Object.defineProperty(HTMLMediaElement.prototype, 'setSinkId', original); else delete (HTMLMediaElement.prototype as Partial).setSinkId; } diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 9cb14ed5781..1e47301b95d 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -10376,6 +10376,10 @@ export interface Locale extends ILocale { "write:calls": string; }; "_calls": { + /** + * チャットを開く + */ + "openChat": string; /** * 通話設定 */ diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 088ad1b4dde..7670763612e 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[];