[IMP] mail: add the "raise hand" feature to calls

This commit adds a button to the `CallActionList` to signal to the
other participants of the call that one is raising a hand.

This will add a hand icon to the `CallParticipantCard` of the people
with a raised hand and send a notification that is displayed in the
`Call` view (only when in discuss, not in chat windows).

task-3229507

closes odoo/odoo#116658

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Thanh Dodeur
2023-03-28 13:43:40 +02:00
committed by Alexandre Kühn
parent ec82454839
commit 5f82fc4cc6
10 changed files with 111 additions and 3 deletions
@@ -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)];
}
@@ -33,6 +33,7 @@
<ImStatus className="'position-absolute bottom-0 end-0'" persona="member.persona"/>
</div>
<span class="ms-2 text-truncate" t-esc="member.persona.name"/>
<span t-if="member.rtcSession?.isRaisingHand" class="p-2 fa ms-auto fa-hand-paper-o"/>
</div>
</t>
+17 -1
View File
@@ -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
+3 -1
View File
@@ -3,7 +3,6 @@
<t t-name="mail.Call" owl="1">
<div class="o-mail-Call user-select-none d-flex" t-att-class="{'o-fullscreen fixed-top vw-100 vh-100': state.isFullscreen, 'o-minimized': minimized, 'position-relative': !state.isFullscreen }">
<!-- Used to make the component depend on the window size and trigger an update when the window size changes. -->
<div class="d-flex flex-grow-1 flex-column align-items-center justify-content-center position-relative overflow-auto bg-black-50" t-on-mouseleave="onMouseleaveMain">
<div
class="d-flex align-items-center overflow-hidden h-100 w-100 flex-wrap justify-content-center"
@@ -30,6 +29,9 @@
<CallActionList thread="props.thread" compact="props.compact" fullscreen="{ isActive: state.isFullscreen, enter: () => this.enterFullScreen(), exit: () => this.exitFullScreen() }"/>
</div>
</div>
<div t-if="hasCallNotifications" class="position-absolute d-flex flex-column-reverse start-0 bottom-0" t-att-class="{ 'ps-5 pb-5': state.isFullscreen, 'ps-2 pb-2': !state.isFullscreen }">
<span class="bg-800 shadow-lg rounded-1 m-1" t-att-class="{ 'p-4 fs-4': state.isFullscreen, 'p-2': !state.isFullscreen }" t-foreach="callNotifications" t-as="notification" t-key="notification.id" t-esc="notification.text"/>
</div>
</div>
<div t-if="state.sidebar and props.thread.activeRtcSession" class="o-mail-Call-sidebar d-flex align-items-center h-100 flex-column">
<CallParticipantCard t-foreach="visibleCards" t-as="cardData" t-key="cardData.key"
@@ -35,6 +35,10 @@ export class CallActionList extends Component {
}
}
async onClickRaiseHand(ev) {
this.rtc.raiseHand(!this.rtc.state.selfSession.isRaisingHand);
}
/**
* @param {MouseEvent} ev
*/
@@ -37,6 +37,17 @@
}"/>
</div>
</button>
<t t-if="rtc.state?.selfSession.isRaisingHand" t-set="handText">Lower hand</t>
<t t-else="" t-set="handText">Raise hand</t>
<button class="btn d-flex m-1 border-0 rounded-circle shadow-none opacity-100 opacity-75-hover"
t-att-class="{ 'p-2': isSmall, 'p-3': !isSmall, 'bg-500' : rtc.state?.selfSession.isRaisingHand }"
t-att-aria-label="handText"
t-att-title="handText"
t-on-click="onClickRaiseHand">
<div class="fa-stack">
<i class="fa fa-stack-1x fa-hand-paper-o" t-att-class="{'fa-lg': !isSmall }"/>
</div>
</button>
<t t-if="rtc.state.sendCamera" t-set="cameraText">Stop camera</t>
<t t-else="" t-set="cameraText">Turn camera on</t>
<button class="btn d-flex m-1 border-0 rounded-circle shadow-none opacity-100 opacity-75-hover"
@@ -36,6 +36,9 @@
</small>
</span>
<div class="o-mail-CallParticipantCard-overlay position-absolute top-0 end-0 d-flex flex-row-reverse">
<span t-if="rtcSession.isRaisingHand" class="d-flex flex-column justify-content-center me-1 rounded-circle bg-500" t-att-class="{'o-minimized p-1': props.minimized, 'p-2': !props.minimized }" title="raising hand" aria-label="raising hand">
<i class="fa fa-hand-paper-o"/>
</span>
<span t-if="rtcSession.isSelfMuted and !rtcSession.isDeaf" class="d-flex flex-column justify-content-center me-1 rounded-circle bg-900" t-att-class="{'o-minimized p-1': props.minimized, 'p-2': !props.minimized }" title="muted" aria-label="muted">
<i class="fa fa-microphone-slash"/>
</span>
+64
View File
@@ -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;
@@ -19,6 +19,7 @@ export class RtcSession {
/** @type {RTCDataChannel} */
dataChannel;
isAudioInError;
isRaisingHand;
isTalking;
localVolume;
/** @type {RTCPeerConnection} */
+2 -1
View File
@@ -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']");