diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 08583e60bc5..fe5779b668f 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -23,7 +23,9 @@ 'views/mail_message_views.xml', 'views/mail_mail_views.xml', 'views/mail_followers_views.xml', + 'views/mail_ice_server_views.xml', 'views/mail_channel_partner_views.xml', + 'views/mail_channel_rtc_session_views.xml', 'views/mail_channel_views.xml', 'views/mail_shortcode_views.xml', 'views/mail_activity_views.xml', diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index 7492c2c96df..c8c971350f0 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from odoo import http from odoo.http import request -from odoo.tools import consteq +from odoo.tools import consteq, file_open from odoo.tools.misc import get_lang from odoo.tools.translate import _ from werkzeug.exceptions import NotFound @@ -297,3 +297,88 @@ class DiscussController(http.Controller): except Exception: return {} return records._message_get_suggested_recipients() + + # -------------------------------------------------------------------------- + # RTC API TODO move check logic in routes. + # -------------------------------------------------------------------------- + + @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. + 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 + :param list target_session_ids: list of the ids of the sessions that should receive the content + :param content: the content to send to the other sessions + """ + if request.env.user._is_public(): + guest = request.env['mail.guest']._get_guest_from_request(request) + if guest: + session = guest.env['mail.channel.rtc.session'].sudo().browse(int(sender_session_id)) + if session.exists() and session.guest_id == guest: + session._notify_peers(target_session_ids, content) + return + raise NotFound() + session = request.env['mail.channel.rtc.session'].sudo().browse(int(sender_session_id)) + if session.exists() and session.partner_id == request.env.user.partner_id: + session._notify_peers(target_session_ids, content) + + @http.route('/mail/rtc/session/update_and_broadcast', methods=['POST'], type="json", auth="public") + def session_update_and_broadcast(self, session_id, values): + """ Update a RTC session and broadcasts the changes to the members of its channel, + only works of the user is the user of that session. + :param int session_id: id of the session to update + :param dict values: write dict for the fields to update + """ + if request.env.user._is_public(): + guest = request.env['mail.guest']._get_guest_from_request(request) + if guest: + session = guest.env['mail.channel.rtc.session'].sudo().browse(int(session_id)).exists() + if session and session.guest_id == guest: + session._update_and_broadcast(values) + return + raise NotFound() + session = request.env['mail.channel.rtc.session'].sudo().browse(int(session_id)).exists() + if session and session.partner_id == request.env.user.partner_id: + session._update_and_broadcast(values) + + @http.route('/mail/rtc/channel/join_call', methods=['POST'], type="json", auth="public") + def channel_call_join(self, channel_id): + """ Joins the RTC call of a channel if the user is a member of that channel + :param int channel_id: id of the channel to join + """ + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + return channel_partner_sudo.channel_id._join_call() + + @http.route('/mail/rtc/channel/leave_call', methods=['POST'], type="json", auth="public") + def channel_call_leave(self, channel_id): + """ Disconnects the current user from a rtc call and clears any invitation sent to that user on this channel + :param int channel_id: id of the channel from which to disconnect + """ + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + channel_partner_sudo.channel_id._leave_call() + + @http.route('/mail/rtc/channel/cancel_call_invitation', methods=['POST'], type="json", auth="public") + def channel_call_cancel_invitation(self, channel_id, partner_ids=None, guest_ids=None): + """ Sends invitations to join the RTC call to all connected members of the thread who are not already invited. + :param list partner_ids: list of the partner ids to invite + :param list guest_ids: list of the guest ids to invite + + if either partner_ids or guest_ids is set, only the specified ids will be invited. + """ + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + channel_partner_sudo.channel_id._cancel_rtc_invitations(partner_ids=partner_ids, guest_ids=guest_ids, inviting_member=channel_partner_sudo) + + @http.route('/mail/rtc/audio_worklet_processor', methods=['GET'], type='http', auth='public') + def audio_worklet_processor(self): + """ Returns a JS file that declares a WorkletProcessor class in + a WorkletGlobalScope, which means that it cannot be added to the + bundles like other assets. + """ + return request.make_response( + file_open('mail/static/src/worklets/audio_processor.js', 'rb').read(), + headers=[ + ('Content-Type', 'application/javascript'), + ('Cache-Control', 'max-age=%s' % http.STATIC_CACHE), + ] + ) diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index 616d82f72f0..b2d16600dd2 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -28,10 +28,13 @@ from . import mail_template # discuss from . import mail_channel_partner +from . import mail_channel_rtc_session from . import mail_channel from . import mail_guest +from . import mail_ice_server from . import mail_shortcode from . import res_users_settings +from . import res_users_settings_volumes # odoo models from . import ir_action_act_window diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 73d44b1f5ba..aa0f362cfb4 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -69,6 +69,7 @@ class Channel(models.Model): channel_last_seen_partner_ids = fields.One2many( 'mail.channel.partner', 'channel_id', string='Last Seen', groups='base.group_user') + rtc_session_ids = fields.One2many('mail.channel.rtc.session', 'channel_id', groups="base.group_system") is_member = fields.Boolean('Is Member', compute='_compute_is_member', compute_sudo=True) member_count = fields.Integer(string="Member Count", compute='_compute_member_count', help="Excluding guests from count.") group_ids = fields.Many2many( @@ -288,7 +289,7 @@ class Channel(models.Model): }) return result - def add_members(self, partner_ids=None, guest_ids=None): + def add_members(self, partner_ids=None, guest_ids=None, invite_to_rtc_call=False): """ Adds the given partner_ids and guest_ids as member of self channels. """ self.check_access_rights('write') self.check_access_rule('write') @@ -316,6 +317,8 @@ class Channel(models.Model): 'channel_id': channel.id, } for partner in guests - existing_guests] new_members = self.env['mail.channel.partner'].sudo().create(members_to_create) + if invite_to_rtc_call: + self._invite_members_to_rtc(partner_ids=partner_ids, guest_ids=guest_ids) for channel_partner in new_members.filtered(lambda channel_partner: channel_partner.partner_id): user = channel_partner.partner_id.user_ids[0] if channel_partner.partner_id.user_ids else self.env['res.users'] # notify invited members through the bus @@ -337,16 +340,18 @@ class Channel(models.Model): new_partner_name=channel_partner.partner_id.name, ) channel_partner.channel_id.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment", notify_by_email=False) + new_partner_data = { + 'id': channel_partner.partner_id.id, + 'im_status': channel_partner.partner_id.im_status, + 'name': channel_partner.partner_id.name, + } self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', channel_partner.channel_id.id), { 'type': 'mail.channel_update', 'payload': { 'id': channel_partner.channel_id.id, 'memberCount': channel_partner.channel_id.member_count, - 'members': [('insert', { - 'id': channel_partner.partner_id.id, - 'im_status': channel_partner.partner_id.im_status, - 'name': channel_partner.partner_id.name, - })], + 'members': [('insert', new_partner_data)], + 'invitedPartners': [('insert', [{'id': new_partner_data['id']}] if invite_to_rtc_call else [])] }, }) @@ -379,6 +384,167 @@ class Channel(models.Model): return False return True + # ------------------------------------------------------------ + # RTC + # ------------------------------------------------------------ + + def _cancel_rtc_invitations(self, partner_ids=None, guest_ids=None, inviting_member=None): + """ Cancels the invitations of the RTC call from all invited members (or the specified partner_ids). + :param list partner_ids: list of the partner ids from which the invitation has to be removed + :param list guest_ids: list of the guest ids from which the invitation has to be removed + if either partner_ids or guest_ids is set, only the specified ids will be invited. + :param inviting_member: if specified, only removes invitations if they are coming + from this channel member + """ + self.ensure_one() + notifications = [] + domain = ['&', ('rtc_inviting_session_id', '!=', False), ('channel_id', '=', self.id)] + if inviting_member: + domain = expression.AND([domain, [('rtc_inviting_session_id.channel_partner_id', '=', inviting_member.id)]]) + if partner_ids or guest_ids: + domain = expression.AND([domain, ['|', ('partner_id', 'in', partner_ids or []), ('guest_id', 'in', guest_ids or [])]]) + channel_partners = self.env['mail.channel.partner'].search(domain) + for member in channel_partners: + member.write({'rtc_inviting_session_id': False}) + model, record_id = ('mail.guest', member.guest_id.id) if member.guest_id else ('res.partner', member.partner_id.id) + notifications.append([ + (self._cr.dbname, model, record_id), + { + 'type': 'rtc_incoming_invitation_update', + 'payload': { + 'channelId': self.id, + }, + }, + ]) + self.env['bus.bus'].sendmany(notifications) + + def _invite_members_to_rtc(self, partner_ids=None, guest_ids=None): + """ Sends invitations to join the RTC call to all connected members of the thread who are not already invited. + :param list partner_ids: list of the partner ids to invite + :param list guest_ids: list of the guest ids to invite + + if either partner_ids or guest_ids is set, only the specified ids will be invited. + """ + self.ensure_one() + + guest = self.env.context.get('guest') + partner = None if guest else 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 + + current_sessions_channel_partners = self.rtc_session_ids.channel_partner_id + notifications = [] + invited_partners = [] + invited_guests = [] + domain = ['&', '&', ('rtc_inviting_session_id', '=', False), ('channel_id', '=', self.id), ('id', 'not in', current_sessions_channel_partners.ids)] + if partner: + domain = expression.AND([domain, [('partner_id', '!=', partner.id)]]) + if guest: + domain = expression.AND([domain, [('guest_id', '!=', guest.id)]]) + if partner_ids or guest_ids: + domain = expression.AND([domain, ['|', ('partner_id', 'in', partner_ids or []), ('guest_id', 'in', guest_ids or [])]]) + channel_partners = self.env['mail.channel.partner'].search(domain) + for member in channel_partners: + member.rtc_inviting_session_id = current_rtc_session.id + if member.partner_id: + invited_partners.append({ + 'id': member.partner_id.id, + 'name': member.partner_id.name, + }) + elif member.guest_id: + invited_guests.append({ + 'id': member.guest_id.id, + 'name': member.guest_id.name, + }) + model, record_id = ('mail.guest', member.guest_id.id) if member.guest_id else ('res.partner', member.partner_id.id) + notifications.append([ + (self._cr.dbname, model, record_id), + { + 'type': 'rtc_incoming_invitation_update', + 'payload': { + 'channelId': self.id, + 'rtcSession': current_rtc_session._mail_rtc_session_format(), + }, + }, + ]) + notification = _('%s started a live conference', partner.name) + self.message_post(body=notification, message_type="notification") + self.env['bus.bus'].sendmany(notifications) + return invited_partners, invited_guests + + def _join_call(self): + self.ensure_one() + session_data, session_id = self._update_call_participation(joining=True) + if not session_id: + return + ice_servers = self.env['mail.ice.server']._get_ice_servers() + invited_partners = [] + invited_guests = [] + if len(self.rtc_session_ids) == 1 and self.channel_type in {'chat', 'group'}: + invited_partners, invited_guests = self._invite_members_to_rtc() + return { + 'rtcSessions': session_data, + 'iceServers': ice_servers or False, + 'invitedGuests': invited_guests, + 'invitedPartners': invited_partners, + 'sessionId': session_id, + } + + def _leave_call(self): + self.ensure_one() + self._update_call_participation(joining=False) + + def _update_call_participation(self, joining=True): + """ Updates the call participation of the current partner and notifies members of + 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)] + else: + domain = [('guest_id', '=', guest.id)] + current_channel_partner = self.env['mail.channel.partner'].search(domain, limit=1) + + if not current_channel_partner: + return + current_channel_partner._remove_rtc_invitation() + old_sessions = self.rtc_session_ids.filtered(lambda s: s.channel_partner_id == current_channel_partner) + old_sessions._disconnect() + if joining: + new_session = self.env['mail.channel.rtc.session'].create({'channel_partner_id': current_channel_partner.id}) + new_session_id = new_session.id + elif not old_sessions: + return + + session_data_by_channel = self._notify_rtc_sessions_change() + return session_data_by_channel.get(self.id, []), new_session_id + + def _notify_rtc_sessions_change(self): + session_data_by_channel = self.rtc_session_ids._mail_rtc_session_format_by_channel() + notifications = [] + for record in self: + sessions_data = session_data_by_channel.get(record.id, []) + if not sessions_data: + # if there is no member left in the rtc call, all invitations are reset + record._cancel_rtc_invitations() + notifications.append([(self._cr.dbname, 'mail.channel', record.id), { + 'type': 'rtc_sessions_update', + 'payload': { + 'channelId': record.id, + 'rtcSessions': sessions_data, + }, + }]) + self.env['bus.bus'].sendmany(notifications) + return session_data_by_channel + # ------------------------------------------------------------ # MAILING # ------------------------------------------------------------ @@ -615,6 +781,7 @@ class Channel(models.Model): if not self: return [] channel_infos = [] + rtc_sessions_by_channel = self.sudo().rtc_session_ids._mail_rtc_session_format_by_channel() channel_last_message_ids = dict((r['id'], r['message_id']) for r in self._channel_last_message_ids()) for channel in self: info = { @@ -650,6 +817,8 @@ class Channel(models.Model): info['custom_channel_name'] = partner_channel.custom_channel_name info['is_pinned'] = partner_channel.is_pinned info['last_interest_dt'] = partner_channel.last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT) + if partner_channel.rtc_inviting_session_id: + info['rtc_inviting_session'] = {'id': partner_channel.rtc_inviting_session_id.id} # add members infos if channel.channel_type != 'channel': # avoid sending potentially a lot of members for big channels @@ -663,6 +832,10 @@ class Channel(models.Model): 'seen_message_id': cp.seen_message_id.id, } for cp in channel_partners], key=lambda p: p['partner_id']) + # add rtc sessions infos + if rtc_sessions_by_channel.get(channel.id): + info['rtc_sessions'] = rtc_sessions_by_channel[channel.id] + channel_infos.append(info) return channel_infos diff --git a/addons/mail/models/mail_channel_partner.py b/addons/mail/models/mail_channel_partner.py index b12024ef771..c9cf5275982 100644 --- a/addons/mail/models/mail_channel_partner.py +++ b/addons/mail/models/mail_channel_partner.py @@ -27,6 +27,30 @@ class ChannelPartner(models.Model): is_minimized = fields.Boolean("Conversation is minimized") is_pinned = fields.Boolean("Is pinned on the interface", default=True) last_interest_dt = fields.Datetime("Last Interest", default=fields.Datetime.now, help="Contains the date and time of the last interesting event that happened in this channel for this partner. This includes: creating, joining, pinning, and new message posted.") + rtc_inviting_session_id = fields.Many2one('mail.channel.rtc.session', string='Ringing session') + + def _remove_rtc_invitation(self): + """ Removes the invitation to the rtc call and notifies the inviting partner if removed. """ + notifications = [] + for record in self: + if not record.rtc_inviting_session_id: + continue + model, record_id = ('mail.guest', record.rtc_inviting_session_id.guest_id.id) if record.rtc_inviting_session_id.guest_id else ( + 'res.partner', record.rtc_inviting_session_id.partner_id.id) + payload = {'channelId': record.channel_id.id} + if record.partner_id: + payload['partnerId'] = record.partner_id.id + else: + payload['guestId'] = record.guest_id.id + notifications.append([ + (self._cr.dbname, model, record_id), + { + 'type': 'rtc_outgoing_invitation_ended', + 'payload': payload, + }, + ]) + self.write({'rtc_inviting_session_id': False}) + self.env['bus.bus'].sendmany(notifications) def init(self): self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS mail_channel_partner_partner_unique ON %s (channel_id, partner_id) WHERE partner_id IS NOT NULL" % self._table) diff --git a/addons/mail/models/mail_channel_rtc_session.py b/addons/mail/models/mail_channel_rtc_session.py new file mode 100644 index 00000000000..faf7f9b6639 --- /dev/null +++ b/addons/mail/models/mail_channel_rtc_session.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class MailRtcSession(models.Model): + _name = 'mail.channel.rtc.session' + _description = 'Mail RTC session' + + channel_partner_id = fields.Many2one('mail.channel.partner', index=True, required=True, ondelete='cascade') + channel_id = fields.Many2one('mail.channel', related='channel_partner_id.channel_id', store=True, readonly=True) + partner_id = fields.Many2one('res.partner', related='channel_partner_id.partner_id', string="Partner") + guest_id = fields.Many2one('mail.guest', related='channel_partner_id.guest_id') + + write_date = fields.Datetime("Last Updated On", index=True) + + is_screen_sharing_on = fields.Boolean(string="Is sharing the screen") + is_camera_on = fields.Boolean(string="Is sending user video") + is_muted = fields.Boolean(string="Is microphone muted") + is_deaf = fields.Boolean(string="Has disabled incoming sound") + + _sql_constraints = [ + ('channel_partner_unique', 'UNIQUE(channel_partner_id)', + 'There can only be one rtc session per channel partner') + ] + + def _update_and_broadcast(self, values): + """ Updates the session and notifies all members of the channel + of the change. + """ + valid_values = {'is_screen_sharing_on', 'is_camera_on', 'is_muted', 'is_deaf'} + self.write({key: values[key] for key in valid_values if key in valid_values}) + session_data = self._mail_rtc_session_format() + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.channel_id.id), { + 'type': 'rtc_session_data_update', + 'payload': { + 'rtcSession': session_data, + }, + }) + + @api.autovacuum + def _gc_inactive_sessions(self): + """ Garbage collect sessions that aren't active anymore, + this can happen when the server or the user's browser crash + or when the user's odoo session ends. + """ + sessions = self.search([ + ('write_date', '<', fields.Datetime.now() - relativedelta(days=1)) + ]) + if not sessions: + return + channel_ids = sessions.channel_id + sessions.unlink() + channel_ids._notify_rtc_sessions_change() + + def action_disconnect(self): + channels = self.channel_id + self._disconnect() + if channels: + channels._notify_rtc_sessions_change() + + def _disconnect(self): + """ Unlinks the sessions and notifies the associated partners/guests that + their session ended. + """ + notifications = [] + for record in self: + model, record_id = ('mail.guest', record.guest_id.id) if record.guest_id else ('res.partner', record.partner_id.id) + notifications.append([ + (self._cr.dbname, model, record_id), + { + 'type': 'rtc_session_ended', + 'payload': { + 'sessionId': record.id, + }, + }, + ]) + self.unlink() + self.env['bus.bus'].sendmany(notifications) + + def _notify_peers(self, target_session_ids, content): + """ Used for peer-to-peer communication, + guarantees that the sender is the current guest or partner. + + :param target_session_ids: a list of mail.channel.rtc.session ids + :param content: a dict with the content to be sent to the targets + """ + notifications = [] + target_sessions = self.search([('id', 'in', [int(target) for target in target_session_ids]), ('channel_id', '=', self.channel_id.id)]) + for session in target_sessions: + model, record_id = ('mail.guest', session.guest_id.id) if session.guest_id else ('res.partner', session.partner_id.id) + notifications.append([ + (self._cr.dbname, model, record_id), + { + 'type': 'rtc_peer_notification', + 'payload': { + 'sender': self.id, + 'content': content, + }, + }, + ]) + return self.env['bus.bus'].sendmany(notifications) + + def _mail_rtc_session_format(self): + vals = { + 'id': self.id, + 'is_screen_sharing_on': self.is_screen_sharing_on, + 'is_muted': self.is_muted, + 'is_deaf': self.is_deaf, + 'is_camera_on': self.is_camera_on, + } + if self.guest_id: + vals['guest'] = { + 'id': self.guest_id.id, + 'name': self.guest_id.name, + } + else: + vals['partner'] = { + 'id': self.partner_id.id, + 'name': self.partner_id.name, + } + return vals + + def _mail_rtc_session_format_by_channel(self): + data = {} + for record in self: + data.setdefault(record.channel_id.id, []).append(record._mail_rtc_session_format()) + return data diff --git a/addons/mail/models/mail_ice_server.py b/addons/mail/models/mail_ice_server.py new file mode 100644 index 00000000000..1ab3a674432 --- /dev/null +++ b/addons/mail/models/mail_ice_server.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models +import requests + + +class MailIceServer(models.Model): + _name = 'mail.ice.server' + _description = 'ICE server' + + server_type = fields.Selection([('stun', 'stun:'), ('turn', 'turn:')], string='Type', required=True, default='stun') + uri = fields.Char('URI', required=True) + username = fields.Char() + credential = fields.Char() + + def _get_local_ice_servers(self): + """ + :return: List of up to 5 dict, each of which representing a stun or turn server + """ + # firefox has a hard cap of 5 ice servers + ice_servers = self.sudo().search([], limit=5) + formatted_ice_servers = [] + for ice_server in ice_servers: + formatted_ice_server = { + 'urls': '%s:%s' % (ice_server.server_type, ice_server.uri), + } + if ice_server.username: + formatted_ice_server['username'] = ice_server.username + if ice_server.credential: + formatted_ice_server['credential'] = ice_server.credential + formatted_ice_servers.append(formatted_ice_server) + return formatted_ice_servers + + def _get_twilio_credentials(self): + """ To be overridable if we need to obtain credentials from another source. + :return: tuple + """ + account_sid = self.env['ir.config_parameter'].get_param('mail.twilio_account_sid') + auth_token = self.env['ir.config_parameter'].get_param('mail.twilio_account_token') + return account_sid, auth_token + + def _get_ice_servers(self): + """ + :return: List of dict, each of which representing a stun or turn server, + formatted as expected by the specifications of RTCConfiguration.iceServers + """ + if self.env['ir.config_parameter'].get_param('mail.use_twilio_rtc_servers'): + (account_sid, auth_token) = self._get_twilio_credentials() + if account_sid and auth_token: + url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Tokens.json' + response = requests.post(url, auth=(account_sid, auth_token), timeout=60) + if response.ok: + response_content = response.json() + if response_content: + return response_content['ice_servers'] + return self._get_local_ice_servers() diff --git a/addons/mail/models/res_config_settings.py b/addons/mail/models/res_config_settings.py index d9686cbe4ac..0ca94545fa3 100644 --- a/addons/mail/models/res_config_settings.py +++ b/addons/mail/models/res_config_settings.py @@ -19,6 +19,19 @@ class ResConfigSettings(models.TransientModel): config_parameter='mail.restrict.template.rendering', help='Users will still be able to render templates.\n' 'However only Mail Template Editors will be able to create new dynamic templates or modify existing ones.') + use_twilio_rtc_servers = fields.Boolean( + 'Use Twilio ICE servers', + help="If you want to use twilio as TURN/STUN server provider", + config_parameter='mail.use_twilio_rtc_servers', + ) + twilio_account_sid = fields.Char( + 'Twilio Account SID', + config_parameter='mail.twilio_account_sid', + ) + twilio_account_token = fields.Char( + 'Twilio Account Auth Token', + config_parameter='mail.twilio_account_token', + ) @api.model def get_values(self): diff --git a/addons/mail/models/res_users_settings.py b/addons/mail/models/res_users_settings.py index e219397859c..e76d1eae1a1 100644 --- a/addons/mail/models/res_users_settings.py +++ b/addons/mail/models/res_users_settings.py @@ -12,6 +12,12 @@ class ResUsersSettings(models.Model): is_discuss_sidebar_category_channel_open = fields.Boolean(string="Is discuss sidebar category channel open?", default=True) is_discuss_sidebar_category_chat_open = fields.Boolean(string="Is discuss sidebar category chat open?", default=True) + # RTC + push_to_talk_key = fields.Char(string="Push-To-Talk shortcut", help="String formatted to represent a key with modifiers following this pattern: shift.ctrl.alt.key, e.g: truthy.1.true.b") + use_push_to_talk = fields.Boolean(string="Use the push to talk feature", default=False) + voice_active_duration = fields.Integer(string="Duration of voice activity in ms", help="How long the audio broadcast will remain active after passing the volume threshold") + volume_settings_ids = fields.One2many('res.users.settings.volumes', 'user_setting_id', string="Volumes of other partners") + _sql_constraints = [ ('unique_user_id', 'UNIQUE(user_id)', 'One user should only have one mail user settings.') ] @@ -25,7 +31,12 @@ class ResUsersSettings(models.Model): def _res_users_settings_format(self): self.ensure_one() - return self._read_format(fnames=[name for name, field in self._fields.items() if name == 'id' or not field.automatic])[0] + res = self._read_format(fnames=[name for name, field in self._fields.items() if name == 'id' or not field.automatic])[0] + res.pop('volume_settings_ids') + res.update({ + 'volume_settings': self.volume_settings_ids._read_format(['id', 'user_setting_id', 'partner_id', 'volume']), + }) + return res def set_res_users_settings(self, new_settings): self.ensure_one() @@ -38,3 +49,25 @@ class ResUsersSettings(models.Model): 'type': 'res.users_settings_changed', 'payload': changed_settings, }) + + def set_volume_setting(self, partner_id, volume): + self.ensure_one() + volume_setting = self.env['res.users.settings.volumes'].search([('user_setting_id', '=', self.id), ('partner_id', '=', partner_id)]) + if volume_setting: + volume_setting.volume = volume + else: + volume_setting = self.env['res.users.settings.volumes'].create([{ + 'user_setting_id': self.id, + 'partner_id': partner_id, + 'volume': volume, + }]) + notification = { + 'type': 'res_users_settings_volumes_update', + 'payload': { + 'volumeSettings': [('insert', { + 'id': volume_setting.id, + 'volume': volume_setting.volume, + })], + } + } + self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.user_id.partner_id.id), notification) diff --git a/addons/mail/models/res_users_settings_volumes.py b/addons/mail/models/res_users_settings_volumes.py new file mode 100644 index 00000000000..f65df2c84f1 --- /dev/null +++ b/addons/mail/models/res_users_settings_volumes.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResUsersSettingsVolumes(models.Model): + """ Represents the volume of the sound that the user of user_setting_id will receive from partner_id. """ + _name = 'res.users.settings.volumes' + _description = 'User Settings Volumes' + + user_setting_id = fields.Many2one('res.users.settings', required=True, ondelete='cascade', index=True) + partner_id = fields.Many2one('res.partner', ondelete='cascade', index=True) + guest_id = fields.Many2one('res.partner', ondelete='cascade', index=True) + volume = fields.Float(default=0.5, help="Ranges between 0.0 and 1.0, scale depends on the browser implementation") + + def init(self): + self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS res_users_settings_volumes_partner_unique ON %s (user_setting_id, partner_id) WHERE partner_id IS NOT NULL" % self._table) + self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS res_users_settings_volumes_guest_unique ON %s (user_setting_id, guest_id) WHERE guest_id IS NOT NULL" % self._table) + + _sql_constraints = [ + ("partner_or_guest_exists", "CHECK((partner_id IS NOT NULL AND guest_id IS NULL) OR (partner_id IS NULL AND guest_id IS NOT NULL))", "A volume setting must have a partner or a guest."), + ] diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index cd3d0673ac5..97d586ea12d 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -17,6 +17,8 @@ access_mail_channel_user,mail.group.user,model_mail_channel,base.group_user,1,1, access_mail_channel_partner_public,mail.channel.partner.public,model_mail_channel_partner,base.group_public,1,0,0,0 access_mail_channel_partner_portal,mail.channel.partner.portal,model_mail_channel_partner,base.group_portal,1,1,1,1 access_mail_channel_partner_user,mail.channel.partner.user,model_mail_channel_partner,base.group_user,1,1,1,1 +access_mail_channel_rtc_session_all,mail.channel.rtc.session.all,model_mail_channel_rtc_session,,0,0,0,0 +access_mail_channel_rtc_session_system,mail.channel.rtc.session.system,model_mail_channel_rtc_session,base.group_system,1,1,1,1 access_mail_alias_all,mail.alias.all,model_mail_alias,,1,0,0,0 access_mail_alias_user,mail.alias.user,model_mail_alias,base.group_user,1,0,0,0 access_mail_alias_system,mail.alias.system,model_mail_alias,base.group_system,1,1,1,1 @@ -49,6 +51,10 @@ access_mail_blacklist_remove_system,acesss.mail.blacklist.remove.system,model_ma access_mail_guest_all,mail.guest,model_mail_guest,,0,0,0,0 access_mail_guest_user,mail.guest,model_mail_guest,base.group_user,1,0,0,0 access_mail_guest_system,mail.guest,model_mail_guest,base.group_system,1,1,1,1 +access_mail_ice_server_all,mail.ice.server.all,model_mail_ice_server,,0,0,0,0 +access_mail_ice_server_system,mail.ice.server.system,model_mail_ice_server,base.group_system,1,1,1,1 access_res_users_settings_all,res.users.settings,model_res_users_settings,,0,0,0,0 access_res_users_settings_user,res.users.settings,model_res_users_settings,base.group_user,1,1,1,1 +access_res_users_settings_volumes_all,res.users.settings.volumes,model_res_users_settings_volumes,,0,0,0,0 +access_res_users_settings_volumes_user,res.users.settings.volumes,model_res_users_settings_volumes,base.group_user,1,1,1,1 ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_actions_report,base.group_user,1,0,0,0 diff --git a/addons/mail/security/mail_security.xml b/addons/mail/security/mail_security.xml index d48bbbf5f2d..5741befbd94 100644 --- a/addons/mail/security/mail_security.xml +++ b/addons/mail/security/mail_security.xml @@ -87,4 +87,26 @@ + + res.users.settings.volumes: access their own entries + + + [('user_setting_id.user_id', '=', user.id)] + + + + + + + + Administrators can access all User Settings volumes. + + + [(1, '=', 1)] + + + + + + diff --git a/addons/mail/static/src/audio/call_02_in_.mp3 b/addons/mail/static/src/audio/call_02_in_.mp3 new file mode 100644 index 00000000000..d9b53962c92 Binary files /dev/null and b/addons/mail/static/src/audio/call_02_in_.mp3 differ diff --git a/addons/mail/static/src/audio/call_02_in_.ogg b/addons/mail/static/src/audio/call_02_in_.ogg new file mode 100644 index 00000000000..79e56959706 Binary files /dev/null and b/addons/mail/static/src/audio/call_02_in_.ogg differ diff --git a/addons/mail/static/src/audio/channel_01_in.mp3 b/addons/mail/static/src/audio/channel_01_in.mp3 new file mode 100644 index 00000000000..12d0b288bcf Binary files /dev/null and b/addons/mail/static/src/audio/channel_01_in.mp3 differ diff --git a/addons/mail/static/src/audio/channel_01_in.ogg b/addons/mail/static/src/audio/channel_01_in.ogg new file mode 100644 index 00000000000..52b119cdfeb Binary files /dev/null and b/addons/mail/static/src/audio/channel_01_in.ogg differ diff --git a/addons/mail/static/src/audio/channel_01_out.mp3 b/addons/mail/static/src/audio/channel_01_out.mp3 new file mode 100644 index 00000000000..cded45bc81f Binary files /dev/null and b/addons/mail/static/src/audio/channel_01_out.mp3 differ diff --git a/addons/mail/static/src/audio/channel_01_out.ogg b/addons/mail/static/src/audio/channel_01_out.ogg new file mode 100644 index 00000000000..d55c305a5f8 Binary files /dev/null and b/addons/mail/static/src/audio/channel_01_out.ogg differ diff --git a/addons/mail/static/src/audio/channel_04_out.mp3 b/addons/mail/static/src/audio/channel_04_out.mp3 new file mode 100644 index 00000000000..f32ebba6bb5 Binary files /dev/null and b/addons/mail/static/src/audio/channel_04_out.mp3 differ diff --git a/addons/mail/static/src/audio/channel_04_out.ogg b/addons/mail/static/src/audio/channel_04_out.ogg new file mode 100644 index 00000000000..1dc628b2ad4 Binary files /dev/null and b/addons/mail/static/src/audio/channel_04_out.ogg differ diff --git a/addons/mail/static/src/audio/dm_01.mp3 b/addons/mail/static/src/audio/dm_01.mp3 new file mode 100644 index 00000000000..71f600e8c62 Binary files /dev/null and b/addons/mail/static/src/audio/dm_01.mp3 differ diff --git a/addons/mail/static/src/audio/dm_01.ogg b/addons/mail/static/src/audio/dm_01.ogg new file mode 100644 index 00000000000..4d6a381aaab Binary files /dev/null and b/addons/mail/static/src/audio/dm_01.ogg differ diff --git a/addons/mail/static/src/audio/dm_02.mp3 b/addons/mail/static/src/audio/dm_02.mp3 new file mode 100644 index 00000000000..91d4390c691 Binary files /dev/null and b/addons/mail/static/src/audio/dm_02.mp3 differ diff --git a/addons/mail/static/src/audio/dm_02.ogg b/addons/mail/static/src/audio/dm_02.ogg new file mode 100644 index 00000000000..80641b347b8 Binary files /dev/null and b/addons/mail/static/src/audio/dm_02.ogg differ diff --git a/addons/mail/static/src/audio/share_02.mp3 b/addons/mail/static/src/audio/share_02.mp3 new file mode 100644 index 00000000000..52141784f61 Binary files /dev/null and b/addons/mail/static/src/audio/share_02.mp3 differ diff --git a/addons/mail/static/src/audio/share_02.ogg b/addons/mail/static/src/audio/share_02.ogg new file mode 100644 index 00000000000..8aeefb13abe Binary files /dev/null and b/addons/mail/static/src/audio/share_02.ogg differ diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.js b/addons/mail/static/src/components/chat_window_header/chat_window_header.js index 270648b382e..b6354b7c066 100644 --- a/addons/mail/static/src/components/chat_window_header/chat_window_header.js +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js @@ -74,6 +74,24 @@ export class ChatWindowHeader extends Component { this.chatWindow.close(); } + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickCamera(ev) { + ev.stopPropagation(); + await this.chatWindow.thread.toggleCall({ startWithVideo: true }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickPhone(ev) { + ev.stopPropagation(); + await this.chatWindow.thread.toggleCall(); + } + /** * @private * @param {MouseEvent} ev diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml index 6ac2bcaa0af..5319b1cbea0 100644 --- a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml @@ -25,6 +25,14 @@
+ +
+ +
+
+ +
+
diff --git a/addons/mail/static/src/components/chat_window_manager/tests/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/tests/chat_window_manager_tests.js index 687a2cb1664..70cee153a7d 100644 --- a/addons/mail/static/src/components/chat_window_manager/tests/chat_window_manager_tests.js +++ b/addons/mail/static/src/components/chat_window_manager/tests/chat_window_manager_tests.js @@ -448,7 +448,7 @@ QUnit.test('new message autocomplete should automatically select first result', }); QUnit.test('chat window: basic rendering', async function (assert) { - assert.expect(12); + assert.expect(14); // channel that is expected to be found in the messaging menu // with random unique id and name that will be asserted during the test @@ -498,8 +498,18 @@ QUnit.test('chat window: basic rendering', async function (assert) { ); assert.strictEqual( chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_command`).length, - 3, - "should have 3 commands in header part" + 5, + "should have 5 commands in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandPhone`).length, + 1, + "should have command to start an RTC call in audio mode" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandCamera`).length, + 1, + "should have command to start an RTC call in video mode" ); assert.strictEqual( chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandShowMemberList`).length, diff --git a/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.scss b/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.scss index 93bb7556fcd..55b14cd4e6e 100644 --- a/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.scss +++ b/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.scss @@ -13,6 +13,14 @@ } } +.o_DiscussSidebarCategoryItem_callIndicator { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + + &.o-isCalling { + color: red; + } +} + .o_DiscussSidebarCategoryItem_command { margin-left: $o-mail-discuss-sidebar-category-item-margin; margin-right: $o-mail-discuss-sidebar-category-item-margin; diff --git a/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.xml b/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.xml index 9b73002e15d..e803d896431 100644 --- a/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.xml +++ b/addons/mail/static/src/components/discuss_sidebar_category_item/discuss_sidebar_category_item.xml @@ -30,6 +30,9 @@ + +
+
diff --git a/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.js b/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.js new file mode 100644 index 00000000000..404440e3069 --- /dev/null +++ b/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +const { Component } = owl; + +export class RtcActivityNotice extends Component { + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.messaging && this.messaging.mailRtc.channel; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.thread.open(); + } + +} + +Object.assign(RtcActivityNotice, { + props: {}, + template: 'mail.RtcActivityNotice', +}); + +registerMessagingComponent(RtcActivityNotice); diff --git a/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.scss b/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.scss new file mode 100644 index 00000000000..4d32c9e13b0 --- /dev/null +++ b/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.scss @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcActivityNotice_buttonContent { + max-width: 150px; + align-items: center; + display: flex; +} + +.o_RtcActivityNotice_buttonTitle { + @include text-truncate(); +} + +.o_RtcActivityNotice_outputIndicator { + margin-inline-start: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcActivityNotice { + cursor: pointer; + &:hover { + background-color: rgba(black, 0.1); + } +} + +.o_RtcActivityNotice_button { + font-weight: bold; + user-select: none; +} diff --git a/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.xml b/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.xml new file mode 100644 index 00000000000..bdbcf29b983 --- /dev/null +++ b/addons/mail/static/src/components/rtc_activity_notice/rtc_activity_notice.xml @@ -0,0 +1,23 @@ + + + + +
  • + + + Open conference: + +
    + + +
    +
    +
    +
  • +
    + +
    diff --git a/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.js b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.js new file mode 100644 index 00000000000..ce6b9176c71 --- /dev/null +++ b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +const { Component } = owl; +const { useRef } = owl.hooks; + +export class RtcCallParticipantCard extends Component { + + /** + * @override + */ + setup() { + super.setup(); + this._volumeMenuAnchorRef = useRef('volumeMenuAnchor'); + } + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread|undefined} + */ + get callParticipantCard() { + return this.messaging.models['mail.rtc_call_participant_card'].get(this.props.callParticipantCardLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This listens to the right click event, and used to redirect the event + * as a click on the popover. + * + * @private + * @param {Event} ev + */ + async _onContextMenu(ev) { + if (!this._volumeMenuAnchorRef || !this._volumeMenuAnchorRef.el) { + return; + } + ev.preventDefault(); + this._volumeMenuAnchorRef.el.click(); + } +} + +Object.assign(RtcCallParticipantCard, { + props: { + callParticipantCardLocalId: { + type: String, + }, + }, + template: 'mail.RtcCallParticipantCard', +}); + +registerMessagingComponent(RtcCallParticipantCard); diff --git a/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.scss b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.scss new file mode 100644 index 00000000000..524f4036680 --- /dev/null +++ b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.scss @@ -0,0 +1,160 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcCallParticipantCard { + padding: map-get($spacers, 1); + display: flex; + position: relative; +} + +.o_RtcCallParticipantCard_avatar { + display: flex; + justify-content: center; + max-height: 100%; + user-select: none; +} + +.o_RtcCallParticipantCard_avatar_image { + max-height: 100px; + max-width: 100px; + border: 5px solid gray('700'); + + &.o-is-talking { + border: 5px solid darken($o-enterprise-primary-color, 5%); + } + + &.o-is-invitation:not(:hover) { + animation: border-pulse 3s linear infinite; + } + + &.o-is-invitation:hover { + border: 5px solid theme-color('danger'); + } +} + +@keyframes border-pulse { + 0% { border: 5px solid white } + 20% { border: 5px solid gray('600') } + 35% { border: 5px solid gray('100') } + 50% { border: 5px solid gray('600') } + 70% { border: 5px solid gray('100') } + 85% { border: 5px solid gray('700') } +} + +.o_RtcCallParticipantCard_container { + margin: map-get($spacers, 0); + width: 100% - map-get($spacers, 0); + height: 100% - map-get($spacers, 0); + display: flex; + position: relative; + justify-content: center; + flex-direction: column; +} + +.o_RtcCallParticipantCard_liveIndicator { + display: flex; + margin-inline-end: 5%; + align-items: center; + + &.o-minimized { + font-size: 0.7rem; + } +} + +.o_RtcCallParticipantCard_name { + padding: map-get($spacers, 1); +} + +.o_RtcCallParticipantCard_overlayBottom { + position: absolute; + display: flex; + pointer-events: none; + margin: map-get($spacers, 1); + max-width: 50%; + overflow: hidden; + bottom: 0; +} + +.o_RtcCallParticipantCard_overlayTop { + position: absolute; + display: flex; + flex-direction: row-reverse; + margin: map-get($spacers, 2); + right: 0%; + top: 0%; +} + +.o_RtcCallParticipantCard_overlayTop_element { + display: flex; + justify-content: center; + flex-direction: column; + margin-inline-end: 5%; + padding: map-get($spacers, 2); + + &.o-minimized { + padding: map-get($spacers, 1); + } +} + +.o_RtcCallParticipantCard_volumeMenuAnchor { + position: absolute; + bottom: 0; + left: 50%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcCallParticipantCard { + border-radius: $o-mail-rounded-rectangle-border-radius-sm; + + &.o-is-talking { + box-shadow: inset 0 0 0 map-get($spacers, 2) darken($o-enterprise-primary-color, 5%); + } + + &.o-is-invitation { + opacity: 0.6; + } +} + +.o_RtcCallParticipantCard_connectionState { + color: theme-color('warning'); +} + +.o_RtcCallParticipantCard_container { + border-radius: $o-mail-rounded-rectangle-border-radius-sm; + + &.o-can-click { + cursor: pointer; + } + + &:not(.o-minimized) { + background-color: $o-brand-secondary; + } +} + +.o_RtcCallParticipantCard_liveIndicator { + background-color: theme-color('danger'); + color: white; + user-select: none; + font-weight: bold; +} + +.o_RtcCallParticipantCard_name { + @include text-truncate(); + color: white; + background-color: rgba(black, 0.8); + border-radius: $o-mail-rounded-rectangle-border-radius-sm; +} + +.o_RtcCallParticipantCard_overlayTop_element { + color: white; + border-radius: 50%; + background-color: gray('900'); +} + +.o_RtcCallParticipantCard_video { + cursor: pointer; +} 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 new file mode 100644 index 00000000000..6660df2c00e --- /dev/null +++ b/addons/mail/static/src/components/rtc_call_participant_card/rtc_call_participant_card.xml @@ -0,0 +1,93 @@ + + + + +
    + +
    + + + + + +
    + Avatar +
    +
    + + + + + + + + + + LIVE + + + +
    + + + + + + + + + + + + + + + + + + LIVE + + +
    + + + + + + + + + + + + +
    + +
    +
    +
    +
    + +
    diff --git a/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.js b/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.js new file mode 100644 index 00000000000..9313273fefa --- /dev/null +++ b/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.js @@ -0,0 +1,152 @@ +/** @odoo-module **/ + +import { useUpdate } from '@mail/component_hooks/use_update/use_update'; +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +import Dialog from 'web.OwlDialog'; + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +const components = { + Dialog, +}; + +// TODO a nice-to-have would be a resize handle under the videos. + +export class RtcCallViewer extends Component { + + /** + * @override + */ + setup() { + super.setup(); + this.state = useState({ + tileWidth: 0, + tileHeight: 0, + columnCount: 0, + }); + this.tileContainerRef = useRef('tileContainer'); + useUpdate({ func: () => this._update() }); + } + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.rtc_call_viewer} + */ + get rtcCallViewer() { + return this.messaging.models['mail.rtc_call_viewer'].get(this.props.localId); + } + + /** + * Used to make the component depend on the window size and trigger an + * update when the window size changes. + * + * @returns {Object|undefined} + */ + get windowSize() { + const device = this.messaging && this.messaging.device; + return device && { + innerHeight: device.globalWindowInnerHeight, + innerWidth: device.globalWindowInnerWidth, + }; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Finds a tile layout and dimensions that respects param0.aspectRatio while maximizing + * the total area covered by the tiles within the specified container dimensions. + * + * @private + * @param {Object} param0 + * @param {number} [param0.aspectRatio] + * @param {number} param0.containerHeight + * @param {number} param0.containerWidth + * @param {number} param0.tileCount + */ + _computeTessellation({ aspectRatio = 1, containerHeight, containerWidth, tileCount }) { + let optimalLayout = { + area: 0, + cols: 0, + tileHeight: 0, + tileWidth: 0, + }; + + for (let columnCount = 1; columnCount <= tileCount; columnCount++) { + const rowCount = Math.ceil(tileCount / columnCount); + const potentialHeight = containerWidth / (columnCount * aspectRatio); + const potentialWidth = containerHeight / rowCount; + let tileHeight; + let tileWidth; + if (potentialHeight > potentialWidth) { + tileHeight = Math.floor(potentialWidth); + tileWidth = Math.floor(tileHeight * aspectRatio); + } else { + tileWidth = Math.floor(containerWidth / columnCount); + tileHeight = Math.floor(tileWidth / aspectRatio); + } + const area = tileHeight * tileWidth; + if (area <= optimalLayout.area) { + continue; + } + optimalLayout = { + area, + columnCount, + tileHeight, + tileWidth, + }; + } + return optimalLayout; + } + + /** + * @private + */ + _setTileLayout() { + if (!this.rtcCallViewer) { + return; + } + if (!this.el) { + return; + } + if (!this.tileContainerRef.el) { + return; + } + const { width, height } = this.tileContainerRef.el.getBoundingClientRect(); + + const { tileWidth, tileHeight, columnCount } = this._computeTessellation({ + aspectRatio: this.rtcCallViewer.aspectRatio, + containerHeight: height, + containerWidth: width, + tileCount: this.tileContainerRef.el.children.length, + }); + + this.state.tileWidth = tileWidth; + this.state.tileHeight = tileHeight; + this.state.columnCount = columnCount; + } + + /** + * @private + */ + _update() { + this._setTileLayout(); + } + +} + +Object.assign(RtcCallViewer, { + components, + props: { + localId: String, + }, + template: 'mail.RtcCallViewer', +}); + +registerMessagingComponent(RtcCallViewer); diff --git a/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.scss b/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.scss new file mode 100644 index 00000000000..d1235aa8716 --- /dev/null +++ b/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.scss @@ -0,0 +1,114 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcCallViewer { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 50%; + min-height: 50%; + + &.o-minimized { + height: 20%; + min-height: 20%; + } + + &.o-fullScreen { + position: fixed; + z-index: $zindex-fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } +} + +.o_RtcCallViewer_controls { + width: 100%; + display: flex; + justify-content: center; + padding-bottom: map-get($spacers, 1); + + &.o-floating { + position: absolute; + padding-bottom: map-get($spacers, 3); + bottom: 0; + } +} + +.o_RtcCallViewer_grid { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 0.3vw; + } + + &.o-dynamic-sizing { + flex-direction: row; + width: 100%; + overflow-y: hidden; + flex-wrap: wrap; + justify-content: center; + + .o_RtcCallViewer_participantCard { + width: var(--width); + height: var(--height); + min-width: var(--width); + min-height: var(--height); + } + } + + &.o-sidebar { + width: 120px; + min-width: 120px; + } +} + +.o_RtcCallViewer_grid_tile { + width: 100%; + aspect-ratio: 16/9; +} + +.o_RtcCallViewer_mainParticipant { + width: 100%; +} + +.o_RtcCallViewer_mainParticipantContainer { + max-height: 100%; + display: flex; + flex-grow: 1; +} + +.o_RtcCallViewer_participantContainer { + overflow: hidden; + height: 100%; + width: 100%; + display: flex; + justify-content: space-between; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcCallViewer { + background-color: lighten(black, 10%); +} + +.o_RtcCallViewer_grid { + + &::-webkit-scrollbar { + background: gray('900'); + } + &::-webkit-scrollbar-thumb { + background: gray('700'); + } +} diff --git a/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.xml b/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.xml new file mode 100644 index 00000000000..d167b3e2f3d --- /dev/null +++ b/addons/mail/static/src/components/rtc_call_viewer/rtc_call_viewer.xml @@ -0,0 +1,73 @@ + + + + +
    + + + + + + +
    + +
    + + + +
    +
    + +
    + + + + + + +
    +
    +
    + + + +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + +
    +
    +
    + +
    \ No newline at end of file diff --git a/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.js b/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.js new file mode 100644 index 00000000000..32324646162 --- /dev/null +++ b/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.js @@ -0,0 +1,93 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +import { browser } from "@web/core/browser/browser"; + +const { Component } = owl; +const { useState } = owl.hooks; + +export class RtcConfigurationMenu extends Component { + + /** + * @override + */ + setup() { + super.setup(); + this.state = useState({ + userDevices: undefined, + }); + } + + async willStart() { + this.state.userDevices = await browser.navigator.mediaDevices.enumerateDevices(); + } + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.configuration_menu} + */ + get rtcConfigurationMenu() { + return this.messaging && this.messaging.userSetting.rtcConfigurationMenu; + } + + /** + * @returns {mail.user_setting} + */ + get userSetting() { + return this.messaging && this.messaging.userSetting; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onChangeDelay(ev) { + this.rtcConfigurationMenu.onChangeDelay(ev.target.value); + } + + /** + * @private + * @param {Event} ev + */ + _onChangePushToTalk(ev) { + this.rtcConfigurationMenu.onChangePushToTalk(); + } + + /** + * @private + * @param {Event} ev + */ + _onChangeSelectAudioInput(ev) { + this.rtcConfigurationMenu.onChangeSelectAudioInput(ev.target.value); + } + + /** + * @private + * @param {Event} ev + */ + _onChangeThreshold(ev) { + this.rtcConfigurationMenu.onChangeThreshold(ev.target.value); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRegisterKeyButton() { + this.rtcConfigurationMenu.onClickRegisterKeyButton(); + } +} + +Object.assign(RtcConfigurationMenu, { + template: 'mail.RtcConfigurationMenu', +}); + +registerMessagingComponent(RtcConfigurationMenu); diff --git a/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.scss b/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.scss new file mode 100644 index 00000000000..89d5eb97ed9 --- /dev/null +++ b/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.scss @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcConfigurationMenu { + display: flex; + overflow-y: auto; + flex-direction: column; + margin-left: map-get($spacers, 2); + user-select: none; +} + +.o_RtcConfigurationMenu_option { + min-height: 40px; + padding: map-get($spacers, 1); + margin: map-get($spacers, 2); + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.o_RtcConfigurationMenu_option_deviceSelect { + padding: map-get($spacers, 1); + padding-right: map-get($spacers, 3); +} + +.o_RtcConfigurationMenu_option_inputGroup { + display: flex; + width: 100%; +} + +.o_RtcConfigurationMenu_option_inputGroup_input { + flex-grow: 2; +} + +.o_RtcConfigurationMenu_option_inputGroup_value { + padding: map-get($spacers, 1); +} + +.o_RtcConfigurationMenu_option_label { + display: flex; + align-items: center; + flex-wrap: wrap; + max-width: 100%; +} + +.o_RtcConfigurationMenu_option_name { + @include text-truncate(); + margin-inline-end: map-get($spacers, 2); +} + +.o_RtcConfigurationMenu_option_pushToTalkGroup { + display: flex; +} + +.o_RtcConfigurationMenu_option_pushToTalkGroup_key { + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); + margin-left: map-get($spacers, 1); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcConfigurationMenu_button { + background: none; + border: none; + outline: none; + + @include hover-focus () { + outline: none; + } + + &:hover { + color: black; + } +} + +.o_RtcConfigurationMenu_option_label { + cursor: pointer; +} + +.o_RtcConfigurationMenu_option_name { + font-weight: bold; +} + +.o_RtcConfigurationMenu_option_pushToTalkGroup_key { + font-size: 1.4em; + font-weight: bold; + border-radius: 3px; + border: 2px solid $o-brand-primary; + + &.o-registering { + border-color: theme-color('danger'); + } +} diff --git a/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.xml b/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.xml new file mode 100644 index 00000000000..3e30a581af8 --- /dev/null +++ b/addons/mail/static/src/components/rtc_configuration_menu/rtc_configuration_menu.xml @@ -0,0 +1,72 @@ + + + + +
    +
    + +
    +
    + +
    + +
    + + +
    +
    Press a key to register it as the push-to-talk shortcut
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    diff --git a/addons/mail/static/src/components/rtc_controller/rtc_controller.js b/addons/mail/static/src/components/rtc_controller/rtc_controller.js new file mode 100644 index 00000000000..0677eba3283 --- /dev/null +++ b/addons/mail/static/src/components/rtc_controller/rtc_controller.js @@ -0,0 +1,45 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +const { Component } = owl; + +export class RtcController extends Component { + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.rtc_controller} + */ + get rtcController() { + return this.messaging && this.messaging.models['mail.rtc_controller'].get(this.props.localId); + } + + /** + * @returns {mail.thread} + */ + get rtcSession() { + return this.messaging && this.messaging.mailRtc.currentRtcSession; + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.rtcController && this.rtcController.callViewer.threadView.thread; + } + +} + +Object.assign(RtcController, { + props: { + localId: { + type: String, + }, + }, + template: 'mail.RtcController', +}); + +registerMessagingComponent(RtcController); diff --git a/addons/mail/static/src/components/rtc_controller/rtc_controller.scss b/addons/mail/static/src/components/rtc_controller/rtc_controller.scss new file mode 100644 index 00000000000..2f61de847fb --- /dev/null +++ b/addons/mail/static/src/components/rtc_controller/rtc_controller.scss @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcController { + display: flex; + justify-content: space-between; +} + +.o_RtcController_button { + margin: map-get($spacers, 1); + padding: map-get($spacers, 4); + display: flex; +} + +.o_RtcController_button_iconWrapper { + display: flex; + width: map-get($spacers, 4); + height: map-get($spacers, 4); + justify-content: center; + flex-direction: column; + align-items: center; +} + +.o_RtcController_buttons { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.o-small { + .o_RtcController_button_iconWrapper { + width: map-get($spacers, 3); + height: map-get($spacers, 3); + } + .o_RtcController_button { + padding: map-get($spacers, 3); + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcController_button { + color: white; + background: none; + border: none; + outline: none; + border-radius: 50%; + background-color: gray('800'); + + &:hover { + background-color: gray('700'); + } + + &.o-active { + opacity: 1; + } + + @include hover-focus () { + outline: none; + } +} + +.o_RtcController_callToggle { + background-color: theme-color('success'); + + &:hover { + background-color: darken(theme-color('success'), 20%); + } + + &.o-active { + background-color: theme-color('danger'); + + &:hover { + background-color: darken(theme-color('danger'), 20%); + } + } +} + +.o_RtcController_videoButton.o-active { + color: theme-color('success'); +} diff --git a/addons/mail/static/src/components/rtc_controller/rtc_controller.xml b/addons/mail/static/src/components/rtc_controller/rtc_controller.xml new file mode 100644 index 00000000000..55077a4045e --- /dev/null +++ b/addons/mail/static/src/components/rtc_controller/rtc_controller.xml @@ -0,0 +1,121 @@ + + + + +
    + +
    + + Unmute + Mute + + Undeafen + Deafen + + Stop camera + Turn camera on + + Stop screen sharing + Share screen + + + + + + + + + + + + + + + + Disconnect + Join Call + + +
    +
    +
    +
    + +
    \ No newline at end of file diff --git a/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.js b/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.js new file mode 100644 index 00000000000..0a1b8ed44ac --- /dev/null +++ b/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +const { Component } = owl; + +export class RtcInvitationCard extends Component { + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.messaging.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickAccept(ev) { + this.thread.open(); + await this.thread.toggleCall(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAvatar(ev) { + this.thread.open(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRefuse(ev) { + this.thread.leaveCall(); + } + +} + +Object.assign(RtcInvitationCard, { + props: { + threadLocalId: String, + }, + template: 'mail.RtcInvitationCard', +}); + +registerMessagingComponent(RtcInvitationCard); diff --git a/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.scss b/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.scss new file mode 100644 index 00000000000..fd242c92603 --- /dev/null +++ b/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.scss @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcInvitationCard { + display: flex; + flex-direction: column; + margin: map-get($spacers, 2); + padding: map-get($spacers, 5); +} + +.o_RtcInvitationCard_buttonList { + width: 100%; + display: flex; + margin-top: map-get($spacers, 4); + justify-content: space-around; + align-items: center; +} + +.o_RtcInvitationCard_buttonList_button { + padding: map-get($spacers, 2); +} + +.o_RtcInvitationCard_buttonList_button_icon { + margin: map-get($spacers, 3); +} + +.o_RtcInvitationCard_partner_info { + display: flex; + width: 130px; + flex-direction: column; + justify-content: space-around; + align-items: center; + white-space: nowrap; +} + +.o_RtcInvitationCard_partner_info_image { + margin-bottom: map-get($spacers, 2); + width: 70%; + height: 70%; +} + +.o_RtcInvitationCard_partner_info_name { + overflow: hidden; + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcInvitationCard { + border-radius: 3px; + background-color: gray('900'); + border: 1px solid black; +} + +.o_RtcInvitationCard_buttonList_button { + border-radius: 100%; + cursor: pointer; + user-select: none; + border: none; +} + +.o_RtcInvitationCard_buttonList_accept { + background-color: theme-color('success'); + + &:hover { + background-color: darken(theme-color('success'), 10%); + } +} + +.o_RtcInvitationCard_buttonList_refuse { + background-color: theme-color('danger'); + + &:hover { + background-color: darken(theme-color('danger'), 10%); + } +} + +.o_RtcInvitationCard_partner_info_image { + border: 3px solid gray; + cursor: pointer; +} + +.o_RtcInvitationCard_partner_info_name { + font-weight: bold; + @include text-truncate(); + text-align: center; +} + +.o_RtcInvitationCard_partner_info_text { + font-style: italic; + opacity: 0.9; +} diff --git a/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.xml b/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.xml new file mode 100644 index 00000000000..b1655503761 --- /dev/null +++ b/addons/mail/static/src/components/rtc_invitation_card/rtc_invitation_card.xml @@ -0,0 +1,33 @@ + + + + +
    + +
    + Avatar + + Incoming Call... +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    diff --git a/addons/mail/static/src/components/rtc_invitations/rtc_invitations.js b/addons/mail/static/src/components/rtc_invitations/rtc_invitations.js new file mode 100644 index 00000000000..c05cfd26103 --- /dev/null +++ b/addons/mail/static/src/components/rtc_invitations/rtc_invitations.js @@ -0,0 +1,27 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +const { Component } = owl; + +export class RtcInvitations extends Component { + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread[]} + */ + get threads() { + return this.messaging && this.messaging.ringingThreads; + } + +} + +Object.assign(RtcInvitations, { + props: {}, + template: 'mail.RtcInvitations', +}); + +registerMessagingComponent(RtcInvitations); diff --git a/addons/mail/static/src/components/rtc_invitations/rtc_invitations.scss b/addons/mail/static/src/components/rtc_invitations/rtc_invitations.scss new file mode 100644 index 00000000000..2692ca19cda --- /dev/null +++ b/addons/mail/static/src/components/rtc_invitations/rtc_invitations.scss @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcInvitations { + display: flex; + flex-direction: column; + position: absolute; + top: 0px; + right: 0px; + padding: map-get($spacers, 2); + z-index: $zindex-modal; +} diff --git a/addons/mail/static/src/components/rtc_invitations/rtc_invitations.xml b/addons/mail/static/src/components/rtc_invitations/rtc_invitations.xml new file mode 100644 index 00000000000..e3b91722614 --- /dev/null +++ b/addons/mail/static/src/components/rtc_invitations/rtc_invitations.xml @@ -0,0 +1,14 @@ + + + + +
    + + + + + +
    +
    + +
    diff --git a/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.js b/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.js new file mode 100644 index 00000000000..7baf1429ea3 --- /dev/null +++ b/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; +import { useComponentToModel } from '@mail/component_hooks/use_component_to_model/use_component_to_model'; + +const { Component } = owl; + +export class RtcLayoutMenu extends Component { + + /** + * @override + */ + setup() { + super.setup(); + useComponentToModel({ fieldName: 'component', modelName: 'mail.rtc_layout_menu', propNameAsRecordLocalId: 'localId' }); + } + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.rtc_layout_menu} + */ + get layoutMenu() { + return this.messaging && this.messaging.models['mail.rtc_layout_menu'].get(this.props.localId); + } + +} + +Object.assign(RtcLayoutMenu, { + props: { + localId: String, + }, + template: 'mail.RtcLayoutMenu', +}); + +registerMessagingComponent(RtcLayoutMenu); diff --git a/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.scss b/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.scss new file mode 100644 index 00000000000..60659192d97 --- /dev/null +++ b/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.scss @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcLayoutMenu { + display: flex; + flex-direction: column; + padding: map-get($spacers, 1); +} + +.o_RtcLayoutMenu_input_text { + @include text-truncate(); +} + +.o_RtcLayoutMenu_separator { + width: 100%; +} + +.o_RtcLayoutMenu_radio { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 1.3em; +} + +.o_RtcLayoutMenu_radio_icon { + margin-inline-end: map-get($spacers, 1); +} + +.o_RtcLayoutMenu_radio_input { + margin: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcLayoutMenu_radio { + cursor: pointer; + border-radius: 5px; + + &:hover { + background-color: gray('100'); + box-shadow: 0px 0px 1px 1px gray('300') inset; + } + + &:active { + background-color: gray('200'); + box-shadow: 0px 0px 1px 1px gray('400') inset; + } +} + +.o_RtcLayoutMenu_separator { + background-color: $border-color; +} diff --git a/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.xml b/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.xml new file mode 100644 index 00000000000..8feee44777f --- /dev/null +++ b/addons/mail/static/src/components/rtc_layout_menu/rtc_layout_menu.xml @@ -0,0 +1,118 @@ + + + + +
    + + + + +
    +
    + + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/addons/mail/static/src/components/rtc_option_list/rtc_option_list.js b/addons/mail/static/src/components/rtc_option_list/rtc_option_list.js new file mode 100644 index 00000000000..601164a8729 --- /dev/null +++ b/addons/mail/static/src/components/rtc_option_list/rtc_option_list.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; +import { useComponentToModel } from '@mail/component_hooks/use_component_to_model/use_component_to_model'; + +const { Component } = owl; + +export class RtcOptionList extends Component { + + /** + * @override + */ + setup() { + super.setup(); + useComponentToModel({ fieldName: 'component', modelName: 'mail.rtc_option_list', propNameAsRecordLocalId: 'localId' }); + } + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.rtc_option_list} + */ + get rtcOptionList() { + return this.messaging && this.messaging.models['mail.rtc_option_list'].get(this.props.localId); + } + +} + +Object.assign(RtcOptionList, { + props: { + localId: String, + }, + template: 'mail.RtcOptionList', +}); + +registerMessagingComponent(RtcOptionList); diff --git a/addons/mail/static/src/components/rtc_option_list/rtc_option_list.scss b/addons/mail/static/src/components/rtc_option_list/rtc_option_list.scss new file mode 100644 index 00000000000..34d57be7efd --- /dev/null +++ b/addons/mail/static/src/components/rtc_option_list/rtc_option_list.scss @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcOptionList { + display: flex; + flex-direction: column; +} + +.o_RtcOptionList_button { + display: flex; + margin: map-get($spacers, 1); + align-items: center; + color: gray('800'); +} + +.o_RtcOptionList_button_icon { + margin: map-get($spacers, 1); + padding: map-get($spacers, 1); +} + +.o_RtcOptionList_button_text { + @include text-truncate(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcOptionList_button { + background: none; + border: none; + outline: none; + cursor: pointer; + border-radius: 5px; + color: gray('800'); + + &:hover { + background-color: gray('100'); + box-shadow: 0px 0px 1px 1px gray('300') inset; + } + + &:active { + background-color: gray('200'); + box-shadow: 0px 0px 1px 1px gray('400') inset; + } + + @include hover-focus () { + outline: none; + } +} diff --git a/addons/mail/static/src/components/rtc_option_list/rtc_option_list.xml b/addons/mail/static/src/components/rtc_option_list/rtc_option_list.xml new file mode 100644 index 00000000000..92f05c3b262 --- /dev/null +++ b/addons/mail/static/src/components/rtc_option_list/rtc_option_list.xml @@ -0,0 +1,23 @@ + + + + +
    + + + + + +
    +
    + +
    diff --git a/addons/mail/static/src/components/rtc_video/rtc_video.js b/addons/mail/static/src/components/rtc_video/rtc_video.js new file mode 100644 index 00000000000..9b0b2df55c4 --- /dev/null +++ b/addons/mail/static/src/components/rtc_video/rtc_video.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; +import { useUpdate } from '@mail/component_hooks/use_update/use_update'; + +const { Component } = owl; +const { useRef } = owl.hooks; + +export class RtcVideo extends Component { + + /** + * @override + */ + setup() { + super.setup(); + useUpdate({ func: () => this._update() }); + this._videoRef = useRef("video"); + } + + //-------------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread|undefined} + */ + get rtcSession() { + return this.messaging.models["mail.rtc_session"].get( + this.props.rtcSessionLocalId + ); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _update() { + this._loadVideo(); + } + + /** + * Since it is not possible to directly put a mediaStreamObject as the src + * or src-object of the template, the video src is manually inserted into + * the DOM. + * + */ + _loadVideo() { + if (!this._videoRef) { + return; + } + if (!this.rtcSession || !this.rtcSession.videoStream) { + this._videoRef.el.srcObject = undefined; + return; + } + this._videoRef.el.srcObject = this.rtcSession.videoStream; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + async _onVideoLoadedMetaData(ev) { + await ev.target.play(); + } +} + +Object.assign(RtcVideo, { + props: { + rtcSessionLocalId: { + type: String, + }, + }, + template: 'mail.RtcVideo', +}); + +registerMessagingComponent(RtcVideo); diff --git a/addons/mail/static/src/components/rtc_video/rtc_video.scss b/addons/mail/static/src/components/rtc_video/rtc_video.scss new file mode 100644 index 00000000000..10d46e2e150 --- /dev/null +++ b/addons/mail/static/src/components/rtc_video/rtc_video.scss @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_RtcVideo_video { + width: 100%; + height: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_RtcVideo_video { + background-color: black; + border-radius: $o-mail-rounded-rectangle-border-radius-sm; + cursor: pointer; +} diff --git a/addons/mail/static/src/components/rtc_video/rtc_video.xml b/addons/mail/static/src/components/rtc_video/rtc_video.xml new file mode 100644 index 00000000000..14248f3b0d6 --- /dev/null +++ b/addons/mail/static/src/components/rtc_video/rtc_video.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/addons/mail/static/src/components/thread_view/thread_view.scss b/addons/mail/static/src/components/thread_view/thread_view.scss index b054d47fb45..9ae574eb052 100644 --- a/addons/mail/static/src/components/thread_view/thread_view.scss +++ b/addons/mail/static/src/components/thread_view/thread_view.scss @@ -49,6 +49,10 @@ flex: 1 1 auto; } +.o_ThreadView_videoRoom { + min-height: 60%; +} + // ------------------------------------------------------------------ // Style // ------------------------------------------------------------------ diff --git a/addons/mail/static/src/components/thread_view/thread_view.xml b/addons/mail/static/src/components/thread_view/thread_view.xml index 12bad72b27c..dcfdc41184a 100644 --- a/addons/mail/static/src/components/thread_view/thread_view.xml +++ b/addons/mail/static/src/components/thread_view/thread_view.xml @@ -9,6 +9,9 @@
    + + +
    Loading... diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js index ae197e2de35..b3dc357da48 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js @@ -29,6 +29,28 @@ export class ThreadViewTopbar extends Component { return this.messaging && this.messaging.models['mail.thread_view_topbar'].get(this.props.localId); } + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickPhone(ev) { + await this.threadViewTopBar.thread.toggleCall(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickCamera(ev) { + await this.threadViewTopBar.thread.toggleCall({ + startWithVideo: true, + }); + } + } Object.assign(ThreadViewTopbar, { diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml index 47cb89b819d..7f0d5818945 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml @@ -49,6 +49,14 @@ + + + +