From 401fc7efe90fb99ebeef33db5d44d8b97be94ed5 Mon Sep 17 00:00:00 2001 From: "Louis Wicket (wil)" Date: Fri, 3 Sep 2021 16:27:35 +0000 Subject: [PATCH] [IMP] discuss: add 'Start a meeting' feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- addons/mail/controllers/discuss.py | 43 ++-- addons/mail/models/ir_http.py | 2 +- addons/mail/models/mail_channel.py | 32 +-- addons/mail/models/mail_guest.py | 21 +- addons/mail/models/mail_thread.py | 2 +- addons/mail/models/res_users.py | 1 + .../components/discuss/tests/discuss_tests.js | 4 +- .../discuss_sidebar/discuss_sidebar.xml | 11 + .../components/media_preview/media_preview.js | 30 +++ .../media_preview/media_preview.scss | 18 ++ .../media_preview/media_preview.xml | 23 ++ .../rtc_call_participant_card.xml | 6 +- .../components/welcome_view/welcome_view.js | 32 +++ .../components/welcome_view/welcome_view.scss | 14 ++ .../components/welcome_view/welcome_view.xml | 24 +++ .../channel_invitation_form.js | 20 +- .../mail/static/src/models/discuss/discuss.js | 73 ++++++- .../src/models/media_preview/media_preview.js | 196 ++++++++++++++++++ .../static/src/models/messaging/messaging.js | 1 + .../messaging_initializer.js | 3 + .../messaging_notification_handler.js | 2 + .../rtc_call_participant_card.js | 15 ++ .../mail/static/src/models/thread/thread.js | 9 + .../src/models/welcome_view/welcome_view.js | 151 ++++++++++++++ .../static/src/public/discuss_public_boot.js | 44 ++-- .../mail/views/discuss_public_templates.xml | 17 +- .../tests/test_performance.py | 18 +- 27 files changed, 761 insertions(+), 51 deletions(-) create mode 100644 addons/mail/static/src/components/media_preview/media_preview.js create mode 100644 addons/mail/static/src/components/media_preview/media_preview.scss create mode 100644 addons/mail/static/src/components/media_preview/media_preview.xml create mode 100644 addons/mail/static/src/components/welcome_view/welcome_view.js create mode 100644 addons/mail/static/src/components/welcome_view/welcome_view.scss create mode 100644 addons/mail/static/src/components/welcome_view/welcome_view.xml create mode 100644 addons/mail/static/src/models/media_preview/media_preview.js create mode 100644 addons/mail/static/src/models/welcome_view/welcome_view.js diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index c8c971350f0..5e0872b694c 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -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//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) diff --git a/addons/mail/models/ir_http.py b/addons/mail/models/ir_http.py index 1acb2498de6..893533f96e7 100644 --- a/addons/mail/models/ir_http.py +++ b/addons/mail/models/ir_http.py @@ -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") diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index aa0f362cfb4..858ae8d14f3 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -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', }) diff --git a/addons/mail/models/mail_guest.py b/addons/mail/models/mail_guest.py index bd0c529c105..c6d65c3dc39 100644 --- a/addons/mail/models/mail_guest.py +++ b/addons/mail/models/mail_guest.py @@ -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, diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 7e61da2a473..c8ae28c4ad4 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -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: diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py index 98c68a2c293..46f6080630b 100644 --- a/addons/mail/models/res_users.py +++ b/addons/mail/models/res_users.py @@ -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, diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 01630fa65ef..163a5c56cf9 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -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, diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml index 1b3830cee8a..5eeb7bdc25e 100644 --- a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml @@ -3,6 +3,17 @@
+ +
+ +
+ + + +
+
diff --git a/addons/mail/static/src/components/media_preview/media_preview.js b/addons/mail/static/src/components/media_preview/media_preview.js new file mode 100644 index 00000000000..80e09242b96 --- /dev/null +++ b/addons/mail/static/src/components/media_preview/media_preview.js @@ -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); diff --git a/addons/mail/static/src/components/media_preview/media_preview.scss b/addons/mail/static/src/components/media_preview/media_preview.scss new file mode 100644 index 00000000000..dfa15f334a7 --- /dev/null +++ b/addons/mail/static/src/components/media_preview/media_preview.scss @@ -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; +} diff --git a/addons/mail/static/src/components/media_preview/media_preview.xml b/addons/mail/static/src/components/media_preview/media_preview.xml new file mode 100644 index 00000000000..0a008e27eb6 --- /dev/null +++ b/addons/mail/static/src/components/media_preview/media_preview.xml @@ -0,0 +1,23 @@ + + + + +
+
+
+ +
diff --git a/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.xml b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.xml index 6660df2c00e..82de94ad20e 100644 --- a/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.xml +++ b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.xml @@ -5,7 +5,7 @@
@@ -13,7 +13,7 @@
+ + + +
+

You've been invited to a meeting!

+

+
+ +
+ + + + + +

Logged as

+
+
+
+

+
+ +
diff --git a/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js b/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js index 4baa90bf910..aa02f4a3244 100644 --- a/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js +++ b/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js @@ -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). diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js index a6e010a3f60..a383cbd55e3 100644 --- a/addons/mail/static/src/models/discuss/discuss.js +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -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 diff --git a/addons/mail/static/src/models/media_preview/media_preview.js b/addons/mail/static/src/models/media_preview/media_preview.js new file mode 100644 index 00000000000..19840346274 --- /dev/null +++ b/addons/mail/static/src/models/media_preview/media_preview.js @@ -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); diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js index 2084c43fe71..608ade3db36 100644 --- a/addons/mail/static/src/models/messaging/messaging.js +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -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'), diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js index e9fb268370e..33580b47f5e 100644 --- a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js +++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js @@ -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 }); } /** diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index 2cbc66f7c32..21260432dcc 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -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': diff --git a/addons/mail/static/src/models/rtc_call_participant_card/rtc_call_participant_card.js b/addons/mail/static/src/models/rtc_call_participant_card/rtc_call_participant_card.js index 61318b6970a..e8e2dbcf77d 100644 --- a/addons/mail/static/src/models/rtc_call_participant_card/rtc_call_participant_card.js +++ b/addons/mail/static/src/models/rtc_call_participant_card/rtc_call_participant_card.js @@ -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. */ diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 86ab0df5964..e5789ee9079 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -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. */ diff --git a/addons/mail/static/src/models/welcome_view/welcome_view.js b/addons/mail/static/src/models/welcome_view/welcome_view.js new file mode 100644 index 00000000000..1b1f4620c85 --- /dev/null +++ b/addons/mail/static/src/models/welcome_view/welcome_view.js @@ -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); diff --git a/addons/mail/static/src/public/discuss_public_boot.js b/addons/mail/static/src/public/discuss_public_boot.js index d48e5853386..a39574a4c9c 100644 --- a/addons/mail/static/src/public/discuss_public_boot.js +++ b/addons/mail/static/src/public/discuss_public_boot.js @@ -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 }; } diff --git a/addons/mail/views/discuss_public_templates.xml b/addons/mail/views/discuss_public_templates.xml index ff867fd698a..80f5a328dc0 100644 --- a/addons/mail/views/discuss_public_templates.xml +++ b/addons/mail/views/discuss_public_templates.xml @@ -33,7 +33,7 @@ -