[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
@@ -207,8 +207,8 @@ QUnit.test('basic rendering: sidebar', async function (assert) {
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll(`.o_Discuss_sidebar .o_DiscussSidebar_separator`).length,
|
||||
1,
|
||||
"should have separator (between mailboxes and channels, but that's not tested)"
|
||||
2,
|
||||
"should have 2 separators (separating 'Start a meeting' button, mailboxes and channels, but that's not tested)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel`).length,
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
|
||||
<t t-name="mail.DiscussSidebar" owl="1">
|
||||
<div name="root" class="o_DiscussSidebar">
|
||||
<Popover position="'right'">
|
||||
<div class="d-flex justify-content-center">
|
||||
<button class="btn btn-primary rounded" title="Start a meeting" t-on-click="discuss.onClickStartAMeetingButton">
|
||||
Start a meeting
|
||||
</button>
|
||||
</div>
|
||||
<t t-set="opened">
|
||||
<ChannelInvitationForm t-if="discuss.channelInvitationForm" localId="discuss.channelInvitationForm.localId"/>
|
||||
</t>
|
||||
</Popover>
|
||||
<hr class="o_DiscussSidebar_separator"/>
|
||||
<div class="o_DiscussSidebar_category o_DiscussSidebar_categoryMailbox">
|
||||
<DiscussSidebarMailbox threadLocalId="messaging.inbox.localId"/>
|
||||
<DiscussSidebarMailbox threadLocalId="messaging.starred.localId"/>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerMessagingComponent } from '@mail/utils/messaging_component';
|
||||
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model/use_ref_to_model';
|
||||
|
||||
export class MediaPreview extends owl.Component {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
useRefToModel({ fieldName: 'audioRef', modelName: 'mail.media_preview', propNameAsRecordLocalId: 'localId', refName: 'audio' });
|
||||
useRefToModel({ fieldName: 'videoRef', modelName: 'mail.media_preview', propNameAsRecordLocalId: 'localId', refName: 'video' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {mail.media_preview}
|
||||
*/
|
||||
get mediaPreview() {
|
||||
return this.messaging && this.messaging.models['mail.media_preview'].get(this.props.localId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(MediaPreview, {
|
||||
props: { localId: String },
|
||||
template: 'mail.MediaPreview',
|
||||
});
|
||||
|
||||
registerMessagingComponent(MediaPreview);
|
||||
@@ -0,0 +1,18 @@
|
||||
.o_MediaPreview {
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.o_MediaPreview_mediaDevicesStatus {
|
||||
@include o-position-absolute($bottom: 50%);
|
||||
}
|
||||
|
||||
.o_MediaPreview_buttonsContainer {
|
||||
@include o-position-absolute($bottom: 0);
|
||||
}
|
||||
|
||||
.o_MediaPreview_button {
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
font-size: 1.5em;
|
||||
line-height: 4rem;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.MediaPreview" owl="1">
|
||||
<div class="o_MediaPreview position-relative d-flex justify-content-center">
|
||||
<video class="o_MediaPreview_videoDisplay shadow rounded bg-dark" height="480" width="640" autoplay="" t-ref="video"/>
|
||||
<p t-if="mediaPreview.doesBrowserSupportMediaDevices and !mediaPreview.isVideoEnabled" class="o_MediaPreview_mediaDevicesStatus position-absolute text-light">
|
||||
Camera is off
|
||||
</p>
|
||||
<p t-if="!mediaPreview.doesBrowserSupportMediaDevices" class="o_MediaPreview_mediaDevicesStatus text-light">
|
||||
Your browser does not support videoconference
|
||||
</p>
|
||||
<div class="o_MediaPreview_buttonsContainer">
|
||||
<button t-if="!mediaPreview.isMicrophoneEnabled" class="o_MediaPreview_enableMicrophoneButton o_MediaPreview_button btn btn-danger btn-lg rounded-circle p-0 m-3 fa fa-microphone-slash" t-on-click="mediaPreview.onClickEnableMicrophoneButton"/>
|
||||
<button t-if="mediaPreview.isMicrophoneEnabled" class="o_MediaPreview_disableMicrophoneButton o_MediaPreview_button btn btn-dark btn-lg p-0 m-3 rounded-circle border-light fa fa-microphone" t-on-click="mediaPreview.onClickDisableMicrophoneButton"/>
|
||||
<button t-if="!mediaPreview.isVideoEnabled" class="o_MediaPreview_enableVideoButton o_MediaPreview_button btn btn-danger btn-lg p-0 m-3 rounded-circle fa fa-eye-slash" t-on-click="mediaPreview.onClickEnableVideoButton"/>
|
||||
<button t-if="mediaPreview.isVideoEnabled" class="o_MediaPreview_disableVideoButton o_MediaPreview_button btn btn-dark btn-lg p-0 m-3 rounded-circle border-light fa fa-video-camera" t-on-click="mediaPreview.onClickDisableVideoButton"/>
|
||||
</div>
|
||||
<audio class="o_MediaPreview_audioPlayer" autoplay="" t-ref="audio"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
+3
-3
@@ -5,7 +5,7 @@
|
||||
<div class="o_RtcCallParticipantCard"
|
||||
t-att-class="{
|
||||
'o-is-talking': callParticipantCard and !callParticipantCard.isMinimized and callParticipantCard.isTalking,
|
||||
'o-is-invitation': callParticipantCard and !!callParticipantCard.invitedPartner,
|
||||
'o-is-invitation': callParticipantCard and callParticipantCard.isInvitation,
|
||||
}"
|
||||
t-on-contextmenu="_onContextMenu"
|
||||
>
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="o_RtcCallParticipantCard_container"
|
||||
t-att-class="{
|
||||
'o-minimized': callParticipantCard.isMinimized,
|
||||
'o-can-click': !!callParticipantCard.invitedPartner,
|
||||
'o-can-click': !callParticipantCard.isInvitation,
|
||||
}"
|
||||
t-att-title="callParticipantCard.name"
|
||||
t-att-aria-label="callParticipantCard.name"
|
||||
@@ -28,7 +28,7 @@
|
||||
<img alt="Avatar"
|
||||
t-att-class="{
|
||||
'o-is-talking': callParticipantCard.isTalking,
|
||||
'o-is-invitation': !!callParticipantCard.invitedPartner,
|
||||
'o-is-invitation': callParticipantCard.isInvitation,
|
||||
}"
|
||||
class="o_RtcCallParticipantCard_avatar_image rounded-circle"
|
||||
t-att-src="callParticipantCard.avatarUrl"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerMessagingComponent } from '@mail/utils/messaging_component';
|
||||
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model/use_ref_to_model';
|
||||
import { useUpdateToModel } from '@mail/component_hooks/use_update_to_model/use_update_to_model';
|
||||
|
||||
export class WelcomeView extends owl.Component {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
useRefToModel({ fieldName: 'guestNameInputRef', modelName: 'mail.welcome_view', propNameAsRecordLocalId: 'localId', refName: 'guestNameInput' });
|
||||
useUpdateToModel({ methodName: 'onComponentUpdate', modelName: 'mail.welcome_view', propNameAsRecordLocalId: 'localId' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {mail.welcome_view}
|
||||
*/
|
||||
get welcomeView() {
|
||||
return this.messaging && this.messaging.models['mail.welcome_view'].get(this.props.localId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(WelcomeView, {
|
||||
props: { localId: String },
|
||||
template: 'mail.WelcomeView',
|
||||
});
|
||||
|
||||
registerMessagingComponent(WelcomeView);
|
||||
@@ -0,0 +1,14 @@
|
||||
.o_WelcomeView_joinButton {
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
font-size: 2em;
|
||||
line-height: 4rem;
|
||||
}
|
||||
|
||||
.o_WelcomeView_guestNameInputLabel {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.o_WelcomeView_loggedAsStatus {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.WelcomeView" owl="1">
|
||||
<div class="o_WelcomeView h-100 d-flex flex-column justify-content-center align-items-center bg-light">
|
||||
<h1 class="font-weight-light">You've been invited to a meeting!</h1>
|
||||
<h2 class="m-5" t-esc="messaging.companyName"/>
|
||||
<div class="d-flex justify-content-center">
|
||||
<MediaPreview class="mr-5" localId="welcomeView.mediaPreview.localId"/>
|
||||
<div class="d-flex flex-column justify-content-center">
|
||||
<t t-if="messaging.currentGuest">
|
||||
<label class="o_WelcomeView_guestNameInputLabel text-center" t-att-for="welcomeView.guestNameInputUniqueId">What's your name?</label>
|
||||
<input class="form-control mb-3" type="text" placeholder="Your name" t-att-name="welcomeView.guestNameInputUniqueId" t-att-id="welcomeView.guestNameInputUniqueId" t-att-value="welcomeView.pendingGuestName" t-ref="guestNameInput" t-on-input="welcomeView.onInputGuestNameInput" t-on-keydown="welcomeView.onKeydownGuestNameInput"/>
|
||||
</t>
|
||||
<t t-if="messaging.currentUser">
|
||||
<p class="o_WelcomeView_loggedAsStatus">Logged as <span t-esc="messaging.currentUser.nameOrDisplayName"/></p>
|
||||
</t>
|
||||
<button class="o_WelcomeView_joinButton btn btn-success btn-lg align-self-end p-0 rounded-circle shadow fa fa-phone" title="Join Channel" t-att-disabled="welcomeView.isJoinButtonDisabled" t-on-click="welcomeView.onClickJoinButton"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -5,13 +5,13 @@ import { MessagingService } from '@mail/services/messaging/messaging';
|
||||
import { getMessagingComponent } from '@mail/utils/messaging_component';
|
||||
|
||||
import { processTemplates } from '@web/core/assets';
|
||||
import { registry } from "@web/core/registry";
|
||||
import { registry } from '@web/core/registry';
|
||||
import { makeEnv, startServices } from '@web/env';
|
||||
import { session } from '@web/session';
|
||||
|
||||
import * as AbstractService from "web.AbstractService";
|
||||
import * as AbstractService from 'web.AbstractService';
|
||||
import { serviceRegistry as legacyServiceRegistry } from 'web.core';
|
||||
import * as legacyEnv from "web.env";
|
||||
import * as legacyEnv from 'web.env';
|
||||
import {
|
||||
makeLegacyCrashManagerService,
|
||||
makeLegacyDialogMappingService,
|
||||
@@ -19,21 +19,21 @@ import {
|
||||
makeLegacyRpcService,
|
||||
makeLegacySessionService,
|
||||
mapLegacyEnvToWowlEnv,
|
||||
} from "@web/legacy/utils";
|
||||
import * as legacySession from "web.session";
|
||||
} from '@web/legacy/utils';
|
||||
import * as legacySession from 'web.session';
|
||||
|
||||
owl.Component.env = legacyEnv;
|
||||
|
||||
export async function boot() {
|
||||
await owl.utils.whenReady();
|
||||
owl.config.mode = owl.Component.env.isDebug() ? "dev" : "prod";
|
||||
owl.config.mode = owl.Component.env.isDebug() ? 'dev' : 'prod';
|
||||
AbstractService.prototype.deployServices(owl.Component.env);
|
||||
const serviceRegistry = registry.category("services");
|
||||
serviceRegistry.add("legacy_rpc", makeLegacyRpcService(owl.Component.env));
|
||||
serviceRegistry.add("legacy_session", makeLegacySessionService(owl.Component.env, legacySession));
|
||||
serviceRegistry.add("legacy_notification", makeLegacyNotificationService(owl.Component.env));
|
||||
serviceRegistry.add("legacy_crash_manager", makeLegacyCrashManagerService(owl.Component.env));
|
||||
serviceRegistry.add("legacy_dialog_mapping", makeLegacyDialogMappingService(owl.Component.env));
|
||||
const serviceRegistry = registry.category('services');
|
||||
serviceRegistry.add('legacy_rpc', makeLegacyRpcService(owl.Component.env));
|
||||
serviceRegistry.add('legacy_session', makeLegacySessionService(owl.Component.env, legacySession));
|
||||
serviceRegistry.add('legacy_notification', makeLegacyNotificationService(owl.Component.env));
|
||||
serviceRegistry.add('legacy_crash_manager', makeLegacyCrashManagerService(owl.Component.env));
|
||||
serviceRegistry.add('legacy_dialog_mapping', makeLegacyDialogMappingService(owl.Component.env));
|
||||
await legacySession.is_bound;
|
||||
owl.Component.env.qweb.addTemplates(legacySession.owlTemplates);
|
||||
Object.assign(odoo, {
|
||||
@@ -76,7 +76,25 @@ export async function boot() {
|
||||
threadViewLocalId: threadViewer.threadView.localId,
|
||||
});
|
||||
await threadViewComponent.mount(document.body);
|
||||
if (threadViewer.thread.defaultDisplayMode === 'video_full_screen') {
|
||||
await threadViewer.thread.toggleCall({ startWithVideo: true });
|
||||
// TODO full screen not possible until the user actually makes an action on the page
|
||||
}
|
||||
}
|
||||
|
||||
return { createThreadViewFromChannelData };
|
||||
async function createWelcomeView(channelId) {
|
||||
const messaging = await owl.Component.env.services.messaging.get();
|
||||
const WelcomeView = getMessagingComponent('WelcomeView');
|
||||
const welcomeView = messaging.models['mail.welcome_view'].create({
|
||||
channelId,
|
||||
isDoFocusGuestNameInput: true,
|
||||
pendingGuestName: messaging.currentGuest && messaging.currentGuest.name,
|
||||
});
|
||||
const welcomeViewComponent = new WelcomeView(null, {
|
||||
localId: welcomeView.localId,
|
||||
});
|
||||
await welcomeViewComponent.mount(document.body);
|
||||
}
|
||||
|
||||
return { createThreadViewFromChannelData, createWelcomeView };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user