[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
+31 -12
View File
@@ -23,21 +23,21 @@ class DiscussController(http.Controller):
channel_sudo = request.env['mail.channel'].browse(int(channel_id)).sudo().exists()
if not channel_sudo or not channel_sudo.uuid or not consteq(channel_sudo.uuid, invitation_token):
raise NotFound()
response = request.redirect(f'/discuss/channel/{channel_sudo.id}')
if request.env['mail.channel.partner']._get_as_sudo_from_request(request=request, channel_id=int(channel_id)):
return response
if channel_sudo.env['mail.channel.partner']._get_as_sudo_from_request(request=request, channel_id=int(channel_id)):
return request.redirect(f'/discuss/channel/{channel_sudo.id}')
if channel_sudo.channel_type == 'chat':
raise NotFound()
if request.session.uid:
channel_sudo.add_members([request.env.user.partner_id.id])
response = request.redirect(f'/discuss/channel/{channel_sudo.id}/welcome')
if not channel_sudo.env.user._is_public():
channel_sudo.add_members([channel_sudo.env.user.partner_id.id])
return response
guest = request.env['mail.guest']._get_guest_from_request(request)
guest = channel_sudo.env['mail.guest']._get_guest_from_request(request)
if not guest:
guest = request.env['mail.guest'].sudo().create({
'country_id': request.env['res.country'].sudo().search([('code', '=', request.session.get('geoip', {}).get('country_code'))], limit=1).id,
'lang': get_lang(request.env).code,
guest = channel_sudo.env['mail.guest'].create({
'country_id': channel_sudo.env['res.country'].search([('code', '=', request.session.get('geoip', {}).get('country_code'))], limit=1).id,
'lang': get_lang(channel_sudo.env).code,
'name': _("Guest"),
'timezone': request.env['mail.guest']._get_timezone_from_request(request),
'timezone': channel_sudo.env['mail.guest']._get_timezone_from_request(request),
})
# Discuss Guest ID: every route in this file will make use of it to authenticate
# the guest through `_get_as_sudo_from_request` or `_get_as_sudo_from_request_or_raise`.
@@ -54,6 +54,14 @@ class DiscussController(http.Controller):
'session_info': channel_partner_sudo.env['ir.http'].session_info(),
})
@http.route('/discuss/channel/<int:channel_id>/welcome', methods=['GET'], type='http', auth='public')
def discuss_channel_welcome(self, channel_id):
channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
return request.render('mail.discuss_public_welcome_template', {
'channel_sudo': channel_partner_sudo.channel_id,
'session_info': channel_partner_sudo.env['ir.http'].session_info(),
})
# --------------------------------------------------------------------------
# Semi-Static Content (GET requests with possible cache)
# --------------------------------------------------------------------------
@@ -92,7 +100,7 @@ class DiscussController(http.Controller):
@http.route('/mail/init_messaging', methods=['POST'], type='json', auth='public')
def mail_init_messaging(self, **kwargs):
if request.session.uid:
if not request.env.user.sudo()._is_public():
return request.env.user._init_messaging()
guest = request.env['mail.guest']._get_guest_from_request(request)
if guest:
@@ -304,7 +312,7 @@ class DiscussController(http.Controller):
@http.route('/mail/rtc/session/notify_call_members', methods=['POST'], type="json", auth="public")
def session_call_notify(self, sender_session_id, target_session_ids=None, content=None):
""" Sends content to other session of the same channel, only works of the user is the user of that session.
""" Sends content to other session of the same channel, only works if the user is the user of that session.
This is used to send peer to peer information between sessions.
:param int sender_session_id: id of the session from which the content is sent
@@ -382,3 +390,14 @@ class DiscussController(http.Controller):
('Cache-Control', 'max-age=%s' % http.STATIC_CACHE),
]
)
# --------------------------------------------------------------------------
# Guest API
# --------------------------------------------------------------------------
@http.route('/mail/guest/update_name', methods=['POST'], type='json', auth='public')
def mail_guest_update_name(self, name):
guest = request.env['mail.guest']._get_guest_from_request(request)
if not guest:
raise NotFound()
guest.sudo()._update_name(name)
+1 -1
View File
@@ -18,7 +18,7 @@ class IrHttp(models.AbstractModel):
assets_discuss_public_hash = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle='mail.assets_discuss_public')
result['cache_hashes']['assets_discuss_public'] = assets_discuss_public_hash
guest = self.env.context.get('guest')
if guest:
if self.env.user._is_public() and guest:
user_context = {'lang': guest.lang}
mods = odoo.conf.server_wide_modules or []
lang = user_context.get("lang")
+19 -13
View File
@@ -58,6 +58,7 @@ class Channel(models.Model):
('group', 'Group')],
string='Channel Type', default='channel', help="Chat is private and unique between 2 persons. Group is private among invited persons. Channel can be freely joined (depending on its configuration).")
is_chat = fields.Boolean(string='Is a chat', compute='_compute_is_chat')
default_display_mode = fields.Selection(string="Default Display Mode", selection=[('video_full_screen', "Full screen video")], help="Determines how the channel will be displayed by default when opening it from its invitation link. No value means display text (no voice/video).")
description = fields.Text('Description')
image_128 = fields.Image("Image", max_width=128, max_height=128)
avatar_128 = fields.Image("Avatar", max_width=128, max_height=128, compute='_compute_avatar_128')
@@ -326,7 +327,7 @@ class Channel(models.Model):
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', channel_partner.partner_id.id), {
'type': 'mail.channel_joined',
'payload': {
'channel': channel_partner.channel_id.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).channel_info()[0],
'channel': channel_partner.channel_id.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).sudo().channel_info()[0],
'invited_by_user_id': self.env.user.id,
},
})
@@ -427,15 +428,19 @@ class Channel(models.Model):
"""
self.ensure_one()
guest = self.env.context.get('guest')
partner = None if guest else self.env.user.partner_id
if self.env.user._is_public():
guest = self.env.context.get('guest')
partner = self.env['res.partner']
else:
guest = self.env['mail.guest']
partner = self.env.user.partner_id
def is_current_user(r):
return (guest and r.guest_id == guest) or r.partner_id == partner
current_rtc_session = self.rtc_session_ids.filtered(is_current_user)
if not current_rtc_session:
return
return [], []
current_sessions_channel_partners = self.rtc_session_ids.channel_partner_id
notifications = []
@@ -504,15 +509,14 @@ class Channel(models.Model):
the channel if necessary.
:param bool joining : true if joining the call, false if leaving.
"""
guest = self.env.context.get('guest')
partner = None if guest else self.env.user.partner_id
new_session_id = None
if partner:
domain = [('partner_id', '=', partner.id)]
if self.env.user._is_public():
guest = self.env.context.get('guest')
partner = self.env['res.partner']
else:
domain = [('guest_id', '=', guest.id)]
current_channel_partner = self.env['mail.channel.partner'].search(domain, limit=1)
guest = self.env['mail.guest']
partner = self.env.user.partner_id
new_session_id = None
current_channel_partner = self.env['mail.channel.partner'].search([('channel_id', '=', self.id), ('partner_id', '=', partner.id), ('guest_id', '=', guest.id)], limit=1)
if not current_channel_partner:
return
current_channel_partner._remove_rtc_invitation()
@@ -788,6 +792,7 @@ class Channel(models.Model):
'avatarCacheKey': channel._get_avatar_cache_key(),
'id': channel.id,
'name': channel.name,
'defaultDisplayMode': channel.default_display_mode,
'description': channel.description,
'uuid': channel.uuid,
'state': 'open',
@@ -1126,7 +1131,7 @@ class Channel(models.Model):
return channel_info
@api.model
def create_group(self, partners_to):
def create_group(self, partners_to, default_display_mode=False):
""" Create a group channel.
:param partners_to : list of res.partner ids to add to the conversation
:returns: channel_info of the created channel
@@ -1135,6 +1140,7 @@ class Channel(models.Model):
channel = self.create({
'channel_last_seen_partner_ids': [Command.create({'partner_id': partner_id}) for partner_id in partners_to],
'channel_type': 'group',
'default_display_mode': default_display_mode,
'name': '', # default name is computed client side from the list of members
'public': 'private',
})
+20 -1
View File
@@ -5,8 +5,9 @@ import pytz
import uuid
from odoo.tools import consteq
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.addons.base.models.res_partner import _tz_get
from odoo.exceptions import UserError
class MailGuest(models.Model):
@@ -48,6 +49,23 @@ class MailGuest(models.Model):
timezone = request.httprequest.cookies.get('tz')
return timezone if timezone in pytz.all_timezones else False
def _update_name(self, name):
self.ensure_one()
name = name.strip()
if name == "":
raise UserError(_("Guest's name cannot be empty."))
self.name = name
self.env['bus.bus'].sendmany([(
(self._cr.dbname, 'mail.channel', channel.id),
{
'type': 'mail.guest_update',
'payload': {
'id': self.id,
'name': self.name,
},
}
) for channel in self.channel_ids])
def _update_timezone(self, timezone):
query = """
UPDATE mail_guest
@@ -64,6 +82,7 @@ class MailGuest(models.Model):
partner_root = self.env.ref('base.partner_root')
return {
'channels': self.channel_ids.channel_info(),
'companyName': self.env.company.name,
'currentGuest': {
'id': self.id,
'name': self.name,
+1 -1
View File
@@ -1802,7 +1802,7 @@ class MailThread(models.AbstractModel):
record_name = record_name or self.display_name
# Find the message's author
if 'guest' in self.env.context:
if self.env.user._is_public() and 'guest' in self.env.context:
author_guest_id = self.env.context['guest'].id
author_id, email_from = False, False
else:
+1
View File
@@ -94,6 +94,7 @@ class Users(models.Model):
partner_root = self.env.ref('base.partner_root')
values = {
'channels': self.partner_id._get_channels_as_member().channel_info(),
'companyName': self.env.company.name,
'currentGuest': False,
'current_partner': self.partner_id.mail_partner_format().get(self.partner_id),
'current_user_id': self.id,
@@ -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>
@@ -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 });
}
/**
@@ -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 };
}
+16 -1
View File
@@ -33,7 +33,7 @@
</html>
</template>
<template id="mail.discuss_public_channel_template" name="Discuss Channel Template">
<template id="mail.discuss_public_channel_template" name="Discuss Public Channel Template">
<t t-call="mail.discuss_public_layout">
<t t-set="head">
<script>
@@ -47,4 +47,19 @@
</t>
</t>
</template>
<template id="mail.discuss_public_welcome_template" name="Discuss Public Welcome Template">
<t t-call="mail.discuss_public_layout">
<t t-set="head">
<script>
odoo.define('mail.discuss_public_boot_welcome_view', async function(require) {
const { boot } = require('@mail/public/discuss_public_boot');
const { createWelcomeView } = await boot();
const channelId = <t t-out="channel_sudo.id"/>;
createWelcomeView(channelId);
});
</script>
</t>
</t>
</template>
</odoo>
@@ -94,11 +94,12 @@ class TestDiscussFullPerformance(TransactionCase):
message = channel_channel_public_1.message_post(body='test', message_type='comment', author_id=self.users[2].partner_id.id, partner_ids=self.users[0].partner_id.ids)
# add star
message.toggle_message_starred()
self.env.company.sudo().name = 'YourCompany'
self.maxDiff = None
self.users[0].flush()
self.users[0].invalidate_cache()
with self.assertQueryCount(emp=50):
with self.assertQueryCount(emp=90):
init_messaging = self.users[0].with_user(self.users[0])._init_messaging()
self.assertEqual(init_messaging, {
@@ -110,6 +111,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': user_root.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': 'General announcements for all employees.',
'group_based_subscription': True,
'id': channel_general.id,
@@ -131,6 +133,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_channel_public_1.id,
@@ -152,6 +155,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_channel_public_2.id,
@@ -173,6 +177,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_channel_group_1.id,
@@ -194,6 +199,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_channel_group_2.id,
@@ -215,6 +221,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_channel_private_1.id,
@@ -236,6 +243,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_channel_private_2.id,
@@ -257,6 +265,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'group',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_group_1.id,
@@ -316,6 +325,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_chat_1.id,
@@ -375,6 +385,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_chat_2.id,
@@ -434,6 +445,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_chat_3.id,
@@ -493,6 +505,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_chat_4.id,
@@ -552,6 +565,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'livechat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_livechat_1.id,
@@ -617,6 +631,7 @@ class TestDiscussFullPerformance(TransactionCase):
'channel_type': 'livechat',
'create_uid': self.env.ref('base.public_user').id,
'custom_channel_name': False,
'defaultDisplayMode': False,
'description': False,
'group_based_subscription': False,
'id': channel_livechat_2.id,
@@ -678,6 +693,7 @@ class TestDiscussFullPerformance(TransactionCase):
'uuid': channel_livechat_2.uuid,
},
],
'companyName': 'YourCompany',
'mail_failures': [],
'shortcodes': [
{