diff --git a/addons/mail/static/src/core/channel_member_model.js b/addons/mail/static/src/core/channel_member_model.js index 6a382811333..94a487765c4 100644 --- a/addons/mail/static/src/core/channel_member_model.js +++ b/addons/mail/static/src/core/channel_member_model.js @@ -11,6 +11,7 @@ import { createLocalId } from "../utils/misc"; */ export class ChannelMember { personaLocalId; + rtcSessionId; threadId; typingTimer; /** @type {import("@mail/core/store_service").Store} */ @@ -24,6 +25,10 @@ export class ChannelMember { this.personaLocalId = persona?.localId; } + get rtcSession() { + return this._store.rtcSessions[this.rtcSessionId]; + } + get thread() { return this._store.threads[createLocalId("mail.channel", this.threadId)]; } diff --git a/addons/mail/static/src/discuss/channel_member_list.xml b/addons/mail/static/src/discuss/channel_member_list.xml index b50ca52b94d..a6c08b0638a 100644 --- a/addons/mail/static/src/discuss/channel_member_list.xml +++ b/addons/mail/static/src/discuss/channel_member_list.xml @@ -33,6 +33,7 @@ + diff --git a/addons/mail/static/src/rtc/call.js b/addons/mail/static/src/rtc/call.js index 330241bda16..77534321e90 100644 --- a/addons/mail/static/src/rtc/call.js +++ b/addons/mail/static/src/rtc/call.js @@ -53,11 +53,19 @@ export class Call extends Component { useExternalListener(browser, "fullscreenchange", this.onFullScreenChange); } + get callNotifications() { + return [...this.rtc.notifications.values()]; + } + + get isActiveCall() { + return Boolean(this.props.thread.id === this.rtc.state?.channel?.id); + } + get minimized() { if (this.state.isFullscreen || this.props.compact || this.props.thread.activeRtcSession) { return false; } - if (this.rtc.state.channel !== this.props.thread || this.props.thread.videoCount === 0) { + if (!this.isActiveCall || this.props.thread.videoCount === 0) { return true; } return false; @@ -97,6 +105,14 @@ export class Call extends Component { return this.visibleCards; } + get hasCallNotifications() { + return Boolean( + (!this.props.compact || this.state.isFullscreen) && + this.isActiveCall && + this.rtc.notifications.size + ); + } + get hasSidebarButton() { return Boolean( this.props.thread.activeRtcSession && this.state.overlay && !this.props.compact diff --git a/addons/mail/static/src/rtc/call.xml b/addons/mail/static/src/rtc/call.xml index 106d309ab8d..5745d90abd7 100644 --- a/addons/mail/static/src/rtc/call.xml +++ b/addons/mail/static/src/rtc/call.xml @@ -3,7 +3,6 @@ - + + + + Lower hand + Raise hand + + + + + Stop camera Turn camera on + + + diff --git a/addons/mail/static/src/rtc/rtc_service.js b/addons/mail/static/src/rtc/rtc_service.js index b6023c298c3..c76da6b4e18 100644 --- a/addons/mail/static/src/rtc/rtc_service.js +++ b/addons/mail/static/src/rtc/rtc_service.js @@ -87,6 +87,9 @@ function hasTurn(iceServers) { } export class Rtc { + notifications = reactive(new Map()); + timeouts = new Map(); + constructor(env, services) { this.env = env; /** @type {import("@mail/core/store_service").Store} */ @@ -257,6 +260,35 @@ export class Rtc { }, 30_000); } + /** + * @param {Object} param0 + * @param {any} param0.id + * @param {string} param0.text + * @param {number} [param0.delay] + */ + addCallNotification({ id, text, delay = 3000 }) { + if (this.notifications.has(id)) { + return; + } + this.notifications.set(id, { id, text }); + this.timeouts.set( + id, + browser.setTimeout(() => { + this.notifications.delete(id); + this.timeouts.delete(id); + }, delay) + ); + } + + /** + * @param {any} id + */ + removeCallNotification(id) { + browser.clearTimeout(this.timeouts.get(id)); + this.notifications.delete(id); + this.timeouts.delete(id); + } + /** * Notifies the server and does the cleanup of the current call. */ @@ -403,6 +435,19 @@ export class Rtc { }); this.disconnect(session); break; + case "raise_hand": + Object.assign(session, { isRaisingHand: payload.active }); + // eslint-disable-next-line no-case-declarations + const notificationId = "raise_hand_" + session.id; + if (session.isRaisingHand) { + this.addCallNotification({ + id: notificationId, + text: sprintf(_t("%s raised a hand"), session.name), + }); + } else { + this.removeCallNotification(notificationId); + } + break; case "trackChange": { const { isSelfMuted, isTalking, isSendingVideo, isDeaf } = payload.state; if (payload.type === "audio") { @@ -641,6 +686,9 @@ export class Rtc { isSelfMuted: this.state.selfSession.isSelfMuted, }, }); + await this.notify([session], "raise_hand", { + active: this.state.selfSession.isRaisingHand, + }); } catch (e) { if (!(e instanceof DOMException) || e.name !== "OperationError") { throw e; @@ -858,6 +906,7 @@ export class Rtc { } disconnect(session) { + this.removeCallNotification("raise_hand_" + session.id); closeStream(session.audioStream); if (session.audioElement) { session.audioElement.pause(); @@ -878,6 +927,7 @@ export class Rtc { delete session.iceState; delete session.logStep; session.isAudioInError = false; + session.isRaisingHand = false; session.isTalking = false; this.removeVideoFromSession(session); session.dataChannel?.close(); @@ -1001,6 +1051,19 @@ export class Rtc { await this.refreshAudioStatus(); } + /** + * @param {Boolean} isRaisingHand + */ + async raiseHand(isRaisingHand) { + if (!this.state.selfSession || !this.state.channel) { + return; + } + this.state.selfSession.isRaisingHand = isRaisingHand; + await this.notify(Object.values(this.state.channel.rtcSessions), "raise_hand", { + active: this.state.selfSession.isRaisingHand, + }); + } + /** * @param {boolean} isTalking */ @@ -1316,6 +1379,7 @@ export class Rtc { } if (channelMember) { const channelMemberRecord = this.channelMemberService.insert(channelMember); + channelMemberRecord.rtcSessionId = session.id; session.channelMemberId = channelMemberRecord.id; if (channelMemberRecord.thread) { channelMemberRecord.thread.rtcSessions[session.id] = session; diff --git a/addons/mail/static/src/rtc/rtc_session_model.js b/addons/mail/static/src/rtc/rtc_session_model.js index b85dca7e628..47e12eda5c8 100644 --- a/addons/mail/static/src/rtc/rtc_session_model.js +++ b/addons/mail/static/src/rtc/rtc_session_model.js @@ -19,6 +19,7 @@ export class RtcSession { /** @type {RTCDataChannel} */ dataChannel; isAudioInError; + isRaisingHand; isTalking; localVolume; /** @type {RTCPeerConnection} */ diff --git a/addons/mail/static/tests/call/call_tests.js b/addons/mail/static/tests/call/call_tests.js index 5e7434da5db..b613c2c3b9a 100644 --- a/addons/mail/static/tests/call/call_tests.js +++ b/addons/mail/static/tests/call/call_tests.js @@ -25,9 +25,10 @@ QUnit.test("basic rendering", async (assert) => { assert.containsOnce($, ".o-mail-CallParticipantCard[aria-label='Mitchell Admin']"); assert.containsOnce($, ".o-mail-CallActionList"); assert.containsOnce($, ".o-mail-CallMenu-buttonContent"); - assert.containsN($, ".o-mail-CallActionList button", 6); + assert.containsN($, ".o-mail-CallActionList button", 7); assert.containsOnce($, "button[aria-label='Unmute'], button[aria-label='Mute']"); // FIXME depends on current browser permission assert.containsOnce($, ".o-mail-CallActionList button[aria-label='Deafen']"); + assert.containsOnce($, ".o-mail-CallActionList button[aria-label='Raise hand']"); assert.containsOnce($, ".o-mail-CallActionList button[aria-label='Turn camera on']"); assert.containsOnce($, ".o-mail-CallActionList button[aria-label='Share screen']"); assert.containsOnce($, ".o-mail-CallActionList button[aria-label='Enter Full Screen']");