[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:
committed by
Sébastien Theys
parent
9f11a84d71
commit
401fc7efe9
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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);
|
||||
Reference in New Issue
Block a user