diff --git a/.changeset/upset-rats-work.md b/.changeset/upset-rats-work.md new file mode 100644 index 0000000000000..d84f7439c76be --- /dev/null +++ b/.changeset/upset-rats-work.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +"@rocket.chat/ui-voip": minor +--- + +Introduces popout functionality for voice calls diff --git a/apps/meteor/client/providers/MediaCallProvider.tsx b/apps/meteor/client/providers/MediaCallProvider.tsx index e741e76d51025..a9a89d0f066af 100644 --- a/apps/meteor/client/providers/MediaCallProvider.tsx +++ b/apps/meteor/client/providers/MediaCallProvider.tsx @@ -14,8 +14,9 @@ const MediaCallProvider = ({ children }: { children: ReactNode }) => { const unauthorizedContextValue = useMemo( () => ({ - inRoomView: false, - setInRoomView: () => undefined, + currentViews: [], + registerView: () => undefined, + unregisterView: () => undefined, instance: undefined, signalEmitter: new Emitter(), audioElement: undefined, diff --git a/apps/meteor/packages/meteor-inject-initial/lib/inject-core.js b/apps/meteor/packages/meteor-inject-initial/lib/inject-core.js index 6f3a23a66c962..06e67ee0814f2 100644 --- a/apps/meteor/packages/meteor-inject-initial/lib/inject-core.js +++ b/apps/meteor/packages/meteor-inject-initial/lib/inject-core.js @@ -45,6 +45,8 @@ Inject.appUrl = function (url) { // Avoid serving app HTML for declared routes such as /sockjs/. if (typeof RoutePolicy !== 'undefined' && RoutePolicy.classify(url)) return false; + if (url === '/voice-call-popup.html') return false; + // we currently return app HTML on all URLs by default return true; }; diff --git a/apps/meteor/public/voice-call-popup.html b/apps/meteor/public/voice-call-popup.html new file mode 120000 index 0000000000000..b800ef444c8d1 --- /dev/null +++ b/apps/meteor/public/voice-call-popup.html @@ -0,0 +1 @@ +../node_modules/@rocket.chat/ui-voip/dist/voice-call-popup.html \ No newline at end of file diff --git a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts index c02b4db8945e0..1570c0c972e58 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts @@ -2,101 +2,385 @@ import type { Locator, Page } from '@playwright/test'; import { expect } from '../../utils/test'; -export class VoiceCalls { +const timerToSeconds = (time: string): number => { + const [seconds, minutes, hours = '00'] = time.split(':').reverse(); + if (!seconds || !minutes) { + throw new Error('Voice call timer has an invalid time format'); + } + return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); +}; + +export class VoiceCallControls { + private readonly _controls: Locator; + + constructor(controls: Locator) { + this._controls = controls; + } + + get call(): Locator { + return this._controls.getByRole('button', { name: 'Call', exact: true }); + } + + get accept(): Locator { + return this._controls.getByRole('button', { name: 'Accept', exact: true }); + } + + get hangup(): Locator { + return this._controls.getByRole('button', { name: /End call|Reject/, exact: true }); + } + + get cancel(): Locator { + return this._controls.getByRole('button', { name: 'Cancel', exact: true }); + } + + get dialpad(): Locator { + return this._controls.getByRole('button', { name: /Dialpad/i }); + } + + get mute(): Locator { + return this._controls.getByRole('button', { name: /Mute/i }); + } + + get hold(): Locator { + return this._controls.getByRole('button', { name: /Hold|Resume/i }); + } + + get transfer(): Locator { + return this._controls.getByRole('button', { name: 'Forward', exact: true }); + } + + get shareScreen(): Locator { + return this._controls.getByRole('button', { name: /Share screen|Stop sharing screen/i }); + } + + get toggleChat(): Locator { + return this._controls.getByRole('button', { name: /Hide chat|Show chat/i }); + } + + get directMessage(): Locator { + return this._controls.getByRole('button', { name: /Direct message/i }); + } + + get popout(): Locator { + return this._controls.getByRole('button', { name: /Open in new window|Return to main window/i }); + } +} + +export class TransferModal { + private readonly root: Locator; + private readonly page: Page; - constructor(page: Page) { + constructor(page: Page, root: Locator) { this.page = page; + this.root = root; + } + + get content(): Locator { + return this.root; + } + + get input(): Locator { + return this.root.getByRole('textbox'); + } + + get hangUpAndTransfer(): Locator { + return this.root.getByRole('button', { name: 'Hang up and transfer call', exact: true }); + } + + async transferCall(username: string): Promise { + // await this.input.click(); + await this.input.fill(username); + // Options have to be gotten from the page since the Options are rendered on a portal + await this.page.getByRole('option', { name: username }).getByRole('figure').click(); + await expect(this.content).toContainText(username); + await this.hangUpAndTransfer.click(); + } +} + +export class Widget { + private readonly root: Locator; + + private readonly callControls: VoiceCallControls; + + private readonly headerControls: VoiceCallControls; + + private readonly transferModal: TransferModal; + + constructor(page: Page) { + this.transferModal = new TransferModal(page, page.getByRole('dialog', { name: 'Transfer call' })); + this.root = page.getByRole('dialog', { name: 'Voice call', exact: false }); + this.callControls = new VoiceCallControls(this.root.getByRole('group')); + this.headerControls = new VoiceCallControls(this.root.getByRole('banner')); + } + + get content(): Locator { + return this.root; + } + + get controls(): VoiceCallControls { + return this.callControls; + } + + get timer(): Locator { + return this.root.getByRole('time'); + } + + get btnShowCallHere(): Locator { + return this.root.getByRole('button', { name: 'Show call here' }); + } + + async showCallHere(): Promise { + await this.btnShowCallHere.click(); + await expect(this.btnShowCallHere).not.toBeVisible(); + } + + async getTimerContentInSeconds() { + const text = await this.timer.innerText(); + return timerToSeconds(text); } async initiateCall(): Promise { - await expect(this.callWidget).toBeVisible(); - await this.btnCall.click(); - await expect(this.btnCancel).toBeVisible(); + await this.callControls.call.click(); + await expect(this.callControls.cancel).toBeVisible(); } async acceptCall(): Promise { - await expect(this.btnAcceptCall).toBeVisible(); - await this.btnAcceptCall.click(); - await expect(this.btnOpenDialpad).toBeVisible(); + await this.callControls.accept.click(); + await expect(this.callControls.hangup).toBeVisible(); } - async rejectCall(): Promise { - await expect(this.btnRejectCall).toBeVisible(); - await this.btnRejectCall.click(); + async endCall(): Promise { + await this.callControls.hangup.click(); + await expect(this.content).not.toBeVisible(); } - get callWidget(): Locator { - return this.page.getByRole('article', { name: 'New call' }); + async goToDm(): Promise { + await this.headerControls.directMessage.click(); + await expect(this.content).not.toBeVisible(); } - get btnCall(): Locator { - return this.page.getByRole('button', { name: 'Call', exact: true }); + async transferCall(username: string): Promise { + await this.callControls.transfer.click(); + await expect(this.transferModal.content).toBeVisible(); + await this.transferModal.transferCall(username); + await expect(this.transferModal.content).not.toBeVisible(); + await expect(this.content).not.toBeVisible(); } - get callControlGroup(): Locator { - return this.page.getByRole('article').getByRole('group'); + async muteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Unmute'); } - btnEndCall(name: string): Locator { - return this.callControlGroup.getByRole('button', { name: `End call with ${name}` }); + async unmuteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Mute'); } - get btnCancel(): Locator { - return this.page.getByRole('button', { name: 'Cancel', exact: true }); + async holdSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Resume'); } - get btnAcceptCall(): Locator { - return this.page.getByRole('button', { name: 'Accept', exact: true }); + async resumeSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Hold'); } - get btnRejectCall(): Locator { - return this.page.getByRole('button', { name: 'Reject', exact: true }); + async openDialpad(): Promise { + await this.callControls.dialpad.click(); + await expect(this.callControls.dialpad).toHaveAttribute('title', 'Close dialpad'); } - get btnOpenDialpad(): Locator { - return this.page.getByRole('button', { name: /Dialpad/i }); + async closeDialpad(): Promise { + await this.callControls.dialpad.click(); + await expect(this.callControls.dialpad).toHaveAttribute('title', 'Open dialpad'); } - get btnMute(): Locator { - return this.page.getByRole('button', { name: /Mute/i }); + get allScreenShareVideos(): Locator { + return this.root.locator('video'); } - get btnHold(): Locator { - return this.page.getByRole('button', { name: /Hold|Resume/i }); + async shareScreen(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Stop sharing screen'); } - get btnTransfer(): Locator { - return this.page.getByRole('button', { name: 'Forward', exact: true }); + async stopSharing(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Share screen'); } - get transferModal(): Locator { - return this.page.getByRole('dialog', { name: 'Transfer call' }); + async openPopout(): Promise { + await this.headerControls.popout.click(); } - get inputUsername(): Locator { - return this.transferModal.getByRole('textbox', { name: 'Enter username or number' }); + peerCard(username: string): Locator { + return this.root.getByText(username); } +} + +export class RoomSection { + private readonly root: Locator; + + public readonly callControls: VoiceCallControls; - get btnHangupAndTransfer(): Locator { - return this.transferModal.getByRole('button', { name: 'Hang up and transfer call', exact: true }); + public readonly otherControls: VoiceCallControls; + + constructor(root: Locator) { + this.root = root; + // Right now there are 2 control groups in the room view, with no discrimination between them + this.callControls = new VoiceCallControls(this.root.getByRole('group').first()); + this.otherControls = new VoiceCallControls(this.root.getByRole('group').last()); } - get callTransferWidget(): Locator { - return this.page.getByRole('heading', { name: 'Transferring call...' }); + get content(): Locator { + return this.root; } - get incommingCallTransferWidget(): Locator { - return this.page.getByRole('heading', { name: 'Incoming call transfer...' }); + get controls(): VoiceCallControls { + return this.callControls; } - async transferCall(username: string): Promise { - await this.btnTransfer.click(); - await expect(this.transferModal).toBeVisible(); - await this.inputUsername.click(); - await this.inputUsername.fill(username); - await this.page.getByRole('option', { name: username }).getByRole('figure').click(); - await expect(this.transferModal).toContainText(username); - await this.btnHangupAndTransfer.click(); + get timer(): Locator { + return this.root.getByRole('time'); + } + + get allScreenShareVideos(): Locator { + return this.root.locator('video'); + } + + get btnShowCallHere(): Locator { + return this.root.getByRole('button', { name: 'Show call here' }); + } + + async showCallHere(): Promise { + await this.btnShowCallHere.click(); + await expect(this.btnShowCallHere).not.toBeVisible(); + } + + async getTimerContentInSeconds() { + const text = await this.timer.innerText(); + return timerToSeconds(text); + } + + async endCall(): Promise { + await this.callControls.hangup.click(); + await expect(this.content).not.toBeVisible(); + } + + async hideChat(): Promise { + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i); + await this.otherControls.toggleChat.click(); + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Show chat/i); + } + + async showChat(): Promise { + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Show chat/i); + await this.otherControls.toggleChat.click(); + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i); + } + + async muteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Unmute'); + } + + async unmuteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Mute'); + } + + async holdSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Resume'); + } + + async resumeSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Hold'); + } + + async shareScreen(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Stop sharing screen'); + } + + async stopSharing(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Share screen'); + } + + peerCard(username: string): Locator { + return this.root.getByText(username); + } + + async openPopout(): Promise { + await this.otherControls.popout.click(); + } +} + +export class PopoutPage extends RoomSection { + private readonly page: Page; + + constructor(page: Page) { + super(page.getByRole('main', { name: 'Voice call' })); + this.page = page; + } + + override async endCall(): Promise { + const pageClosed = new Promise((resolve) => this.page.on('close', () => resolve(true))); + await this.callControls.hangup.click(); + await expect(pageClosed).resolves.toBe(true); + } +} + +export class VoiceCalls { + public readonly widget: Widget; + + public readonly roomSection: RoomSection; + + public popoutPage: PopoutPage | undefined; + + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + this.widget = new Widget(page); + this.roomSection = new RoomSection(page.getByRole('region', { name: 'Voice call' })); + } + + get popout(): PopoutPage { + if (!this.popoutPage) { + throw new Error('Voice call - Popout is not open (call openPopout(Room|Widget) first)'); + } + return this.popoutPage; + } + + // Open the popout from the room section + async openPopoutRoom(): Promise { + const [popout] = await Promise.all([this.page.waitForEvent('popup'), this.roomSection.openPopout()]); + if (!popout) { + throw new Error('Voice call - Failed to open popout'); + } + await popout.waitForLoadState(); + popout.on('close', () => { + this.popoutPage = undefined; + }); + this.popoutPage = new PopoutPage(popout); + } + + // Open the popout from the widget + async openPopoutWidget(): Promise { + const [popout] = await Promise.all([this.page.waitForEvent('popup'), this.widget.openPopout()]); + if (!popout) { + throw new Error('Voice call - Failed to open popout'); + } + await popout.waitForLoadState(); + popout.on('close', () => { + this.popoutPage = undefined; + }); + this.popoutPage = new PopoutPage(popout); } } diff --git a/apps/meteor/tests/e2e/voice-calls-ee.spec.ts b/apps/meteor/tests/e2e/voice-calls-ee.spec.ts index e699e8bee96d0..ad93f25a37d42 100644 --- a/apps/meteor/tests/e2e/voice-calls-ee.spec.ts +++ b/apps/meteor/tests/e2e/voice-calls-ee.spec.ts @@ -28,6 +28,7 @@ test.describe('Internal Voice Calls - Enterprise Edition', () => { test.afterAll(async ({ api }) => { await setSettingValueById(api, 'VoIP_TeamCollab_Screen_Sharing_Enabled', true); + await Promise.all([...sessions.map(({ page }) => page.close())]); }); test('should initiate voice call from direct message', async () => { @@ -40,18 +41,18 @@ test.describe('Internal Voice Calls - Enterprise Edition', () => { await test.step('initiate a voice call from room toolbar', async () => { await user1.poHomeChannel.content.btnVoiceCall.click(); - await expect(user1.poHomeChannel.voiceCalls.callWidget).toBeVisible(); - await user1.poHomeChannel.voiceCalls.initiateCall(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); }); await test.step('user2 accepts the call', async () => { - await user2.poHomeChannel.voiceCalls.acceptCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); }); await test.step('user2 ends the call', async () => { - await user2.poHomeChannel.voiceCalls.btnEndCall('user1').click(); - await expect(user2.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); - await expect(user1.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); + await user2.poHomeChannel.voiceCalls.widget.endCall(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); }); }); @@ -61,44 +62,37 @@ test.describe('Internal Voice Calls - Enterprise Edition', () => { await user1.poHomeChannel.navbar.openChat('user2'); await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); await user1.poHomeChannel.content.btnVoiceCall.click(); - await user1.poHomeChannel.voiceCalls.initiateCall(); - await user2.poHomeChannel.voiceCalls.acceptCall(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); }); await test.step('should mute/unmute microphone from user1', async () => { // User1 mutes microphone - await user1.poHomeChannel.voiceCalls.btnMute.click(); - await expect(user1.poHomeChannel.voiceCalls.btnMute).toHaveAttribute('title', 'Unmute'); + await user1.poHomeChannel.voiceCalls.widget.muteSelf(); // User1 unmutes microphone - await user1.poHomeChannel.voiceCalls.btnMute.click(); - await expect(user1.poHomeChannel.voiceCalls.btnMute).toHaveAttribute('title', 'Mute'); + await user1.poHomeChannel.voiceCalls.widget.unmuteSelf(); }); await test.step('should put call on hold from user1', async () => { // User1 puts call on hold - await user1.poHomeChannel.voiceCalls.btnHold.click(); - await expect(user1.poHomeChannel.voiceCalls.btnHold).toHaveAttribute('title', 'Resume'); + await user1.poHomeChannel.voiceCalls.widget.holdSelf(); // User1 resumes call - await user1.poHomeChannel.voiceCalls.btnHold.click(); - await expect(user1.poHomeChannel.voiceCalls.btnHold).toHaveAttribute('title', 'Hold'); + await user1.poHomeChannel.voiceCalls.widget.resumeSelf(); }); await test.step('should access dialpad during call', async () => { // User1 opens dial pad - await user1.poHomeChannel.voiceCalls.btnOpenDialpad.click(); - await expect(user1.poHomeChannel.voiceCalls.btnOpenDialpad).toHaveAttribute('title', 'Close dialpad'); + await user1.poHomeChannel.voiceCalls.widget.openDialpad(); // User1 closes dial pad - await user1.poHomeChannel.voiceCalls.btnOpenDialpad.click(); - await expect(user1.poHomeChannel.voiceCalls.btnOpenDialpad).toHaveAttribute('title', 'Open dialpad'); + await user1.poHomeChannel.voiceCalls.widget.closeDialpad(); }); await test.step('should end the call from user1', async () => { - await user1.poHomeChannel.voiceCalls.btnEndCall('user2').click(); - await expect(user1.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); - await expect(user2.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); + await user1.poHomeChannel.voiceCalls.widget.endCall(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); }); }); @@ -115,27 +109,29 @@ test.describe('Internal Voice Calls - Enterprise Edition', () => { await user1.poHomeChannel.navbar.openChat('user2'); await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); await user1.poHomeChannel.content.btnVoiceCall.click(); - await user1.poHomeChannel.voiceCalls.initiateCall(); - await user2.poHomeChannel.voiceCalls.acceptCall(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); }); await test.step('user1 transfers call to user3', async () => { - await user1.poHomeChannel.voiceCalls.transferCall('user3'); + await user1.poHomeChannel.voiceCalls.widget.transferCall('user3'); await user1.poHomeChannel.toastMessage.waitForDisplay({ type: 'success' }); - await expect(user1.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); - await expect(user2.poHomeChannel.voiceCalls.callTransferWidget).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).toHaveAccessibleName(/Transferring call.../gi); }); await test.step('user3 receives transferred call', async () => { - await expect(user3.poHomeChannel.voiceCalls.incommingCallTransferWidget).toBeVisible(); - await user3.poHomeChannel.voiceCalls.acceptCall(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).toHaveAccessibleName(/Incoming call transfer.../gi); + await user3.poHomeChannel.voiceCalls.widget.acceptCall(); }); await test.step('user3 ends the call', async () => { - await user3.poHomeChannel.voiceCalls.btnEndCall('user2').click(); - await expect(user3.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); - await expect(user2.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); + await user3.poHomeChannel.voiceCalls.widget.endCall(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); }); + await user3.page.close(); }); @@ -146,16 +142,310 @@ test.describe('Internal Voice Calls - Enterprise Edition', () => { await user1.poHomeChannel.navbar.openChat('user2'); await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); await user1.poHomeChannel.content.btnVoiceCall.click(); - await user1.poHomeChannel.voiceCalls.initiateCall(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); }); await test.step('user2 declines the call', async () => { - await user2.poHomeChannel.voiceCalls.btnRejectCall.click(); + await user2.poHomeChannel.voiceCalls.widget.endCall(); }); await test.step('Verify call widget disappears', async () => { - await expect(user1.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); - await expect(user2.poHomeChannel.voiceCalls.callWidget).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); +}); + +test.describe('Internal Voice Calls - In-room view - Enterprise Edition', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + test.afterAll(async () => { + await Promise.all([...sessions.map(({ page }) => page.close())]); + }); + + test('should show in-room view when navigating to peer DM during call', async () => { + const [user1, user2] = sessions; + + await test.step('establish call from user1 DM with user2', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('widget should be hidden and room view should be visible', async () => { + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + }); + + await test.step('user cards should have correct names', async () => { + await expect(user1.poHomeChannel.voiceCalls.roomSection.peerCard('user1')).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.peerCard('user2')).toBeVisible(); + }); + + await test.step('mute/unmute and hold/resume', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.muteSelf(); + await user1.poHomeChannel.voiceCalls.roomSection.unmuteSelf(); + + await user1.poHomeChannel.voiceCalls.roomSection.holdSelf(); + await user1.poHomeChannel.voiceCalls.roomSection.resumeSelf(); + }); + + await test.step('should change chat visibility', async () => { + // Visible by default + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + // Hide + await user1.poHomeChannel.voiceCalls.roomSection.hideChat(); + await expect(user1.poHomeChannel.composer.inputMessage).not.toBeVisible(); + + // Show + await user1.poHomeChannel.voiceCalls.roomSection.showChat(); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + }); + + await test.step('end call to clean up', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.endCall(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); + + test('should switch views when navigating into and away from peer DM', async () => { + const [user1, user2] = sessions; + + await test.step('establish call and navigate to peer DM', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('navigate user1 away from peer DM', async () => { + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + const initialRoomViewTimer = await user1.poHomeChannel.voiceCalls.roomSection.getTimerContentInSeconds(); + + await user1.poHomeChannel.navbar.openChat('general'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + + const widgetTimer = await user1.poHomeChannel.voiceCalls.widget.getTimerContentInSeconds(); + expect(widgetTimer).toBeGreaterThanOrEqual(initialRoomViewTimer); + }); + + await test.step('return to peer DM (go to dm button)', async () => { + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + const initialWidgetTimer = await user1.poHomeChannel.voiceCalls.widget.getTimerContentInSeconds(); + + await user1.poHomeChannel.voiceCalls.widget.goToDm(); + + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + + const roomTimer = await user1.poHomeChannel.voiceCalls.roomSection.getTimerContentInSeconds(); + expect(roomTimer).toBeGreaterThanOrEqual(initialWidgetTimer); + }); + + await test.step('end call to clean up', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.endCall(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); + + // Maybe flaky + test('Screen sharing', async () => { + const [user1, user2] = sessions; + + await test.step('establish call and navigate to peer DM', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + }); + + await test.step('start screen sharing with both users', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.shareScreen(); + + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(1); + + await user2.poHomeChannel.voiceCalls.widget.shareScreen(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(2); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(2); + }); + + await test.step('stop sharing with user1 through room view', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.stopSharing(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(1); + }); + + await test.step('stop sharing with user2 through widget', async () => { + await user2.poHomeChannel.voiceCalls.widget.stopSharing(); + + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(0); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(0); + }); + + await test.step('end call to clean up', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.endCall(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); +}); + +test.describe('Internal Voice Calls - Popout view - Enterprise Edition', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + test.afterAll(async () => { + await Promise.all([...sessions.map(({ page }) => page.close())]); + }); + + test('should show popout view', async () => { + const [user1, user2] = sessions; + + await test.step('establish call from user1 DM with user2', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('should open popout for both users', async () => { + await user1.poHomeChannel.voiceCalls.openPopoutRoom(); + await expect(user1.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + + await user2.poHomeChannel.voiceCalls.openPopoutWidget(); + await expect(user2.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + }); + + await test.step('user cards should have correct names', async () => { + await expect(user1.poHomeChannel.voiceCalls.popout.peerCard('user1')).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.popout.peerCard('user2')).toBeVisible(); + + await expect(user2.poHomeChannel.voiceCalls.popout.peerCard('user1')).toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.popout.peerCard('user2')).toBeVisible(); + }); + + await test.step('mute/unmute and hold/resume', async () => { + await user1.poHomeChannel.voiceCalls.popout.muteSelf(); + await user2.poHomeChannel.voiceCalls.popout.muteSelf(); + + await user1.poHomeChannel.voiceCalls.popout.unmuteSelf(); + await user2.poHomeChannel.voiceCalls.popout.unmuteSelf(); + + await user1.poHomeChannel.voiceCalls.popout.holdSelf(); + await user2.poHomeChannel.voiceCalls.popout.holdSelf(); + + await user1.poHomeChannel.voiceCalls.popout.resumeSelf(); + await user2.poHomeChannel.voiceCalls.popout.resumeSelf(); + }); + + await test.step('Widget "Show call here" button', async () => { + await expect(user2.poHomeChannel.voiceCalls.widget.btnShowCallHere).toBeVisible(); + await user2.poHomeChannel.voiceCalls.widget.showCallHere(); + }); + + await test.step('Room "Show call here" button', async () => { + await expect(user1.poHomeChannel.voiceCalls.roomSection.btnShowCallHere).toBeVisible(); + await user1.poHomeChannel.voiceCalls.roomSection.showCallHere(); + }); + + await test.step('end call from within popup', async () => { + await user1.poHomeChannel.voiceCalls.openPopoutRoom(); + await expect(user1.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + + await user1.poHomeChannel.voiceCalls.popout.endCall(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); + + // Maybe flaky + test('Screen sharing', async () => { + const [user1, user2] = sessions; + + await test.step('establish call and open popout', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + + await user1.poHomeChannel.voiceCalls.openPopoutRoom(); + await expect(user1.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + + await user2.poHomeChannel.voiceCalls.openPopoutWidget(); + await expect(user2.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + }); + + await test.step('start screen sharing with both users', async () => { + await user1.poHomeChannel.voiceCalls.popout.shareScreen(); + + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + + await user2.poHomeChannel.voiceCalls.popout.shareScreen(); + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(2); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(2); + }); + + await test.step('stop sharing with both users through popout', async () => { + await user1.poHomeChannel.voiceCalls.popout.stopSharing(); + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + + await user2.poHomeChannel.voiceCalls.popout.stopSharing(); + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(0); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(0); + }); + + await test.step('end call with user2 from the popout', async () => { + await user2.poHomeChannel.voiceCalls.popout.endCall(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); }); }); }); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2ab8263d4e6fb..9575fd1284e63 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1053,6 +1053,7 @@ "Call_not_found": "Call not found", "Call_not_found_error": "This could happen when the call URL is not valid, or you're having connection issues. Please check with the source of the call URL and try again, or talk to your workspace administrator if the problem persists", "Call_ongoing": "Call ongoing", + "Call_open_separate_window": "Call open in a separate window", "Call_provider": "Call Provider", "Call_ringer_volume": "Call ringer volume", "Call_ringer_volume_hint": "For all incoming voice and video call notifications", @@ -2157,6 +2158,7 @@ "Exclude_pinned": "Exclude pinned messages", "Execute_Synchronization_Now": "Execute Synchronization Now", "Exit_Full_Screen": "Exit Full Screen", + "Exit_fullscreen": "Exit fullscreen", "Expand": "Expand", "Expand_group": "Expand {{group}}", "Expand_all": "Expand all", @@ -2222,6 +2224,7 @@ "Failed_to_generate_invite_link": "Failed to generate invite link", "Failed_to_transfer_call": "Failed to transfer call", "Failed_to_validate_invite_token": "Failed to validate invite token", + "Failed_to_open_call_window": "Failed to open call window", "Failure": "Failure", "Fallback_forward_department": "Fallback department for forwarding", "Fallback_forward_department_description": "Allows you to define a fallback department which will receive the chats forwarded to this one in case there's no online agents at the moment", @@ -4067,6 +4070,7 @@ "Open_Days": "Open days", "Open_Dialpad": "Open Dialpad", "Open_dialpad": "Open dialpad", + "Open_in_new_window": "Open in new window", "Open_Livechats": "Chats in progress", "Open_Outlook": "Open Outlook", "Open_call": "Open call", @@ -4612,6 +4616,7 @@ "Retrying": "Retrying", "Retry_Count": "Retry Count", "Return_to_home": "Return to home", + "Return_to_main_window": "Return to main window", "Return_to_previous_page": "Return to previous page", "Return_to_the_queue": "Return back to the Queue", "Review": "Review", @@ -4982,6 +4987,7 @@ "Show_agent_email": "Show agent email", "Show_agent_info": "Show agent information", "Show_all": "Show All", + "Show_call_here": "Show call here", "Show_counter": "Mark as unread", "Show_default_content": "Show default content", "Show_email_field": "Show email field", @@ -6029,6 +6035,7 @@ "You_are_not_authorized_to_access_this_feature": "You are not authorized to access this feature.", "You_are_sharing_your_screen": "You are sharing your screen", "You_can_change_a_different_avatar_too": "You can override the avatar used to post from this integration.", + "You_can_close_this_window": "You can close this window", "You_can_close_this_window_now": "You can close this window now.", "You_can_do_from_account_preferences": "You can do this later from your account preferences", "You_can_search_using_RegExp_eg": "You can search using Regular Expression. e.g. /^text$/i", diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index cae9475f57e75..76e16c947bc1a 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -8,7 +8,8 @@ "/dist" ], "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build": "rm -rf dist && tsc -p tsconfig.build.json && yarn build:post", + "build:post": "node --no-warnings dist/generate-landing-view.js && rm -f dist/generate-landing-view.*", "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", "lint": "eslint .", "lint:fix": "eslint --fix .", @@ -51,6 +52,7 @@ "@testing-library/user-event": "~14.6.1", "@types/jest": "~30.0.0", "@types/jest-axe": "~3.5.9", + "@types/node": "~22.19.17", "@types/react": "~18.3.28", "@types/react-dom": "~18.3.7", "date-fns": "~4.1.0", @@ -58,6 +60,7 @@ "i18next": "~23.4.9", "jest": "~30.2.0", "jest-axe": "~10.0.0", + "jsdom": "^26.1.0", "react": "~18.3.1", "react-dom": "~18.3.1", "react-virtuoso": "~4.12.8", diff --git a/packages/ui-voip/src/components/ToggleButton.tsx b/packages/ui-voip/src/components/ToggleButton.tsx index 86f6a72852d4a..03ea1e2362e7b 100644 --- a/packages/ui-voip/src/components/ToggleButton.tsx +++ b/packages/ui-voip/src/components/ToggleButton.tsx @@ -11,20 +11,32 @@ type ToggleButtonProps = { onToggle?: () => void; } & Omit, 'icon' | 'title' | 'aria-label' | 'disabled' | 'onClick'>; -const ToggleButton = ({ disabled, label, pressed, icons, titles, onToggle, ...props }: ToggleButtonProps) => { +const ToggleButton = ({ + disabled, + label, + pressed, + icons, + titles, + onToggle, + danger = true, + secondary = true, + tiny = false, + ...props +}: ToggleButtonProps) => { const iconName = icons[pressed ? 1 : 0]; const title = titles[pressed ? 1 : 0]; - const iconColor = pressed ? 'font-danger' : undefined; + const iconColor = pressed && danger ? 'font-danger' : undefined; + + const size = tiny ? { tiny: true } : { medium: true }; return ( } title={title} - pressed={pressed} aria-label={label} disabled={disabled} onClick={onToggle} diff --git a/packages/ui-voip/src/components/Widget/Widget.tsx b/packages/ui-voip/src/components/Widget/Widget.tsx index d199f0ef0ac81..56c43ea828bba 100644 --- a/packages/ui-voip/src/components/Widget/Widget.tsx +++ b/packages/ui-voip/src/components/Widget/Widget.tsx @@ -10,8 +10,9 @@ import { type LastKnownPosition } from '../../providers/useWidgetPositionTracker // TODO: Initial position from the draggable api instead of style props // TODO: A11Y -const WidgetBase = styled('article')` +const WidgetBase = styled('div')` position: fixed; + padding: 0; right: 2em; top: 11em; display: flex; @@ -45,7 +46,12 @@ const Widget = ({ children, onChangePosition, restorePosition, ...props }: Widge return ( - + {children} diff --git a/packages/ui-voip/src/components/Widget/WidgetHeader.tsx b/packages/ui-voip/src/components/Widget/WidgetHeader.tsx index 733c8c21441cc..02c3495ad1297 100644 --- a/packages/ui-voip/src/components/Widget/WidgetHeader.tsx +++ b/packages/ui-voip/src/components/Widget/WidgetHeader.tsx @@ -1,5 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; import type { ReactNode } from 'react'; +import { VisuallyHidden } from 'react-aria'; +import { useTranslation } from 'react-i18next'; type WidgetHeaderProps = { title: ReactNode; @@ -8,8 +10,10 @@ type WidgetHeaderProps = { // TODO: A11Y - duration/title const WidgetHeader = ({ title, children }: WidgetHeaderProps) => { + const { t } = useTranslation(); return ( + {t('Voice_call')} {title} diff --git a/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap b/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap index 26354a3750a1d..041b73b7e6363 100644 --- a/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap +++ b/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap @@ -7,9 +7,10 @@ exports[`renders FullWidget without crashing 1`] = ` data-focus-scope-start="true" hidden="" /> - + + ); +}; + +export default memo(MediaCallPopoutView); diff --git a/packages/ui-voip/src/views/MediaCallPopoutWindow.tsx b/packages/ui-voip/src/views/MediaCallPopoutWindow.tsx new file mode 100644 index 0000000000000..99fee03d46104 --- /dev/null +++ b/packages/ui-voip/src/views/MediaCallPopoutWindow.tsx @@ -0,0 +1,77 @@ +import { Box, OwnerDocument as FuselageOwnerDocument } from '@rocket.chat/fuselage'; +import { OwnerDocument as StyledOwnerDocument } from '@rocket.chat/styled'; +import { ModalProvider, ModalRegion, TooltipProvider, useUserDisplayName } from '@rocket.chat/ui-client'; +import { useUser, useUserAvatarPath } from '@rocket.chat/ui-contexts'; +import { useCallback, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import MediaCallPopoutView from './MediaCallPopoutView'; +import type { PopoutContainer } from './usePopoutWindow'; +import MediaCallViewProvider from '../providers/MediaCallViewProvider'; + +type MediaCallPopoutWindowProps = { + container: PopoutContainer; + onClosePopout: () => void; +}; +const MediaCallPopoutWindow = ({ container, onClosePopout }: MediaCallPopoutWindowProps) => { + const [fullscreen, setFullscreen] = useState(false); + const [region] = useState(() => Symbol()); + + const user = useUser(); + const displayName = useUserDisplayName({ name: user?.name, username: user?.username }); + const getUserAvatarPath = useUserAvatarPath(); + const ownUser = useMemo(() => { + return { + displayName: displayName || '', + avatarUrl: getUserAvatarPath({ userId: user?._id || '' }), + }; + }, [displayName, getUserAvatarPath, user?._id]); + + const { root, ownerDocument } = container; + + const onClickFullscreen = useCallback(() => { + const requestFullScreen = async () => { + try { + if (!fullscreen) { + await ownerDocument.documentElement.requestFullscreen(); + setFullscreen(true); + } else { + await ownerDocument.exitFullscreen(); + setFullscreen(false); + } + } catch (error) { + console.error('Error requesting fullscreen', error); + } + }; + void requestFullScreen(); + }, [ownerDocument, fullscreen]); + + const contextValue = useMemo(() => ({ document: ownerDocument }), [ownerDocument]); + + return ( + + + + + + {createPortal( + + + + , + root, + )} + + + + + + ); +}; + +export default MediaCallPopoutWindow; diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx index fdf221acd8e4e..0429312e03f6e 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx +++ b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx @@ -1,4 +1,4 @@ -import { Box, ButtonGroup } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,8 +16,9 @@ import { ActionStrip, ActionToggleChat, } from '../../components'; +import { useMediaCallInstance } from '../../context/MediaCallInstanceContext'; import { useMediaCallView } from '../../context/MediaCallViewContext'; -import useRoomView from '../../context/useRoomView'; +import useRegisterView from '../../context/useRegisterView'; import { usePlayMediaStream } from '../../providers/usePlayMediaStream'; type MediaCallRoomSectionProps = { @@ -55,8 +56,13 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: onForward, onEndCall, onToggleScreenSharing, + onOpenPopout, + onClosePopout, streams: { remoteScreen, localScreen }, } = useMediaCallView(); + const { currentViews } = useMediaCallInstance(); + + const isPopout = currentViews.includes('popout'); const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, startedAt } = sessionState; @@ -68,7 +74,7 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: const [remoteStreamRefCallback] = usePlayMediaStream(remoteScreen?.stream ?? null); const [localStreamRefCallback] = usePlayMediaStream(localScreen?.stream ?? null); - useRoomView(); + useRegisterView('room'); const onClickFocusRemoteCard = () => { setFocusedCard((prev) => (prev === 'remote' ? null : 'remote')); @@ -84,7 +90,14 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: const remoteStreamCard = remoteScreen?.active ? ( - @@ -98,7 +111,14 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: focused={focusedCard === 'local'} showStopSharingOnHover > -