[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:
committed by
Alexandre Kühn
parent
ec82454839
commit
5f82fc4cc6
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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']");
|
||||
|
||||
Reference in New Issue
Block a user