[IMP] discuss: add 'Start a meeting' feature

- Add a new stand-alone page "WelcomeView"
    - Redirect people who join using an invitation link to the WelcomeView
    - Add microphone/camera preview
    - Allow guests to choose a name from the WelcomeView
- Add "Start a meeting" button to the discuss sidebar
    - Create a new channel on click on the "Start a meeting" button
    - Display ChannelInvitationForm on click on the "Start a meeting"
button
    - Add a new field to `mail.channel` in order to define the display
mode to use for videos (e.g full screen) when joining a meeting

Task 2494829

closes odoo/odoo#75899

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Louis Wicket (wil)
2021-09-06 08:21:38 +00:00
committed by Sébastien Theys
parent 9f11a84d71
commit 401fc7efe9
27 changed files with 761 additions and 51 deletions
@@ -190,6 +190,20 @@ function factory(dependencies) {
return this.env._t("Invite to Channel");
}
/**
* @private
* @returns {mail.thread}
*/
_computeThread() {
if (this.threadView && this.threadView.thread) {
return link(this.threadView.thread);
}
if (this.discuss && this.discuss.mostRecentMeetingChannel) {
return link(this.discuss.mostRecentMeetingChannel);
}
return clear();
}
}
ChannelInvitationForm.fields = {
@@ -199,6 +213,9 @@ function factory(dependencies) {
* it is open to update the button active state.
*/
component: attr(),
discuss: one2one('mail.discuss', {
inverse: 'channelInvitationForm',
}),
/**
* Determines whether this search input needs to be focused.
*/
@@ -251,7 +268,8 @@ function factory(dependencies) {
* States the thread on which this list operates (if any).
*/
thread: many2one('mail.thread', {
related: 'threadView.thread',
compute: '_computeThread',
required: true,
}),
/**
* States the thread view on which this list operates (if any).
@@ -1,15 +1,25 @@
/** @odoo-module **/
import { registerNewModel } from '@mail/model/model_core';
import { attr, many2one, one2many, one2one } from '@mail/model/model_field';
import { attr, many2one, one2one } from '@mail/model/model_field';
import { clear, create, link, unlink, unlinkAll, update } from '@mail/model/model_field_command';
function factory(dependencies) {
class Discuss extends dependencies['mail.model'] {
/**
* @override
*/
_created() {
super._created();
// Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876
this.onClickStartAMeetingButton = this.onClickStartAMeetingButton.bind(this);
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
clearIsAddingItem() {
this.update({
@@ -30,6 +40,23 @@ function factory(dependencies) {
this.update({ isOpen: false });
}
async createMeetingChannel() {
const channelData = await this.env.services.rpc({
model: 'mail.channel',
method: 'create_group',
kwargs: {
default_display_mode: 'video_full_screen',
partners_to: [this.messaging.currentPartner.id],
},
});
const channel = this.messaging.models['mail.thread'].insert(
this.messaging.models['mail.thread'].convertData(channelData)
);
this.update({ mostRecentMeetingChannel: link(channel) });
this.channelInvitationForm.update({ doFocusOnSearchInput: true });
this.channelInvitationForm.searchPartnersToInvite();
}
focus() {
this.update({ isDoFocus: true });
}
@@ -153,6 +180,15 @@ function factory(dependencies) {
this.update({ isAddingChannel: true });
}
/**
* Handles click on the "Start a meeting" button.
*
* @param {MouseEvent} ev
*/
onClickStartAMeetingButton(ev) {
this.createMeetingChannel();
}
/**
* Open thread from init active id. `initActiveId` is used to refer to
* a thread that we may not have full data yet, such as when messaging
@@ -265,6 +301,25 @@ function factory(dependencies) {
return this.addingChannelValue;
}
/**
* @private
* @returns {mail.channel_invitation_form}
*/
_computeChannelInvitationForm() {
if (!this.mostRecentMeetingChannel) {
return clear();
}
if (!this.channelInvitationForm) {
return create();
}
return update({
searchResultCount: clear(),
searchTerm: clear(),
selectablePartners: clear(),
selectedPartners: clear(),
});
}
/**
* @private
* @returns {boolean}
@@ -328,7 +383,6 @@ function factory(dependencies) {
return;
}
/**
* Only pinned threads are allowed in discuss.
*
@@ -389,6 +443,16 @@ function factory(dependencies) {
categoryChat: one2one('mail.discuss_sidebar_category', {
isCausal: true,
}),
/**
* Determines the channel invitation form to use for the invitation
* to the most recent meeting channel.
*/
channelInvitationForm: one2one('mail.channel_invitation_form', {
compute: '_computeChannelInvitationForm',
inverse: 'discuss',
isCausal: true,
readonly: true,
}),
/**
* Determines whether `this.thread` should be displayed.
*/
@@ -446,6 +510,11 @@ function factory(dependencies) {
menu_id: attr({
default: null,
}),
/**
* The channel created last time the user clicked on the "Start a
* meeting" button.
*/
mostRecentMeetingChannel: one2one('mail.thread'),
/**
* The message that is currently selected as being replied to in Inbox.
* There is only one reply composer shown at a time, which depends on
@@ -0,0 +1,196 @@
/** @odoo-module **/
import { attr } from '@mail/model/model_field';
import { registerNewModel } from '@mail/model/model_core';
function factory(dependencies) {
class MediaPreview extends dependencies['mail.model'] {
/**
* @override
*/
_created() {
super._created();
// Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876
this.onClickDisableMicrophoneButton = this.onClickDisableMicrophoneButton.bind(this);
this.onClickDisableVideoButton = this.onClickDisableVideoButton.bind(this);
this.onClickEnableMicrophoneButton = this.onClickEnableMicrophoneButton.bind(this);
this.onClickEnableVideoButton = this.onClickEnableVideoButton.bind(this);
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
/**
* Iterates tracks of the provided MediaStream, calling the `stop`
* method on each of them.
*
* @static
* @param {MediaStream} mediaStream
*/
static stopTracksOnMediaStream(mediaStream) {
for (const track of mediaStream.getTracks()) {
track.stop();
}
}
/**
* Stops recording user's microphone.
*/
disableMicrophone() {
this.audioRef.el.srcObject = null;
if (!this.audioStream) {
return;
}
this.messaging.models['mail.media_preview'].stopTracksOnMediaStream(this.audioStream);
this.update({ audioStream: null });
}
/**
* Stops recording user's video device.
*/
disableVideo() {
this.videoRef.el.srcObject = null;
if (!this.videoStream) {
return;
}
this.messaging.models['mail.media_preview'].stopTracksOnMediaStream(this.videoStream);
this.update({ videoStream: null });
}
/**
* Asks for access to the user's microphone if not granted yet, then
* starts recording and defines the resulting audio stream as the source
* of the audio element in order to play the audio feedback.
*/
async enableMicrophone() {
if (!this.doesBrowserSupportMediaDevices) {
return;
}
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.update({ audioStream });
this.audioRef.el.srcObject = this.audioStream;
}
/**
* Asks for access to the user's video device if not granted yet, then
* starts recording and defines the resulting video stream as the source
* of the video element in order to display the video feedback.
*/
async enableVideo() {
if (!this.doesBrowserSupportMediaDevices) {
return;
}
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true });
this.update({ videoStream });
this.videoRef.el.srcObject = this.videoStream;
}
/**
* Handles click on the "disable microphone" button.
*/
onClickDisableMicrophoneButton() {
this.disableMicrophone();
}
/**
* Handles click on the "disable video" button.
*/
onClickDisableVideoButton() {
this.disableVideo();
}
/**
* Handles click on the "enable microphone" button.
*/
onClickEnableMicrophoneButton() {
this.enableMicrophone();
}
/**
* Handles click on the "enable video" button.
*/
onClickEnableVideoButton() {
this.enableVideo();
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* @private
* @returns {boolean}
*/
_computeDoesBrowserSupportMediaDevices() {
return Boolean(
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia &&
window.MediaStream
);
}
/**
* @private
* @returns {boolean}
*/
_computeIsMicrophoneEnabled() {
return this.audioStream !== null;
}
/**
* @private
* @returns {boolean}
*/
_computeIsVideoEnabled() {
return this.videoStream !== null;
}
}
MediaPreview.fields = {
/**
* Ref to the audio element used for the audio feedback.
*/
audioRef: attr(),
/**
* The MediaStream from the microphone.
*
* Default set to null to be consistent with the default value of
* `HTMLMediaElement.srcObject`.
*/
audioStream: attr({
default: null,
}),
doesBrowserSupportMediaDevices: attr({
compute: '_computeDoesBrowserSupportMediaDevices',
}),
isMicrophoneEnabled: attr({
compute: '_computeIsMicrophoneEnabled',
}),
isVideoEnabled: attr({
compute: '_computeIsVideoEnabled',
}),
/**
* Ref to the video element used for the video feedback.
*/
videoRef: attr(),
/**
* The MediaStream from the camera.
*
* Default set to null to be consistent with the default value of
* `HTMLMediaElement.srcObject`.
*/
videoStream: attr({
default: null,
}),
};
MediaPreview.modelName = 'mail.media_preview';
return MediaPreview;
}
registerNewModel('mail.media_preview', factory);
@@ -261,6 +261,7 @@ function factory(dependencies) {
readonly: true,
}),
commands: one2many('mail.channel_command'),
companyName: attr(),
currentGuest: one2one('mail.guest'),
currentPartner: one2one('mail.partner'),
currentUser: one2one('mail.user'),
@@ -74,6 +74,7 @@ function factory(dependencies) {
async _init({
channels,
commands = [],
companyName,
current_partner,
currentGuest,
current_user_id,
@@ -113,6 +114,8 @@ function factory(dependencies) {
// failures after channels
this._initMailFailures(mail_failures);
discuss.update({ menu_id });
// company related data
this.messaging.update({ companyName });
}
/**
@@ -89,6 +89,8 @@ function factory(dependencies) {
return this._handleNotificationChannelRenamed(message.payload);
case 'mail.channel_update':
return this._handleNotificationChannelUpdate(message.payload);
case 'mail.guest_update':
return this.messaging.models['mail.guest'].insert(message.payload);
case 'mail.message_update':
return this.messaging.models['mail.message'].insert(message.payload);
case 'res.users_settings_changed':
@@ -97,6 +97,14 @@ function factory(dependencies) {
return callViewer && callViewer.isMinimized;
}
/**
* @private
* @returns {boolean}
*/
_computeIsInvitation() {
return Boolean(this.invitedPartner || this.invitedGuest);
}
/**
* @private
* @returns {boolean}
@@ -144,6 +152,13 @@ function factory(dependencies) {
* If set, this card represents an invitation of this partner to this call.
*/
invitedPartner: many2one('mail.partner'),
/**
* States whether this card is representing a person with a pending
* invitation.
*/
isInvitation: attr({
compute: '_computeIsInvitation'
}),
/**
* Determines if this card has to be displayed in a minimized form.
*/
@@ -175,6 +175,9 @@ function factory(dependencies) {
if ('avatarCacheKey' in data) {
data2.avatarCacheKey = data.avatarCacheKey;
}
if ('defaultDisplayMode' in data) {
data2.defaultDisplayMode = data.defaultDisplayMode;
}
if ('description' in data) {
data2.description = data.description;
}
@@ -2021,6 +2024,12 @@ function factory(dependencies) {
}),
creator: many2one('mail.user'),
custom_channel_name: attr(),
/**
* Determines the default display mode of this channel. Should contain
* either no value (to display the chat), or 'video_full_screen' to
* start a call in full screen.
*/
defaultDisplayMode: attr(),
/**
* States the description of this thread. Only applies to channels.
*/
@@ -0,0 +1,151 @@
/** @odoo-module **/
import { attr, one2one } from '@mail/model/model_field';
import { browser } from "@web/core/browser/browser";
import { create } from '@mail/model/model_field_command';
import { registerNewModel } from '@mail/model/model_core';
function factory(dependencies) {
const getNextGuestNameInputId = (function() {
let id = 0;
return () => ++id;
})();
class WelcomeView extends dependencies['mail.model'] {
/**
* @override
*/
_created() {
super._created();
// Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876
this.onClickJoinButton = this.onClickJoinButton.bind(this);
this.onInputGuestNameInput = this.onInputGuestNameInput.bind(this);
this.onKeydownGuestNameInput = this.onKeydownGuestNameInput.bind(this);
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
/**
* @static
* @param {string} name
*/
static async updateGuestNameServerSide(name) {
await this.env.services.rpc({
route: '/mail/guest/update_name',
params: { name },
});
}
/**
* @param {MouseEvent} ev
*/
async onClickJoinButton(ev) {
this.updateGuestNameIfAnyThenJoinChannel();
}
/**
* Handles OWL update on this WelcomeView component.
*/
onComponentUpdate() {
this._handleFocus();
}
/**
* @param {KeyboardEvent} ev
*/
onInputGuestNameInput(ev) {
this._updateGuestNameWithInputValue();
}
/**
* @param {KeyboardEvent} ev
*/
onKeydownGuestNameInput(ev) {
if (ev.key === 'Enter') {
this.updateGuestNameIfAnyThenJoinChannel();
}
}
/**
* Redirects users to the related channel. If the current user is a
* guest, first updates their username with the value of guestNameInput.
*/
async updateGuestNameIfAnyThenJoinChannel() {
if (this.messaging.currentGuest) {
await this.messaging.models['mail.welcome_view'].updateGuestNameServerSide(this.pendingGuestName.trim());
}
browser.location.href = `/discuss/channel/${this.channelId}`;
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* @private
*/
_handleFocus() {
if (this.isDoFocusGuestNameInput) {
if (!this.guestNameInputRef.el) {
return
}
this.update({ isDoFocusGuestNameInput: false });
this.guestNameInputRef.el.focus();
// place cursor at end of text
const { length } = (this.pendingGuestName || '');
this.guestNameInputRef.el.setSelectionRange(length, length);
}
}
/**
* @private
*/
_updateGuestNameWithInputValue() {
this.update({ pendingGuestName: this.guestNameInputRef.el.value });
}
/**
* @private
* @returns {boolean}
*/
_computeIsJoinButtonDisabled() {
return Boolean(this.messaging.currentGuest && !this.pendingGuestName.trim());
}
}
WelcomeView.fields = {
channelId: attr({
required: true,
}),
/**
* Used as value for `id`, `for`, and `name` attributes of the guest
* name input and its label. Necessary to ensure the uniqueness.
*/
guestNameInputUniqueId: attr({
default: `o_WelcomeView_guestNameInput_${getNextGuestNameInputId()}`,
}),
guestNameInputRef: attr(),
isDoFocusGuestNameInput: attr(),
isJoinButtonDisabled: attr({
compute: '_computeIsJoinButtonDisabled'
}),
mediaPreview: one2one('mail.media_preview', {
default: create(),
isCausal: true,
readonly: true,
required: true,
}),
pendingGuestName: attr(),
};
WelcomeView.modelName = 'mail.welcome_view';
return WelcomeView;
}
registerNewModel('mail.welcome_view', factory);