diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a25c05ec48c..50e8a7d1aca 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2721,6 +2721,9 @@ _permissions: "write:calls": "Misskey Callsへの参加と音声通話を操作する" _calls: + activities: "アクティビティ" + backToActivities: "アクティビティ一覧に戻る" + noActivities: "利用できるアクティビティはまだありません" participationNotAllowed: "このアカウントではコールへの参加が許可されていません。" cameraSettings: "カメラの設定" screenSettings: "画面共有の設定" @@ -3944,3 +3947,33 @@ _events: tagsCaption: "カンマ区切りで入力" rateLimitReached: "本日のイベント作成上限に達しました" unknownError: "エラーが発生しました" + +_drawing: + title: "絵チャ" + start: "キャンバスを開く" + join: "絵チャに参加" + leave: "絵チャから退出" + scope: "参加できる人" + public: "公開ルームにアクセスできる人" + chatRoom: "ChatRoomのメンバー" + calls: "Callsに参加している人" + pen: "ペン" + eraser: "消しゴム" + color: "色" + width: "太さ" + clear: "全消去" + clearConfirm: "キャンバスを全消去しますか? 全員の絵が消えます。" + end: "絵チャを終了" + endConfirm: "絵チャを終了しますか? 終了後は編集できません。必要なら先に画像をドライブへ保存してください。" + ended: "この絵チャは終了しました。画像の保存・添付は引き続きできます。" + unavailable: "この絵チャにはアクセスできません。参加範囲とCallsの参加状態を確認してください。" + waiting: "ホストがキャンバスを開くと参加できます。" + save: "ドライブに保存" + attach: "画像を添付して投稿" + remove: "絵チャから退出させる" + removeConfirm: "この参加者を絵チャから退出させますか? 再参加できなくなります。" + messageLimit: "500文字まで" + cannotJoin: "絵チャに参加できません。利用権限・参加範囲・空き人数を確認してください。画像の閲覧と保存は引き続きできます。" + canvasFull: "キャンバスの容量に達しました。画像を保存してから、ホストが全消去してください。" + description: "1280×720、同時参加16人まで。音声に接続せずに描くこともできます。" + disconnected: "再接続を待っています。" diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 7bd51eda2ef..839ef71e1c0 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { DrawingService } from './drawing/DrawingService.js'; import { Module } from '@nestjs/common'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; @@ -345,6 +346,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting QueueModule, ], providers: [ + DrawingService, LoggerService, AbuseReportService, AbuseReportNotificationService, @@ -678,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#endregion ], exports: [ + DrawingService, QueueModule, LoggerService, AbuseReportService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 5490c80b497..202a03036e4 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,6 +20,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; +import type { DrawingEvent } from './drawing/DrawingService.js'; import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { MiCallsRoom } from '@/models/CallsRoom.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -339,6 +340,10 @@ export type GlobalEvents = { name: `chatRoomStream:${MiChatRoom['id']}`; payload: EventTypesToEventPayload; }; + drawing: { + name: `drawingStream:${string}`; + payload: { type: 'updated'; body: DrawingEvent }; + }; callsRoom: { name: `callsRoomStream:${MiCallsRoom['id']}`; payload: EventTypesToEventPayload; @@ -458,6 +463,10 @@ export class GlobalEventService { this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value); } + public publishDrawingStream(roomId: string, event: DrawingEvent): void { + this.publish(`drawingStream:${roomId}`, 'updated', event); + } + @bindThis public publishCallsRoomStream(roomId: MiCallsRoom['id'], type: K, value: CallsRoomEventTypes[K]): void { this.publish(`callsRoomStream:${roomId}`, type, value); diff --git a/packages/backend/src/core/drawing/DrawingService.ts b/packages/backend/src/core/drawing/DrawingService.ts new file mode 100644 index 00000000000..2c1f14bb9bf --- /dev/null +++ b/packages/backend/src/core/drawing/DrawingService.ts @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { CallsParticipantsRepository, MiUser } from '@/models/_.js'; +import { CallsRoomService } from '@/core/calls/CallsRoomService.js'; +import { CallsLiveConnectionService } from '@/core/calls/CallsLiveConnectionService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; + +export type DrawingScope = 'public' | 'chatRoom' | 'calls'; +export type DrawingStroke = { color: string; width: number; eraser: boolean; points: number[][] }; +export type DrawingMessage = { userId: string; text: string; id: string }; +export type DrawingControl = { + canvasId: string; + version: number; + scope: DrawingScope; + ended: boolean; + participantIds: string[]; + removedIds: string[]; + lastSeen: Record; +}; +export type DrawingSnapshot = Omit & { strokes: DrawingStroke[]; messages: DrawingMessage[] }; +export type DrawingEvent = Omit & { action: string; stroke: DrawingStroke | null; message: DrawingMessage | null }; +export type DrawingAction = 'start' | 'join' | 'leave' | 'stroke' | 'clear' | 'end' | 'kick' | 'message'; +export class DrawingError extends Error { + constructor(public readonly code: 'accessDenied' | 'invalidState' | 'roomFull' | 'canvasFull') { super(code); } +} + +@Injectable() +export class DrawingService { + constructor( + @Inject(DI.redis) private redis: Redis.Redis, + @Inject(DI.callsParticipantsRepository) private callsParticipants: CallsParticipantsRepository, + private callsRoomService: CallsRoomService, + private liveConnections: CallsLiveConnectionService, + private chatService: ChatService, + private events: GlobalEventService, + ) {} + + private key(roomId: string) { return `drawing:${roomId}`; } + + private async control(roomId: string): Promise { + const raw = await this.redis.get(this.key(roomId)); + return raw == null ? null : JSON.parse(raw) as DrawingControl; + } + + public async assertAccess(user: MiUser, roomId: string, control?: DrawingControl | null, write = false) { + const room = await this.callsRoomService.getRoom(roomId); + await this.callsRoomService.assertCanAccess(user, room); + const availability = await this.chatService.getChatAvailability(user.id); + if (!availability[write ? 'write' : 'read']) throw new DrawingError('accessDenied'); + const state = control === undefined ? await this.control(roomId) : control; + if (state?.removedIds.includes(user.id)) throw new DrawingError('accessDenied'); + const participant = await this.callsParticipants.findOneBy({ roomId, userId: user.id }); + if (participant?.state === 'removed') throw new DrawingError('accessDenied'); + if (state?.scope === 'calls') { + if (participant?.state !== 'active' || await this.liveConnections.get(participant.id) == null) throw new DrawingError('accessDenied'); + } + // Calls moderators may inspect a ChatRoom, but drawing is limited to its members. + if (state?.scope === 'chatRoom') { + const chatRoom = room.chatRoomId == null ? null : await this.chatService.findRoomById(room.chatRoomId); + if (chatRoom == null || !await this.chatService.isRoomMember(chatRoom, user.id)) throw new DrawingError('accessDenied'); + } + return room; + } + + public async snapshot(user: MiUser, roomId: string): Promise { + return this.liveConnections.withRoomLock(roomId, async () => { + const control = await this.control(roomId); + await this.assertAccess(user, roomId, control); + if (control == null) return null; + const { removedIds, lastSeen, ...visible } = control; + const [strokes, messages] = await Promise.all([ + this.redis.lrange(`${this.key(roomId)}:strokes`, 0, -1), + this.redis.lrange(`${this.key(roomId)}:messages`, 0, -1), + ]); + const room = await this.callsRoomService.getRoom(roomId); + return { ...visible, ended: visible.ended || room.state !== 'open', participantIds: visible.participantIds.filter(id => (control.lastSeen[id] ?? 0) > Date.now() - 90000), strokes: strokes.map(x => JSON.parse(x) as DrawingStroke), messages: messages.map(x => JSON.parse(x) as DrawingMessage) }; + }); + } + + public async update(user: MiUser, roomId: string, action: DrawingAction, params: { canvasId?: string; scope?: DrawingScope; userId?: string; stroke?: DrawingStroke; text?: string }): Promise { + await this.liveConnections.withRoomLock(roomId, async assertHeld => { + let control = await this.control(roomId); + const room = await this.assertAccess(user, roomId, control, true); + if (room.state !== 'open') throw new DrawingError('invalidState'); + const ownerAction = ['start', 'clear', 'end', 'kick'].includes(action); + if (ownerAction && room.ownerUserId !== user.id) throw new DrawingError('accessDenied'); + const transaction = this.redis.multi(); + const key = this.key(roomId); + if (action === 'start') { + if (control != null || params.scope == null) throw new DrawingError('invalidState'); + if (params.scope === 'public' && (room.attachmentType !== 'personal' || room.visibility !== 'public')) throw new DrawingError('accessDenied'); + if (params.scope === 'chatRoom' && room.chatRoomId == null) throw new DrawingError('accessDenied'); + control = { canvasId: randomUUID(), version: 0, scope: params.scope, ended: false, participantIds: [user.id], removedIds: [], lastSeen: { [user.id]: Date.now() } }; + await this.assertAccess(user, roomId, control, true); + } else { + if (control == null || control.ended || control.canvasId !== params.canvasId) throw new DrawingError('invalidState'); + control.participantIds = control.participantIds.filter(id => (control!.lastSeen[id] ?? 0) > Date.now() - 90000); + if (action === 'join') { + control.lastSeen[user.id] = Date.now(); + if (!control.participantIds.includes(user.id)) { + if (control.participantIds.length >= 16) throw new DrawingError('roomFull'); + control.participantIds.push(user.id); + } + } else if (action === 'leave') { + control.participantIds = control.participantIds.filter(id => id !== user.id); + } else if (action === 'kick') { + if (params.userId == null || params.userId === room.ownerUserId || !control.participantIds.includes(params.userId)) throw new DrawingError('invalidState'); + control.participantIds = control.participantIds.filter(id => id !== params.userId); + control.removedIds.push(params.userId); + } else if (action === 'clear') { + control.canvasId = randomUUID(); + transaction.del(`${key}:strokes`); + } else if (action === 'end') { + control.ended = true; + } else { + if (!control.participantIds.includes(user.id)) throw new DrawingError('accessDenied'); + if (action === 'stroke') { + if (params.stroke == null) throw new DrawingError('invalidState'); + if (await this.redis.llen(`${key}:strokes`) >= 10000) throw new DrawingError('canvasFull'); + transaction.rpush(`${key}:strokes`, JSON.stringify(params.stroke)); + } else if (action === 'message') { + if (params.text == null || params.text.trim().length === 0) throw new DrawingError('invalidState'); + } + } + } + const message = action === 'message' ? { userId: user.id, text: params.text!.trim(), id: randomUUID() } : null; + if (message != null) transaction.rpush(`${key}:messages`, JSON.stringify(message)).ltrim(`${key}:messages`, -50, -1); + control.version++; + await assertHeld(); + transaction.set(key, JSON.stringify(control)); + const result = await transaction.exec(); + const error = result?.find(([err]) => err != null)?.[0]; + if (error != null) throw error; + // Drawing without audio still counts as the host being present in the Calls room. + if (room.ownerUserId === user.id && (action === 'start' || action === 'join')) await this.liveConnections.touchHost(roomId); + const { removedIds, lastSeen, ...visible } = control; + this.events.publishDrawingStream(roomId, { ...visible, action, stroke: action === 'stroke' ? params.stroke! : null, message }); + }); + } +} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index dc360e2fe54..1308c663178 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -46,6 +46,7 @@ import { ServerStatsChannel } from './api/stream/channels/server-stats.js'; import { UserListChannel } from './api/stream/channels/user-list.js'; import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js'; import { ChatUserChannel } from './api/stream/channels/chat-user.js'; +import { DrawingChannel } from '@/server/api/stream/channels/drawing.js'; import { ChatRoomChannel } from './api/stream/channels/chat-room.js'; import { CallsRoomChannel } from './api/stream/channels/calls-room.js'; import { CallsRoomsChannel } from './api/stream/channels/calls-rooms.js'; @@ -93,6 +94,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j RoleTimelineChannel, ChatUserChannel, ChatRoomChannel, + DrawingChannel, CallsRoomChannel, CallsRoomsChannel, ReversiChannel, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index afc9fa38fc0..20f51af534a 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -522,3 +522,13 @@ export * as 'admin/inbox-rule/edit' from './endpoints/admin/inbox-rule/edit.js'; export * as 'admin/inbox-rule/set' from './endpoints/admin/inbox-rule/set.js'; export * as 'admin/inbox-rule/delete' from './endpoints/admin/inbox-rule/delete.js'; export * as 'admin/inbox-rule/list' from './endpoints/admin/inbox-rule/list.js'; + +export * as 'drawing/show' from './endpoints/drawing/show.js'; +export * as 'drawing/start' from './endpoints/drawing/start.js'; +export * as 'drawing/join' from './endpoints/drawing/join.js'; +export * as 'drawing/leave' from './endpoints/drawing/leave.js'; +export * as 'drawing/stroke' from './endpoints/drawing/stroke.js'; +export * as 'drawing/clear' from './endpoints/drawing/clear.js'; +export * as 'drawing/end' from './endpoints/drawing/end.js'; +export * as 'drawing/kick' from './endpoints/drawing/kick.js'; +export * as 'drawing/message' from './endpoints/drawing/message.js'; diff --git a/packages/backend/src/server/api/endpoints/drawing/_shared.ts b/packages/backend/src/server/api/endpoints/drawing/_shared.ts new file mode 100644 index 00000000000..3f50f201d66 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/_shared.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { DrawingError } from '@/core/drawing/DrawingService.js'; +import { ApiError } from '@/server/api/error.js'; +import { callsApiError, callsErrors } from '../calls/_shared.js'; + +export const drawingErrors = { + ...callsErrors, + drawingAccessDenied: { message: 'You cannot access this canvas.', code: 'DRAWING_ACCESS_DENIED', id: '3e6830dc-0b80-41fe-84d3-dc7d4fe0d725' }, + drawingInvalidState: { message: 'The canvas is not in the required state.', code: 'DRAWING_INVALID_STATE', id: '175cdb1a-5156-472b-8387-a94f64e2cc12' }, + drawingRoomFull: { message: 'The canvas has reached its participant limit.', code: 'DRAWING_ROOM_FULL', id: '5489fcf0-205d-4800-b460-4bba2929da29' }, + drawingCanvasFull: { message: 'Export or clear the canvas before continuing.', code: 'DRAWING_CANVAS_FULL', id: 'f47a90a1-766c-4c4c-a7c3-78ace3ca86ed' }, +} as const; + +export function drawingApiError(error: unknown): never { + if (error instanceof DrawingError) { + const definition = { + accessDenied: drawingErrors.drawingAccessDenied, + invalidState: drawingErrors.drawingInvalidState, + roomFull: drawingErrors.drawingRoomFull, + canvasFull: drawingErrors.drawingCanvasFull, + }[error.code]; + throw new ApiError(definition); + } + return callsApiError(error); +} + +export const strokeSchema = { + type: 'object', optional: false, nullable: false, + properties: { + color: { type: 'string', optional: false, nullable: false, pattern: '^#[0-9a-fA-F]{6}$' }, + width: { type: 'number', optional: false, nullable: false, minimum: 1, maximum: 64 }, + eraser: { type: 'boolean', optional: false, nullable: false }, + points: { type: 'array', optional: false, nullable: false, minItems: 1, maxItems: 64, items: { + type: 'array', optional: false, nullable: false, minItems: 2, maxItems: 2, + items: { type: 'number', optional: false, nullable: false, minimum: 0, maximum: 1280 }, + } }, + }, required: ['color', 'width', 'eraser', 'points'], additionalProperties: false, +} as const; + +export const messageSchema = { type: 'object', optional: false, nullable: false, properties: { + id: { type: 'string', optional: false, nullable: false }, + userId: { type: 'string', optional: false, nullable: false }, + text: { type: 'string', optional: false, nullable: false }, +} } as const; + +export const snapshotSchema = { + type: 'object', optional: false, nullable: true, + properties: { + canvasId: { type: 'string', optional: false, nullable: false }, + version: { type: 'number', optional: false, nullable: false }, + scope: { type: 'string', optional: false, nullable: false, enum: ['public', 'chatRoom', 'calls'] }, + ended: { type: 'boolean', optional: false, nullable: false }, + participantIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', optional: false, nullable: false } }, + strokes: { type: 'array', optional: false, nullable: false, items: strokeSchema }, + messages: { type: 'array', optional: false, nullable: false, items: messageSchema }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/drawing/clear.ts b/packages/backend/src/server/api/endpoints/drawing/clear.ts new file mode 100644 index 00000000000..0076a998b16 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/clear.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' } }, required: ['roomId', 'canvasId'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'clear', { canvasId: ps.canvasId }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/end.ts b/packages/backend/src/server/api/endpoints/drawing/end.ts new file mode 100644 index 00000000000..3c7b71e7d4d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/end.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' } }, required: ['roomId', 'canvasId'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'end', { canvasId: ps.canvasId }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/join.ts b/packages/backend/src/server/api/endpoints/drawing/join.ts new file mode 100644 index 00000000000..1b1b36d2542 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/join.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' } }, required: ['roomId', 'canvasId'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'join', { canvasId: ps.canvasId }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/kick.ts b/packages/backend/src/server/api/endpoints/drawing/kick.ts new file mode 100644 index 00000000000..03768682b04 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/kick.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' }, userId: { type: 'string', format: 'misskey:id' } }, required: ['roomId', 'canvasId', 'userId'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'kick', { canvasId: ps.canvasId, userId: ps.userId }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/leave.ts b/packages/backend/src/server/api/endpoints/drawing/leave.ts new file mode 100644 index 00000000000..fee5cf05ea5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/leave.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' } }, required: ['roomId', 'canvasId'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'leave', { canvasId: ps.canvasId }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/message.ts b/packages/backend/src/server/api/endpoints/drawing/message.ts new file mode 100644 index 00000000000..85fd0d50c43 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/message.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 20 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' }, text: { type: 'string', minLength: 1, maxLength: 500 } }, required: ['roomId', 'canvasId', 'text'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'message', { canvasId: ps.canvasId, text: ps.text }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/show.ts b/packages/backend/src/server/api/endpoints/drawing/show.ts new file mode 100644 index 00000000000..bcc19b0eef8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/show.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors, snapshotSchema } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, kind: 'read:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, + res: { type: 'object', optional: false, nullable: false, properties: { canvas: snapshotSchema } }, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' } }, required: ['roomId'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { return { canvas: await drawingService.snapshot(me, ps.roomId) }; } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/start.ts b/packages/backend/src/server/api/endpoints/drawing/start.ts new file mode 100644 index 00000000000..d50212c7618 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/start.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 60 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, scope: { type: 'string', enum: ['public', 'chatRoom', 'calls'] } }, required: ['roomId', 'scope'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { await drawingService.update(me, ps.roomId, 'start', { scope: ps.scope }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drawing/stroke.ts b/packages/backend/src/server/api/endpoints/drawing/stroke.ts new file mode 100644 index 00000000000..5be5e53ece2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drawing/stroke.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { DrawingService, DrawingError } from '@/core/drawing/DrawingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { drawingApiError, drawingErrors, strokeSchema } from './_shared.js'; + +export const meta = { + tags: ['calls'], requireCredential: true, prohibitMoved: true, kind: 'write:calls', + limit: { duration: 1000 * 60, max: 600 }, errors: drawingErrors, +} as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, canvasId: { type: 'string', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' }, stroke: strokeSchema }, required: ['roomId', 'canvasId', 'stroke'] } as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(drawingService: DrawingService) { + super(meta, paramDef, async (ps, me) => { + try { if (ps.stroke.points.some(point => point[1] > 720)) throw new DrawingError('invalidState'); await drawingService.update(me, ps.roomId, 'stroke', { canvasId: ps.canvasId, stroke: ps.stroke }); } catch (error) { drawingApiError(error); } + }); + } +} diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index a7a588aef77..f1e76b762fc 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -36,6 +36,7 @@ import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js'; import { AdminChannel } from '@/server/api/stream/channels/admin.js'; import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js'; +import { DrawingChannel } from '@/server/api/stream/channels/drawing.js'; import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js'; import { CallsRoomChannel } from '@/server/api/stream/channels/calls-room.js'; import { CallsRoomsChannel } from '@/server/api/stream/channels/calls-rooms.js'; @@ -367,6 +368,7 @@ export default class Connection { case 'admin': return AdminChannel; case 'chatUser': return ChatUserChannel; case 'chatRoom': return ChatRoomChannel; + case 'drawing': return DrawingChannel; case 'callsRoom': return CallsRoomChannel; case 'callsRooms': return CallsRoomsChannel; case 'reversi': return ReversiChannel; diff --git a/packages/backend/src/server/api/stream/channels/drawing.ts b/packages/backend/src/server/api/stream/channels/drawing.ts new file mode 100644 index 00000000000..4ac529ebf5c --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/drawing.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import Channel, { type ChannelRequest } from '../channel.js'; + +@Injectable({ scope: Scope.TRANSIENT }) +export class DrawingChannel extends Channel { + public readonly chName = 'drawing'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:calls'; + private roomId: string; + + constructor(@Inject(REQUEST) request: ChannelRequest, private drawingService: DrawingService) { super(request); } + + @bindThis + public async init(params: JsonObject): Promise { + if (typeof params.roomId !== 'string' || this.user == null) return false; + this.roomId = params.roomId; + try { await this.drawingService.assertAccess(this.user, this.roomId); } catch { return false; } + this.subscriber.on(`drawingStream:${this.roomId}`, this.onEvent); + return true; + } + + @bindThis + private async onEvent(data: GlobalEvents['drawing']['payload']) { + try { await this.drawingService.assertAccess(this.user!, this.roomId); } catch { + this.send('revoked', null); + this.dispose(); + return; + } + this.send(data.type, data.body); + } + + @bindThis + public dispose() { this.subscriber.off(`drawingStream:${this.roomId}`, this.onEvent); } +} diff --git a/packages/backend/test/e2e/calls.ts b/packages/backend/test/e2e/calls.ts index 278f7a871aa..8fb9fd7614b 100644 --- a/packages/backend/test/e2e/calls.ts +++ b/packages/backend/test/e2e/calls.ts @@ -6,6 +6,7 @@ process.env.NODE_ENV = 'test'; import { beforeAll, describe, expect, test } from 'vitest'; +import { loadConfig } from '@/config.js'; import { api, castAsError, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; @@ -60,4 +61,49 @@ describe('Calls', () => { expect(castAsError(response.body ?? {}).error.code).toBe('CALLS_INVALID_STATE'); }); }); + + describe('drawing/stroke', () => { + test.each([ + { color: '#112233', width: 4, eraser: false, points: [] }, + { color: '#112233', width: 65, eraser: false, points: [[10, 20]] }, + { color: 'invalid', width: 4, eraser: false, points: [[10, 20]] }, + ])('描画入力のサイズと色を検証する (%j)', async stroke => { + const response = await api('drawing/stroke', { + roomId: '9a000000000000000000000000000000', + canvasId: '11111111-1111-4111-8111-111111111111', + stroke, + }, alice); + expect(response.status).toBe(400); + expect(castAsError(response.body ?? {}).error.code).toBe('INVALID_PARAM'); + }); + }); + + describe.runIf(loadConfig().cloudflareRealtime?.enabled === true)('Drawing room lifecycle', () => { + test('公開キャンバスで参加・描画・文字チャット・退出・消去・終了を同期する', async () => { + const bob = await signup({ username: 'drawing_bob' }); + const created = await api('calls/rooms/create', { attachmentType: 'personal', title: 'Drawing', visibility: 'public' }, alice); + expect(created.status).toBe(200); + const roomId = created.body.id; + await api('calls/rooms/open', { roomId, expectedRevision: created.body.revision }, alice); + expect((await api('drawing/show', { roomId }, alice)).body.canvas).toBeNull(); + expect((await api('drawing/start', { roomId, scope: 'public' }, alice)).status).toBe(204); + const canvasId = (await api('drawing/show', { roomId }, alice)).body.canvas!.canvasId; + expect((await api('drawing/join', { roomId, canvasId }, bob)).status).toBe(204); + const stroke = { color: '#112233', width: 4, eraser: false, points: [[10, 20], [30, 40]] }; + expect((await api('drawing/stroke', { roomId, canvasId, stroke }, bob)).status).toBe(204); + expect((await api('drawing/message', { roomId, canvasId, text: 'hello' }, bob)).status).toBe(204); + expect((await api('drawing/show', { roomId }, alice)).body.canvas).toMatchObject({ strokes: [stroke], messages: [{ userId: bob.id, text: 'hello' }] }); + await api('drawing/leave', { roomId, canvasId }, bob); + expect(castAsError((await api('drawing/stroke', { roomId, canvasId, stroke }, bob)).body ?? {}).error.code).toBe('DRAWING_ACCESS_DENIED'); + await api('drawing/join', { roomId, canvasId }, bob); + await api('drawing/kick', { roomId, canvasId, userId: bob.id }, alice); + expect(castAsError((await api('drawing/show', { roomId }, bob)).body ?? {}).error.code).toBe('DRAWING_ACCESS_DENIED'); + await api('drawing/clear', { roomId, canvasId }, alice); + const cleared = (await api('drawing/show', { roomId }, alice)).body.canvas!; + expect(cleared.strokes).toEqual([]); + expect(cleared.canvasId).not.toBe(canvasId); + await api('drawing/end', { roomId, canvasId: cleared.canvasId }, alice); + expect((await api('drawing/show', { roomId }, alice)).body.canvas?.ended).toBe(true); + }); + }); }); diff --git a/packages/backend/test/unit/core/drawing/DrawingService.ts b/packages/backend/test/unit/core/drawing/DrawingService.ts new file mode 100644 index 00000000000..373fa4cdfb1 --- /dev/null +++ b/packages/backend/test/unit/core/drawing/DrawingService.ts @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { DrawingService } from '@/core/drawing/DrawingService.js'; +import type { MiUser } from '@/models/_.js'; + +class MemoryRedis { + public values = new Map(); + public lists = new Map(); + public async get(key: string) { return this.values.get(key) ?? null; } + public async llen(key: string) { return this.lists.get(key)?.length ?? 0; } + public async lrange(key: string) { return this.lists.get(key) ?? []; } + public multi() { + const commands: (() => void)[] = []; + const transaction = { + set: (key: string, value: string) => { commands.push(() => { this.values.set(key, value); }); return transaction; }, + del: (key: string) => { commands.push(() => { this.lists.delete(key); }); return transaction; }, + rpush: (key: string, value: string) => { commands.push(() => { this.lists.set(key, [...(this.lists.get(key) ?? []), value]); }); return transaction; }, + ltrim: (key: string, start: number) => { commands.push(() => { this.lists.set(key, (this.lists.get(key) ?? []).slice(start)); }); return transaction; }, + exec: async () => { commands.forEach(command => command()); return [[null, 'OK']]; }, + }; + return transaction; + } +} + +const owner = { id: 'owner', host: null } as MiUser; +const member = { id: 'member', host: null } as MiUser; +const outsider = { id: 'outsider', host: null } as MiUser; +const stroke = { color: '#123456', width: 4, eraser: false, points: [[20, 30], [40, 50]] }; +function fixture() { + const redis = new MemoryRedis(); + const room = { id: 'room', ownerUserId: owner.id, attachmentType: 'personal', visibility: 'public', state: 'open', chatRoomId: null as string | null }; + const participants = new Map(); + const live = new Set(); + const memberships = new Set([owner.id, member.id]); + const events = vi.fn(); + const touchHost = vi.fn(); + const service = new DrawingService(redis as never, { findOneBy: async ({ userId }: { userId: string }) => participants.get(userId) ?? null } as never, + { getRoom: async () => room, assertCanAccess: async () => {} } as never, + { withRoomLock: async (_id: string, callback: (assertHeld: () => Promise) => Promise) => callback(async () => {}), get: async (id: string) => live.has(id) ? {} : null, touchHost } as never, + { getChatAvailability: async () => ({ read: true, write: true }), findRoomById: async () => ({ id: 'chat' }), isRoomMember: async (_room: unknown, id: string) => memberships.has(id) } as never, + { publishDrawingStream: events } as never); + return { service, redis, room, participants, live, memberships, events, touchHost }; +} + +async function start(f: ReturnType, scope: 'public' | 'chatRoom' | 'calls' = 'public') { + await f.service.update(owner, 'room', 'start', { scope }); + return (await f.service.snapshot(owner, 'room'))!; +} + +describe('DrawingService', () => { + test('a drawing host without audio refreshes the Calls deadline, while other members cannot keep an absent host present', async () => { + const f = fixture(); + const { canvasId } = await start(f); + expect(f.touchHost).toHaveBeenCalledWith('room'); + f.touchHost.mockClear(); + await f.service.update(member, 'room', 'join', { canvasId }); + expect(f.touchHost).not.toHaveBeenCalled(); + await f.service.update(owner, 'room', 'join', { canvasId }); + expect(f.touchHost).toHaveBeenCalledOnce(); + }); + + test('two members share strokes and late/reloaded clients receive the current canvas', async () => { + const f = fixture(); + const state = await start(f); + await f.service.update(member, 'room', 'join', { canvasId: state.canvasId }); + await f.service.update(owner, 'room', 'stroke', { canvasId: state.canvasId, stroke }); + await f.service.update(member, 'room', 'stroke', { canvasId: state.canvasId, stroke: { ...stroke, eraser: true } }); + expect(await f.service.snapshot(outsider, 'room')).toEqual(await f.service.snapshot(member, 'room')); + expect((await f.service.snapshot(member, 'room'))?.strokes).toEqual([stroke, { ...stroke, eraser: true }]); + expect(f.events.mock.calls.at(-1)?.[1]).toMatchObject({ version: 4, action: 'stroke' }); + }); + + test('only the owner can clear, end or remove participants; removed users lose read and write access', async () => { + const f = fixture(); + const { canvasId } = await start(f); + await f.service.update(member, 'room', 'join', { canvasId }); + for (const action of ['clear', 'end', 'kick'] as const) await expect(f.service.update(member, 'room', action, { canvasId, userId: owner.id })).rejects.toMatchObject({ code: 'accessDenied' }); + await f.service.update(owner, 'room', 'kick', { canvasId, userId: member.id }); + await expect(f.service.snapshot(member, 'room')).rejects.toMatchObject({ code: 'accessDenied' }); + await expect(f.service.update(member, 'room', 'stroke', { canvasId, stroke })).rejects.toMatchObject({ code: 'accessDenied' }); + await expect(f.service.update(member, 'room', 'join', { canvasId })).rejects.toMatchObject({ code: 'accessDenied' }); + }); + + test('clear invalidates queued strokes and end preserves the exportable canvas without reopening editing', async () => { + const f = fixture(); + const { canvasId } = await start(f); + await f.service.update(owner, 'room', 'stroke', { canvasId, stroke }); + await f.service.update(owner, 'room', 'clear', { canvasId }); + const cleared = (await f.service.snapshot(owner, 'room'))!; + expect(cleared.strokes).toEqual([]); + await expect(f.service.update(owner, 'room', 'stroke', { canvasId, stroke })).rejects.toMatchObject({ code: 'invalidState' }); + await f.service.update(owner, 'room', 'stroke', { canvasId: cleared.canvasId, stroke }); + await f.service.update(owner, 'room', 'end', { canvasId: cleared.canvasId }); + expect(await f.service.snapshot(owner, 'room')).toMatchObject({ ended: true, strokes: [stroke] }); + await expect(f.service.update(owner, 'room', 'stroke', { canvasId: cleared.canvasId, stroke })).rejects.toMatchObject({ code: 'invalidState' }); + await expect(f.service.update(owner, 'room', 'start', { scope: 'public' })).rejects.toMatchObject({ code: 'invalidState' }); + }); + + test('ChatRoom mode checks actual membership for reads and every write', async () => { + const f = fixture(); + f.room.attachmentType = 'chatRoom'; f.room.chatRoomId = 'chat'; + const { canvasId } = await start(f, 'chatRoom'); + await expect(f.service.snapshot(outsider, 'room')).rejects.toMatchObject({ code: 'accessDenied' }); + await f.service.update(member, 'room', 'join', { canvasId }); + f.memberships.delete(member.id); + await expect(f.service.update(member, 'room', 'stroke', { canvasId, stroke })).rejects.toMatchObject({ code: 'accessDenied' }); + }); + + test('Calls mode requires a live participant, including the host, and rejects departed or removed participants', async () => { + const f = fixture(); + await expect(start(f, 'calls')).rejects.toMatchObject({ code: 'accessDenied' }); + f.participants.set(owner.id, { id: 'host', state: 'active' }); f.live.add('host'); + const { canvasId } = await start(f, 'calls'); + await expect(f.service.snapshot(member, 'room')).rejects.toMatchObject({ code: 'accessDenied' }); + f.participants.set(member.id, { id: 'guest', state: 'active' }); f.live.add('guest'); + await f.service.update(member, 'room', 'join', { canvasId }); + f.live.delete('guest'); + await expect(f.service.update(member, 'room', 'stroke', { canvasId, stroke })).rejects.toMatchObject({ code: 'accessDenied' }); + f.live.add('guest'); f.participants.set(member.id, { id: 'guest', state: 'removed' }); + await expect(f.service.snapshot(member, 'room')).rejects.toMatchObject({ code: 'accessDenied' }); + }); + + test('limits participant slots and releases expired slots while retaining the canvas', async () => { + const f = fixture(); + const { canvasId } = await start(f); + for (let index = 0; index < 15; index++) await f.service.update({ id: `guest${index}` } as MiUser, 'room', 'join', { canvasId }); + await expect(f.service.update(member, 'room', 'join', { canvasId })).rejects.toMatchObject({ code: 'roomFull' }); + const key = 'drawing:room'; + const control = JSON.parse(f.redis.values.get(key)!); + control.lastSeen.guest0 = Date.now() - 100000; + f.redis.values.set(key, JSON.stringify(control)); + await f.service.update(member, 'room', 'join', { canvasId }); + expect((await f.service.snapshot(owner, 'room'))?.participantIds).toHaveLength(16); + }); +}); diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 4274839e016..00352656bb4 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -464,6 +464,7 @@ function toStories(component: string): Promise { globSync('src/components/MkTagItem.vue'), globSync('src/components/MkRoleSelectDialog.vue'), globSync('src/components/grid/MkGrid.vue'), + globSync('src/components/calls/MkCallsActivities.vue'), globSync('src/pages/admin/custom-emojis-manager2.vue'), globSync('src/pages/admin/overview.ap-requests.vue'), globSync('src/pages/user/home.vue'), diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index b4fe50bdda9..edf8726dbc3 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 }} + @@ -20,22 +21,20 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.notFound }}