From c88cca6beddd6cbd019aaa3053c9930d62ba9840 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 1 Oct 2026 15:46:36 +1000 Subject: [PATCH 1/4] fix(control): readable errors, calendar loading and call page fixes - Show the error message (or code) in error notifications instead of `[object Object]`. Driver and API errors are plain objects. - Load the calendar list when the meeting list opens, and select the first calendar. Nothing loaded calendars, so the list was always empty. - Close custom tooltips on Escape (shared components library). - Reset the call layout and presentation selects to the codec value when a change fails. - Leave room for the Help button above the call controls. - End the assistant chat connection when voice control turns off, so the previous room's socket does not stay open on bootstrap. --- apps/control/src/app/control-state.service.ts | 5 ++-- apps/control/src/app/error-text.ts | 16 ++++++++++ .../app/tabbed-view/tab-outlet.component.ts | 1 + .../app/ui/select-meeting-modal.component.ts | 7 ++++- .../src/app/ui/source-select.component.ts | 5 +++- .../src/app/ui/voice-assistant.service.ts | 2 ++ .../video-call-dial-view.component.ts | 5 +++- .../video-call/video-call-page.component.ts | 30 ++++++++++++++----- .../video-call/video-call-state.service.ts | 19 ++++++++---- .../src/tests/control-state.service.spec.ts | 19 ++++++++++-- apps/control/src/tests/error-text.spec.ts | 15 ++++++++++ .../tabbed-view/tab-outlet.component.spec.ts | 12 ++++++++ .../tests/ui/voice-assistant.service.spec.ts | 2 ++ .../video-call-page.component.spec.ts | 12 ++++++++ .../video-call-state.service.spec.ts | 6 ++-- .../src/lib/custom-tooltip.component.ts | 8 +++++ .../tests/custom-tooltip.component.spec.ts | 22 ++++++++++++++ 17 files changed, 163 insertions(+), 23 deletions(-) create mode 100644 apps/control/src/app/error-text.ts create mode 100644 apps/control/src/tests/error-text.spec.ts diff --git a/apps/control/src/app/control-state.service.ts b/apps/control/src/app/control-state.service.ts index e50eae85e53..97b7dabc2fd 100644 --- a/apps/control/src/app/control-state.service.ts +++ b/apps/control/src/app/control-state.service.ts @@ -541,11 +541,12 @@ export class ControlStateService extends AsyncHandler { /** Open select meeting modal */ public async selectMeeting(input?: string) { - const cals = this.calendars(); - if (cals?.length) this.setCalendar(cals[0]); this._dialog.open(SelectMeetingModalComponent, { data: { input }, }); + await this._cal.loadCalendars(); + const first = this.calendars()[0]; + if (!this._calendar() && first) this.setCalendar(first); } /** Open view help modal */ diff --git a/apps/control/src/app/error-text.ts b/apps/control/src/app/error-text.ts new file mode 100644 index 00000000000..77e7f177952 --- /dev/null +++ b/apps/control/src/app/error-text.ts @@ -0,0 +1,16 @@ +import { errorMessage } from '@placeos/common'; + +/** + * Readable text for a failed request, for error notifications. + * Driver and API errors are plain objects, so they print as + * `[object Object]` when passed to a message directly. + */ +export function errorText(error: unknown): string { + const message = errorMessage(error); + if (message) return message; + const { code, status } = (error ?? {}) as { + code?: number; + status?: number; + }; + return `${code ?? status ?? 'unknown'}`; +} diff --git a/apps/control/src/app/tabbed-view/tab-outlet.component.ts b/apps/control/src/app/tabbed-view/tab-outlet.component.ts index 7e181b81277..0eb10aa1541 100644 --- a/apps/control/src/app/tabbed-view/tab-outlet.component.ts +++ b/apps/control/src/app/tabbed-view/tab-outlet.component.ts @@ -131,6 +131,7 @@ import { TVControlsComponent } from './tv-controls.component'; @if (call()) {
!!this._show_camera_pip()); private readonly _mic_mute = this._state.mic_mute; public readonly mic_mute = computed(() => !!this._mic_mute()); - public readonly video_layout = this._state.video_layout; - public readonly presentation_mode = this._state.presentation_mode; + /** Selected layout. Set on change, and reset to the codec value when the change fails. */ + public readonly video_layout = linkedSignal(() => + this._state.video_layout(), + ); + /** Selected presentation mode. Reset to the codec value when a change fails. */ + public readonly presentation_mode = linkedSignal(() => + this._state.presentation_mode(), + ); public readonly presentables = this._control.presentables; /** List of available cameras to select from */ public readonly camera_list = this._control.camera_list; @@ -274,10 +282,16 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { public readonly sentDTMF = (d) => this._state.sendDTMF(d); public readonly setPresentationSource = (i) => this._control.setRoute(i.id, this.present_output(), false); - public readonly setPresentationMode = (d: PresentationMode) => - this._state.setPresentationMode(d); - public readonly setVideoLayout = (d: VideoLayout) => - this._state.setVideoLayout(d); + public readonly setPresentationMode = async (d: PresentationMode) => { + this.presentation_mode.set(d); + if (await this._state.setPresentationMode(d)) return; + this.presentation_mode.set(this._state.presentation_mode()); + }; + public readonly setVideoLayout = async (d: VideoLayout) => { + this.video_layout.set(d); + if (await this._state.setVideoLayout(d)) return; + this.video_layout.set(this._state.video_layout()); + }; public readonly toggleCamera = async () => this._state.showCameraPIP(!this.show_camera_pip()); public readonly toggleMute = async () => @@ -287,7 +301,9 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { this.loading.set(i18n('APP.CONTROL.VC_LEAVE_LOADING')); await this._state.hangup().catch((_) => { this.loading.set(''); - notifyError(i18n('APP.CONTROL.VC_LEAVE_ERROR', { error: _ })); + notifyError( + i18n('APP.CONTROL.VC_LEAVE_ERROR', { error: errorText(_) }), + ); throw _; }); this._onCallEnded(); diff --git a/apps/control/src/app/video-call/video-call-state.service.ts b/apps/control/src/app/video-call/video-call-state.service.ts index d2e16868302..270892e775c 100644 --- a/apps/control/src/app/video-call/video-call-state.service.ts +++ b/apps/control/src/app/video-call/video-call-state.service.ts @@ -2,6 +2,7 @@ import { computed, inject, Injectable, Signal } from '@angular/core'; import { i18n, notifyError } from '@placeos/common'; import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; +import { errorText } from '../error-text'; import { systemBinding } from '../system-binding'; export type VideoLayout = 'Auto' | 'Equal' | 'Overlay' | 'Prominent' | 'Single'; @@ -108,22 +109,28 @@ export class VideoCallStateService { return this._exec('dtmf_send', [digit]); } - public toggleCallOnHold() { + public async toggleCallOnHold() { const call = this.call(); - if (!call) return; + if (!call) return false; return this._exec( call.Status === 'OnHold' ? 'call_resume' : 'call_place_on_hold', ); } - /** Run a VidConf method. Shows an error and resolves when it fails. */ + /** Run a VidConf method. Shows an error when it fails. Resolves `true` on success. */ private async _exec(method: string, args: unknown[] = []) { const id = this._control.id; - if (!id) return; + if (!id) return false; try { - return await getModule(id, 'VidConf').execute(method, args); + await getModule(id, 'VidConf').execute(method, args); + return true; } catch (error) { - notifyError(i18n('APP.CONTROL.VC_COMMAND_ERROR', { error })); + notifyError( + i18n('APP.CONTROL.VC_COMMAND_ERROR', { + error: errorText(error), + }), + ); + return false; } } diff --git a/apps/control/src/tests/control-state.service.spec.ts b/apps/control/src/tests/control-state.service.spec.ts index 3ec90a03865..1dba701b993 100644 --- a/apps/control/src/tests/control-state.service.spec.ts +++ b/apps/control/src/tests/control-state.service.spec.ts @@ -1,11 +1,12 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; +import { Calendar } from '@placeos/common'; import { CalendarService, SpacesService } from '@placeos/events'; -import { of } from 'rxjs'; vi.mock('@placeos/ts-client', { spy: true }); @@ -23,11 +24,18 @@ describe('ControlStateService', () => { /** Names of bindings that were released */ let released: string[]; const loadSpace = vi.fn(); + const calendar_list = signal([]); + const loadCalendars = vi.fn(async () => + calendar_list.set([{ id: 'cal-1' } as Calendar]), + ); const createService = createServiceFactory({ service: ControlStateService, providers: [ { provide: MatDialog, useValue: { open: vi.fn() } }, - { provide: CalendarService, useValue: { calendars: of([]) } }, + { + provide: CalendarService, + useValue: { calendar_list, loadCalendars }, + }, { provide: SpacesService, useValue: { loadSpaces: vi.fn(), loadSpace }, @@ -104,4 +112,11 @@ describe('ControlStateService', () => { expect(navigate).not.toHaveBeenCalled(); expect(localStorage.getItem(CONTROL_STORE_KEY)).toBe('sys-1'); }); + + it('should load calendars and pick the first when selecting a meeting', async () => { + calendar_list.set([]); + await spectator.service.selectMeeting(); + expect(loadCalendars).toHaveBeenCalled(); + expect(spectator.service.calendar()?.id).toBe('cal-1'); + }); }); diff --git a/apps/control/src/tests/error-text.spec.ts b/apps/control/src/tests/error-text.spec.ts new file mode 100644 index 00000000000..8dc1eded6ba --- /dev/null +++ b/apps/control/src/tests/error-text.spec.ts @@ -0,0 +1,15 @@ +import { errorText } from '../app/error-text'; + +describe('errorText', () => { + it('should use the error message when there is one', () => { + expect(errorText(new Error('busy'))).toBe('busy'); + expect(errorText({ message: 'not found' })).toBe('not found'); + expect(errorText('offline')).toBe('offline'); + }); + + it('should fall back to the error code or status', () => { + expect(errorText({ code: 500 })).toBe('500'); + expect(errorText({ status: 404 })).toBe('404'); + expect(errorText(undefined)).toBe('unknown'); + }); +}); diff --git a/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts b/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts index 07d115d9fc8..b3b4e9f3d47 100644 --- a/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts +++ b/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts @@ -264,4 +264,16 @@ describe('TabOutletComponent', () => { ); vi.useRealTimers(); }); + + it('should leave room for the help button above the call controls', () => { + const service: any = spectator.inject(ControlStateService); + const call_state: any = spectator.inject(VideoCallStateService); + service.tabs.set([ + { id: 'tab1', controls: 'vidconf-controls', help: 'vc-help' }, + ]); + call_state.call.set({ Status: 'Connected' }); + spectator.detectChanges(); + expect(spectator.query('[video-call-page]')).toHaveClass('pt-16'); + call_state.call.set(null); + }); }); diff --git a/apps/control/src/tests/ui/voice-assistant.service.spec.ts b/apps/control/src/tests/ui/voice-assistant.service.spec.ts index eb9531f84d9..0368a8590be 100644 --- a/apps/control/src/tests/ui/voice-assistant.service.spec.ts +++ b/apps/control/src/tests/ui/voice-assistant.service.spec.ts @@ -53,6 +53,7 @@ describe('VoiceAssistantService', () => { startChat: vi.fn(), sendMessage: vi.fn(), close: vi.fn(), + endChat: vi.fn(), }; spectator = createService({ providers: [{ provide: ChatService, useValue: chat }], @@ -106,6 +107,7 @@ describe('VoiceAssistantService', () => { expect(spectator.service.enabled()).toBe(false); expect(instance.abort).toHaveBeenCalled(); expect(instance.onresult).toBeNull(); + expect(chat.endChat).toHaveBeenCalled(); }); it('should report speech recognition as unavailable when the browser lacks it', () => { diff --git a/apps/control/src/tests/video-call/video-call-page.component.spec.ts b/apps/control/src/tests/video-call/video-call-page.component.spec.ts index 91dc82d800b..d53c6d89f17 100644 --- a/apps/control/src/tests/video-call/video-call-page.component.spec.ts +++ b/apps/control/src/tests/video-call/video-call-page.component.spec.ts @@ -178,4 +178,16 @@ describe('VideoCallPageComponent', () => { expect(client.getModule).toHaveBeenCalledWith('sys-1', 'System'); expect(execute_spy).toHaveBeenCalledWith('selected_camera', ['cam-1']); }); + + it('should reset the layout select when the change fails', async () => { + call_state.setVideoLayout.mockResolvedValue(false); + await spectator.component.setVideoLayout('Single'); + expect(spectator.component.video_layout()).toBe('Auto'); + }); + + it('should keep the new layout when the change succeeds', async () => { + call_state.setVideoLayout.mockResolvedValue(true); + await spectator.component.setVideoLayout('Single'); + expect(spectator.component.video_layout()).toBe('Single'); + }); }); diff --git a/apps/control/src/tests/video-call/video-call-state.service.spec.ts b/apps/control/src/tests/video-call/video-call-state.service.spec.ts index a4b72a4c9cd..afc1b87a931 100644 --- a/apps/control/src/tests/video-call/video-call-state.service.spec.ts +++ b/apps/control/src/tests/video-call/video-call-state.service.spec.ts @@ -143,8 +143,8 @@ describe('VideoCallStateService', () => { it('should resolve instead of throwing when a command fails', async () => { execute_spy.mockRejectedValue(new Error('offline')); - await expect( - spectator.service.muteMicrophone(true), - ).resolves.toBeUndefined(); + await expect(spectator.service.muteMicrophone(true)).resolves.toBe( + false, + ); }); }); diff --git a/libs/components/src/lib/custom-tooltip.component.ts b/libs/components/src/lib/custom-tooltip.component.ts index 6ac41ff7814..6cd78015bc5 100644 --- a/libs/components/src/lib/custom-tooltip.component.ts +++ b/libs/components/src/lib/custom-tooltip.component.ts @@ -20,6 +20,7 @@ import { viewChild, } from '@angular/core'; import { AsyncHandler } from '@placeos/common'; +import { filter } from 'rxjs/operators'; import { SanitizePipe } from './sanitise.pipe'; @Injectable() @@ -229,6 +230,13 @@ export class CustomTooltipComponent ]), }); this._overlay_ref.attach(portal); + this.subscription( + 'escape', + this._overlay_ref + .keydownEvents() + .pipe(filter((e) => e.key === 'Escape')) + .subscribe(() => this.close()), + ); if (this.backdrop()) { this.subscription( 'backdrop', diff --git a/libs/components/src/tests/custom-tooltip.component.spec.ts b/libs/components/src/tests/custom-tooltip.component.spec.ts index d73e9ee846d..040610c9279 100644 --- a/libs/components/src/tests/custom-tooltip.component.spec.ts +++ b/libs/components/src/tests/custom-tooltip.component.spec.ts @@ -126,6 +126,28 @@ describe('CustomTooltipComponent', () => { ); }); + it('should close when Escape is pressed', async () => { + spectator = createDirective(` +
+ Escape Template + `); + overlay_container = spectator.inject(OverlayContainer); + spectator.click(spectator.query('div')); + await settle(); + expect(overlay_container.getContainerElement().textContent).toContain( + 'Escape Template', + ); + + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await settle(); + + expect( + overlay_container.getContainerElement().textContent, + ).not.toContain('Escape Template'); + }); + it('should allow rendering HTML', async () => { spectator = createDirective( '
', From 98f34607da9a65fe1de2ff7c2d521a5db01b940d Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 1 Oct 2026 16:13:12 +1000 Subject: [PATCH 2/4] fix(control): meeting list opens once, driver error text, call help space - Load calendars untracked in `selectMeeting()`. The join effects in the panel and call views tracked the calendar list and opened the meeting list a second time when it loaded. - Show the `msg` text of realtime driver errors, not only the code. - Reserve space above the call actions only, not the whole call page. The page padding cut off the dialpad on smaller screens. --- apps/control/src/app/control-state.service.ts | 8 +++--- apps/control/src/app/error-text.ts | 8 +++--- .../app/tabbed-view/tab-outlet.component.ts | 2 +- .../video-call/video-call-page.component.ts | 4 +++ .../src/tests/control-state.service.spec.ts | 27 ++++++++++++++++--- apps/control/src/tests/error-text.spec.ts | 1 + .../tabbed-view/tab-outlet.component.spec.ts | 4 +-- .../video-call-page.component.spec.ts | 6 +++++ 8 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/control/src/app/control-state.service.ts b/apps/control/src/app/control-state.service.ts index 97b7dabc2fd..3e295a471bc 100644 --- a/apps/control/src/app/control-state.service.ts +++ b/apps/control/src/app/control-state.service.ts @@ -544,9 +544,11 @@ export class ControlStateService extends AsyncHandler { this._dialog.open(SelectMeetingModalComponent, { data: { input }, }); - await this._cal.loadCalendars(); - const first = this.calendars()[0]; - if (!this._calendar() && first) this.setCalendar(first); + // Untracked so an effect that calls this does not re-run when the + // calendars load, which would open the list again + await untracked(() => this._cal.loadCalendars()); + const first = untracked(this.calendars)[0]; + if (!untracked(this._calendar) && first) this.setCalendar(first); } /** Open view help modal */ diff --git a/apps/control/src/app/error-text.ts b/apps/control/src/app/error-text.ts index 77e7f177952..f052e272849 100644 --- a/apps/control/src/app/error-text.ts +++ b/apps/control/src/app/error-text.ts @@ -2,15 +2,17 @@ import { errorMessage } from '@placeos/common'; /** * Readable text for a failed request, for error notifications. - * Driver and API errors are plain objects, so they print as - * `[object Object]` when passed to a message directly. + * Driver and API errors are plain objects (realtime errors carry `msg`), + * so they print as `[object Object]` when passed to a message directly. */ export function errorText(error: unknown): string { const message = errorMessage(error); if (message) return message; - const { code, status } = (error ?? {}) as { + const { msg, code, status } = (error ?? {}) as { + msg?: unknown; code?: number; status?: number; }; + if (typeof msg === 'string' && msg) return msg; return `${code ?? status ?? 'unknown'}`; } diff --git a/apps/control/src/app/tabbed-view/tab-outlet.component.ts b/apps/control/src/app/tabbed-view/tab-outlet.component.ts index 0eb10aa1541..19481831426 100644 --- a/apps/control/src/app/tabbed-view/tab-outlet.component.ts +++ b/apps/control/src/app/tabbed-view/tab-outlet.component.ts @@ -131,7 +131,7 @@ import { TVControlsComponent } from './tv-controls.component'; @if (call()) {