[IMP] mail*: add audio and video conference with webRTC
*test_discuss_full,test_lint
This commit adds the audio and video conference feature to mail channels
and integrate it with the groupDM/guest features.
Adds new mp3 and ogg files (from task-2554674) for sound effects.
- Adds three new tables:
* `mail.channel.rtc.session` to manage the peerToPeer interactions
during rtc calls.
* `mail.ice.server` to provide ICE servers necessary to establish
peerToPeer connections with webRtc.
* `res.users.settings.volumes` to hold the partner-to-partner volume
settings, each partner can create one new setting per other
partner to configure the volume coming from those partners during
calls.
- changes res.config.settings:
* Adds new fields for the Twilio credentials to use their STUN/TURN
service.
- changes res.user.settings:
* Adds 4 fields for the push to talk and voice activation.
- changes mail.channel:
* Adds a new field `rtc_session_ids` that represents the active
participants in a rtc call on that channel.
- changes mail.channel.partner:
* Adds a new field `rtc_inviting_session_id` that represents the
rtcSession of the user that is inviting that channelPartner to a
call.
task-2366708
closes odoo/odoo#66611
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
@@ -87,4 +87,26 @@
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="res_users_settings_volumes_rule_user" model="ir.rule">
|
||||
<field name="name">res.users.settings.volumes: access their own entries</field>
|
||||
<field name="model_id" ref="model_res_users_settings_volumes"/>
|
||||
<field name="groups" eval="[Command.link(ref('base.group_user'))]"/>
|
||||
<field name="domain_force">[('user_setting_id.user_id', '=', user.id)]</field>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="res_users_settings_volumes_rule_admin" model="ir.rule">
|
||||
<field name="name">Administrators can access all User Settings volumes.</field>
|
||||
<field name="model_id" ref="model_res_users_settings_volumes"/>
|
||||
<field name="groups" eval="[Command.link(ref('base.group_system'))]"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
</t>
|
||||
<div class="o-autogrow"/>
|
||||
<div class="o_ChatWindowHeader_item o_ChatWindowHeader_rightArea">
|
||||
<t t-if="chatWindow.hasCallButtons">
|
||||
<div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandCamera" t-att-class="{ 'o-mobile': messaging.device.isMobile }" t-att-disabled="chatWindow.thread.hasPendingRtcRequest" t-on-click="_onClickCamera" title="Start a Video Call">
|
||||
<i class="fa fa-video-camera"/>
|
||||
</div>
|
||||
<div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandPhone" t-att-class="{ 'o-mobile': messaging.device.isMobile }" t-att-disabled="chatWindow.thread.hasPendingRtcRequest" t-on-click="_onClickPhone" title="Start a Call">
|
||||
<i class="fa fa-phone"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="chatWindow.thread and chatWindow.thread.hasMemberListFeature and !chatWindow.isMemberListOpened">
|
||||
<div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShowMemberList" t-att-class="{ 'o-mobile': messaging.device.isMobile }" title="Show Member List" t-on-click="chatWindow.onClickShowMemberList">
|
||||
<i class="fa fa-users"/>
|
||||
|
||||
+13
-3
@@ -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,
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
+3
@@ -30,6 +30,9 @@
|
||||
<div class="o_DiscussSidebarCategoryItem_command o_DiscussSidebarCategoryItem_commandUnpin fa fa-times" t-on-click="categoryItem.onClickCommandUnpin" title="Unpin Conversation" role="img"/>
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="categoryItem.channel and categoryItem.channel.rtcSessions.length">
|
||||
<div class="o_DiscussSidebarCategoryItem_item o_DiscussSidebarCategoryItem_callIndicator fa fa-volume-up" t-att-class="{ 'o-isCalling': categoryItem.channel.mailRtc }"/>
|
||||
</t>
|
||||
<t t-if="categoryItem.counter > 0">
|
||||
<div class="o_DiscussSidebarCategoryItem_counter o_DiscussSidebarCategoryItem_item badge badge-pill">
|
||||
<t t-esc="categoryItem.counter"/>
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcActivityNotice" owl="1">
|
||||
<li class="o_RtcActivityNotice">
|
||||
<RtcInvitations/>
|
||||
<t t-if="thread">
|
||||
<t t-set="title">Open conference: <t t-esc="thread.displayName"/></t>
|
||||
<a class="o_RtcActivityNotice_button" t-att-title="title" role="button" t-on-click="_onClick">
|
||||
<div class="o_RtcActivityNotice_buttonContent">
|
||||
<span class="o_RtcActivityNotice_buttonTitle" t-esc="thread.displayName"/>
|
||||
<i class="o_RtcActivityNotice_outputIndicator fa" t-att-class="{
|
||||
'fa-microphone': !messaging.mailRtc.sendDisplay and !messaging.mailRtc.sendUserVideo,
|
||||
'fa-video-camera': messaging.mailRtc.sendUserVideo,
|
||||
'fa-desktop': messaging.mailRtc.sendDisplay,
|
||||
}"/>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
+58
@@ -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);
|
||||
+160
@@ -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;
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcCallParticipantCard" owl="1">
|
||||
<div class="o_RtcCallParticipantCard"
|
||||
t-att-class="{
|
||||
'o-is-talking': callParticipantCard and !callParticipantCard.isMinimized and callParticipantCard.isTalking,
|
||||
'o-is-invitation': callParticipantCard and !!callParticipantCard.invitedPartner,
|
||||
}"
|
||||
t-on-contextmenu="_onContextMenu"
|
||||
>
|
||||
<t t-if="callParticipantCard">
|
||||
<div class="o_RtcCallParticipantCard_container"
|
||||
t-att-class="{
|
||||
'o-minimized': callParticipantCard.isMinimized,
|
||||
'o-can-click': !!callParticipantCard.invitedPartner,
|
||||
}"
|
||||
t-att-title="callParticipantCard.name"
|
||||
t-att-aria-label="callParticipantCard.name"
|
||||
t-on-click="callParticipantCard.onClick"
|
||||
>
|
||||
<!-- card -->
|
||||
<t t-if="callParticipantCard.rtcSession and callParticipantCard.rtcSession.videoStream">
|
||||
<RtcVideo t-on-click="callParticipantCard.onClickVideo" rtcSessionLocalId="callParticipantCard.rtcSession.localId"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_RtcCallParticipantCard_avatar" draggable="false">
|
||||
<img alt="Avatar"
|
||||
t-att-class="{
|
||||
'o-is-talking': callParticipantCard.isTalking,
|
||||
'o-is-invitation': !!callParticipantCard.invitedPartner,
|
||||
}"
|
||||
class="o_RtcCallParticipantCard_avatar_image rounded-circle"
|
||||
t-att-src="callParticipantCard.avatarUrl"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="callParticipantCard.rtcSession">
|
||||
<!-- overlay -->
|
||||
<span class="o_RtcCallParticipantCard_overlayBottom">
|
||||
<t t-if="!callParticipantCard.isMinimized">
|
||||
<span class="o_RtcCallParticipantCard_name" t-esc="callParticipantCard.name"/>
|
||||
</t>
|
||||
<t t-if="callParticipantCard.rtcSession.isScreenSharingOn and callParticipantCard.isMinimized and !callParticipantCard.rtcSession.channel.mailRtc">
|
||||
<span class="o_RtcCallParticipantCard_liveIndicator badge-pill o-minimized" title="live" aria-label="live">
|
||||
LIVE
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
<div class="o_RtcCallParticipantCard_overlayTop">
|
||||
<t t-if="callParticipantCard.rtcSession.isMuted">
|
||||
<span class="o_RtcCallParticipantCard_overlayTop_element" t-att-class="{'o-minimized': callParticipantCard.isMinimized }" title="muted" aria-label="muted">
|
||||
<i class="fa fa-microphone-slash"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="callParticipantCard.rtcSession.isDeaf">
|
||||
<span class="o_RtcCallParticipantCard_overlayTop_element" t-att-class="{'o-minimized': callParticipantCard.isMinimized }" title="deaf" aria-label="deaf">
|
||||
<i class="fa fa-deaf"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="callParticipantCard.rtcSession.channel.mailRtc and !callParticipantCard.rtcSession.mailRtc and !['connected', 'completed'].includes(callParticipantCard.rtcSession.connectionState)">
|
||||
<span class="o_RtcCallParticipantCard_overlayTop_element" t-att-title="callParticipantCard.rtcSession.connectionState">
|
||||
<i class="fa fa-lg fa-signal o_RtcCallParticipantCard_connectionState"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="callParticipantCard.rtcSession.isScreenSharingOn and !callParticipantCard.isMinimized and !callParticipantCard.rtcSession.channel.mailRtc">
|
||||
<span class="o_RtcCallParticipantCard_liveIndicator badge-pill" title="live" aria-label="live">
|
||||
LIVE
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- volume popover -->
|
||||
<t t-if="!callParticipantCard.rtcSession.isOwnSession">
|
||||
<i class="o_RtcCallParticipantCard_volumeMenuAnchor">
|
||||
<Popover>
|
||||
<i t-ref="volumeMenuAnchor"/>
|
||||
<t t-set="opened">
|
||||
<input type="range" min="0.0" max="1" step="0.01" t-att-value="callParticipantCard.rtcSession.volume" t-on-change="callParticipantCard.onChangeVolume"/>
|
||||
</t>
|
||||
</Popover>
|
||||
</i>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcCallViewer" owl="1">
|
||||
<div class="o_RtcCallViewer" t-att-class="{ 'o-fullScreen': rtcCallViewer and rtcCallViewer.isFullScreen, 'o-minimized': rtcCallViewer and rtcCallViewer.isMinimized }">
|
||||
<t t-if="rtcCallViewer">
|
||||
|
||||
<!-- used to make the template depend on window size -->
|
||||
<t t-set="sizeChangeListener" t-value="windowSize"/>
|
||||
|
||||
<!-- Call members display -->
|
||||
<div class="o_RtcCallViewer_participantContainer" t-on-click="rtcCallViewer.onClick" t-on-mousemove="rtcCallViewer.onMouseMove">
|
||||
<t t-if="rtcCallViewer.layout !== 'tiled'">
|
||||
<div class="o_RtcCallViewer_mainParticipantContainer">
|
||||
<t t-if="rtcCallViewer.mainParticipantCard">
|
||||
<RtcCallParticipantCard class="o_RtcCallViewer_participantCard o_RtcCallViewer_mainParticipant" callParticipantCardLocalId="rtcCallViewer.mainParticipantCard.localId"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="rtcCallViewer.layout !== 'spotlight'">
|
||||
<div
|
||||
class="o_RtcCallViewer_grid"
|
||||
t-att-class="{
|
||||
'o-dynamic-sizing': rtcCallViewer.layout === 'tiled',
|
||||
'o-sidebar': rtcCallViewer.layout === 'sidebar',
|
||||
}"
|
||||
t-ref="tileContainer"
|
||||
t-attf-style="--height:{{state.tileHeight}}px; --width:{{state.tileWidth}}px; --columns:{{state.columnCount}};"
|
||||
>
|
||||
<t t-foreach="rtcCallViewer.tileParticipantCards" t-as="participantCard" t-key="'grid_tile_'+participantCard.localId">
|
||||
<t t-if="!rtcCallViewer.filterVideoGrid or (participantCard.rtcSession and participantCard.rtcSession.videoStream)">
|
||||
<!-- maybe filter focused partner out? -->
|
||||
<RtcCallParticipantCard
|
||||
class="o_RtcCallViewer_participantCard o_RtcCallViewer_grid_tile"
|
||||
callParticipantCardLocalId="participantCard.localId"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<t t-if="rtcCallViewer.showOverlay or !rtcCallViewer.isControllerFloating">
|
||||
<div class="o_RtcCallViewer_controls" t-att-class="{ 'o-floating': rtcCallViewer.isControllerFloating }">
|
||||
<div class="o_RtcCallViewer_controls_overlay_container" t-on-mousemove="rtcCallViewer.onMouseMoveOverlay">
|
||||
<RtcController localId="rtcCallViewer.rtcController.localId"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<t t-if="messaging.userSetting.rtcConfigurationMenu.isOpen">
|
||||
<Dialog size="'small'" title="'Settings'" t-on-dialog-closed="rtcCallViewer.onRtcSettingsDialogClosed">
|
||||
<RtcConfigurationMenu/>
|
||||
<t t-set-slot="buttons">
|
||||
<!-- Explicit No buttons -->
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
<t t-if="rtcCallViewer and rtcCallViewer.rtcLayoutMenu">
|
||||
<Dialog size="'small'" title="'Change Layout'" t-on-dialog-closed="rtcCallViewer.onLayoutSettingsDialogClosed">
|
||||
<RtcLayoutMenu localId="rtcCallViewer.rtcLayoutMenu.localId"/>
|
||||
<t t-set-slot="buttons">
|
||||
<!-- Explicit No buttons -->
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcConfigurationMenu" owl="1">
|
||||
<div class="o_RtcConfigurationMenu">
|
||||
<div class="o_RtcConfigurationMenu_option">
|
||||
<label class="o_RtcConfigurationMenu_option_label" title="Input device" aria-label="Input device">
|
||||
<span class="o_RtcConfigurationMenu_option_name">Input device</span>
|
||||
<div>
|
||||
<select name="inputDevice" class="o_RtcConfigurationMenu_option_deviceSelect" t-att-value="userSetting.audioInputDeviceId" t-on-change="_onChangeSelectAudioInput">
|
||||
<option value="">Browser default</option>
|
||||
<t t-if="state.userDevices" t-foreach="state.userDevices" t-as="device">
|
||||
<t t-if="device.kind === 'audioinput'">
|
||||
<option t-att-value="device.deviceId"><t t-esc="device.label"/></option>
|
||||
</t>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="o_RtcConfigurationMenu_option">
|
||||
<label class="o_RtcConfigurationMenu_option_label" title="Use Push-to-talk" aria-label="Use Push-to-talk">
|
||||
<span class="o_RtcConfigurationMenu_option_name">Use Push-to-talk</span>
|
||||
<input type="checkbox" aria-label="toggle push-to-talk" title="toggle push-to-talk" t-on-change="_onChangePushToTalk" t-att-checked="userSetting.usePushToTalk ? 'checked' : ''"/>
|
||||
</label>
|
||||
</div>
|
||||
<t t-if="userSetting.usePushToTalk">
|
||||
<div class="o_RtcConfigurationMenu_option">
|
||||
<label class="o_RtcConfigurationMenu_option_label" title="Push-to-talk key" aria-label="Push-to-talk key">
|
||||
<span class="o_RtcConfigurationMenu_option_name">Push-to-talk key</span>
|
||||
<span class="o_RtcConfigurationMenu_option_pushToTalkGroup">
|
||||
<t t-if="userSetting.pushToTalkKey">
|
||||
<span class="o_RtcConfigurationMenu_option_pushToTalkGroup_key" t-att-class="{ 'o-registering': rtcConfigurationMenu.isRegisteringKey }" t-esc="userSetting.pushToTalkKeyToString()"/>
|
||||
</t>
|
||||
<button class="o_RtcConfigurationMenu_button" t-on-click="_onClickRegisterKeyButton">
|
||||
<t t-if="rtcConfigurationMenu.isRegisteringKey">
|
||||
<i title="Cancel" aria-label="Cancel" class="fa fa-2x fa-times-circle"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i title="Register new key" aria-label="Register new key" class="fa fa-2x fa-keyboard-o"/>
|
||||
</t>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
<div t-if="rtcConfigurationMenu.isRegisteringKey">Press a key to register it as the push-to-talk shortcut</div>
|
||||
<div class="o_RtcConfigurationMenu_option">
|
||||
<label class="o_RtcConfigurationMenu_option_label" title="Delay after releasing push-to-talk" aria-label="Delay after releasing push-to-talk">
|
||||
<span class="o_RtcConfigurationMenu_option_name">Delay after releasing push-to-talk</span>
|
||||
<div class="o_RtcConfigurationMenu_option_inputGroup">
|
||||
<input class="o_RtcConfigurationMenu_option_inputGroup_input" type="range" min="1" max="2000" step="1" t-att-value="userSetting.voiceActiveDuration" t-on-change="_onChangeDelay"/>
|
||||
<span class="o_RtcConfigurationMenu_option_inputGroup_value"><t t-esc="userSetting.voiceActiveDuration"/>ms</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_RtcConfigurationMenu_option">
|
||||
<label class="o_RtcConfigurationMenu_option_label" title="Minimum activity for voice detection" aria-label="Minimum activity for voice detection">
|
||||
<span class="o_RtcConfigurationMenu_option_name">Minimum activity for voice detection</span>
|
||||
<div class="o_RtcConfigurationMenu_option_inputGroup">
|
||||
<input class="o_RtcConfigurationMenu_option_inputGroup_input" type="range" min="0.001" max="1" step="0.001" t-att-value="userSetting.voiceActivationThreshold" t-on-change="_onChangeThreshold"/>
|
||||
<span class="o_RtcConfigurationMenu_option_inputGroup_value"><t t-esc="userSetting.voiceActivationThreshold"/></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcController" owl="1">
|
||||
<div class="o_RtcController" t-att-class="{ 'o-small': rtcController and rtcController.isSmall }">
|
||||
<t t-if="rtcController">
|
||||
<div class="o_RtcController_buttons">
|
||||
<t t-if="thread.mailRtc and rtcSession">
|
||||
<t t-if="rtcSession.isMuted"><t t-set="microphoneTitle">Unmute</t></t>
|
||||
<t t-else=""><t t-set="microphoneTitle">Mute</t></t>
|
||||
<button class="o_RtcController_button"
|
||||
t-att-class="{ 'o-active': !rtcSession.isMuted }"
|
||||
t-att-aria-label="microphoneTitle"
|
||||
t-att-title="microphoneTitle"
|
||||
t-on-click="rtcController.onClickMicrophone">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa" t-att-class="{
|
||||
'fa-lg': !rtcController.isSmall,
|
||||
'fa-microphone': !rtcSession.isMuted,
|
||||
'fa-microphone-slash': rtcSession.isMuted,
|
||||
'text-danger': rtcSession.isMuted,
|
||||
}"/>
|
||||
</div>
|
||||
</button>
|
||||
<t t-if="rtcSession.isDeaf"><t t-set="headphoneTitle">Undeafen</t></t>
|
||||
<t t-else=""><t t-set="headphoneTitle">Deafen</t></t>
|
||||
<button class="o_RtcController_button"
|
||||
t-att-class="{ 'o-active': !rtcSession.isDeaf }"
|
||||
t-att-aria-label="headphoneTitle"
|
||||
t-att-title="headphoneTitle"
|
||||
t-on-click="rtcController.onClickDeafen">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa" t-att-class="{
|
||||
'fa-lg': !rtcController.isSmall,
|
||||
'fa-headphones': !rtcSession.isDeaf,
|
||||
'fa-deaf': rtcSession.isDeaf,
|
||||
'text-danger': rtcSession.isDeaf,
|
||||
}"/>
|
||||
</div>
|
||||
</button>
|
||||
<t t-if="messaging.mailRtc.sendUserVideo"><t t-set="cameraTitle">Stop camera</t></t>
|
||||
<t t-else=""><t t-set="cameraTitle">Turn camera on</t></t>
|
||||
<button class="o_RtcController_button o_RtcController_videoButton"
|
||||
t-att-class="{
|
||||
'o-active': messaging.mailRtc.sendUserVideo,
|
||||
}"
|
||||
t-att-aria-label="cameraTitle"
|
||||
t-att-title="cameraTitle"
|
||||
t-on-click="rtcController.onClickCamera">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa fa-video-camera" t-att-class="{ 'fa-lg': !rtcController.isSmall }"/>
|
||||
</div>
|
||||
</button>
|
||||
<t t-if="messaging.mailRtc.sendDisplay"><t t-set="displayTitle">Stop screen sharing</t></t>
|
||||
<t t-else=""><t t-set="displayTitle">Share screen</t></t>
|
||||
<button class="o_RtcController_button o_RtcController_videoButton"
|
||||
t-att-class="{
|
||||
'o-active': messaging.mailRtc.sendDisplay,
|
||||
}"
|
||||
t-att-aria-label="displayTitle"
|
||||
t-att-title="displayTitle"
|
||||
t-on-click="rtcController.onClickScreen">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa fa-desktop" t-att-class="{ 'fa-lg': !rtcController.isSmall }"/>
|
||||
</div>
|
||||
</button>
|
||||
<Popover position="'top'">
|
||||
<button class="o_RtcController_button"
|
||||
aria-label="More"
|
||||
title="More">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa fa-ellipsis-h" t-att-class="{ 'fa-lg': !rtcController.isSmall }"/>
|
||||
</div>
|
||||
</button>
|
||||
<t t-set="opened">
|
||||
<RtcOptionList localId="rtcController.rtcOptionList.localId"/>
|
||||
</t>
|
||||
</Popover>
|
||||
</t>
|
||||
<t t-if="!thread.mailRtc">
|
||||
<button class="o_RtcController_button o_RtcController_callToggle"
|
||||
aria-label="Join Video Call"
|
||||
title="Join Video Call"
|
||||
t-att-disabled="thread.hasPendingRtcRequest"
|
||||
t-on-click="rtcController.onClickToggleVideoCall">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa fa-video-camera" t-att-class="{ 'fa-lg': !rtcController.isSmall }"/>
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="thread">
|
||||
<t t-if="thread.rtcInvitingSession and !thread.mailRtc">
|
||||
<button class="o_RtcController_button o_RtcController_callToggle o-active"
|
||||
aria-label="Reject"
|
||||
title="Reject"
|
||||
t-att-disabled="thread.hasPendingRtcRequest"
|
||||
t-on-click="rtcController.onClickRejectCall">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa fa-phone" t-att-class="{ 'fa-lg': !rtcController.isSmall }"/>
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="thread.mailRtc"><t t-set="phoneTitle">Disconnect</t></t>
|
||||
<t t-else=""><t t-set="phoneTitle">Join Call</t></t>
|
||||
<button class="o_RtcController_button o_RtcController_callToggle"
|
||||
t-att-aria-label="phoneTitle"
|
||||
t-att-class="{ 'o-active': !!thread.mailRtc }"
|
||||
t-att-disabled="thread.hasPendingRtcRequest"
|
||||
t-att-title="phoneTitle"
|
||||
t-on-click="rtcController.onClickToggleAudioCall">
|
||||
<div class="o_RtcController_button_iconWrapper">
|
||||
<i class="fa fa-phone" t-att-class="{ 'fa-lg': !rtcController.isSmall }"/>
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcInvitationCard" owl="1">
|
||||
<div class="o_RtcInvitationCard">
|
||||
<t t-if="thread and thread.rtcInvitingSession">
|
||||
<div class="o_RtcInvitationCard_partner_info">
|
||||
<img class="o_RtcInvitationCard_partner_info_image rounded-circle"
|
||||
t-att-src="thread.rtcInvitingSession.avatarUrl"
|
||||
t-on-click="_onClickAvatar"
|
||||
alt="Avatar"/>
|
||||
<span class="o_RtcInvitationCard_partner_info_name" t-esc="thread.rtcInvitingSession.name"/>
|
||||
<span class="o_RtcInvitationCard_partner_info_text">Incoming Call...</span>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_RtcInvitationCard_buttonList">
|
||||
<div class="o_RtcInvitationCard_buttonList_button o_RtcInvitationCard_buttonList_refuse"
|
||||
aria-label="Refuse"
|
||||
title="Refuse"
|
||||
t-on-click="_onClickRefuse">
|
||||
<i class="o_RtcInvitationCard_buttonList_button_icon fa fa-lg fa-times"/>
|
||||
</div>
|
||||
<div class="o_RtcInvitationCard_buttonList_button o_RtcInvitationCard_buttonList_accept"
|
||||
aria-label="Accept"
|
||||
title="Accept"
|
||||
t-on-click="_onClickAccept">
|
||||
<i class="o_RtcInvitationCard_buttonList_button_icon fa fa-lg fa-phone"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcInvitations" owl="1">
|
||||
<div class="o_RtcInvitations">
|
||||
<t t-if="threads">
|
||||
<t t-foreach="threads" t-as="thread" t-key="thread.localId">
|
||||
<RtcInvitationCard threadLocalId="thread.localId"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcLayoutMenu" owl="1">
|
||||
<div class="o_RtcLayoutMenu">
|
||||
<t t-if="layoutMenu">
|
||||
<t t-if="layoutMenu and layoutMenu.callViewer.threadView.thread.videoCount > 0">
|
||||
<label class="o_RtcLayoutMenu_radio">
|
||||
<div class="o_RtcLayoutMenu_radio_inputContainer">
|
||||
<input class="o_RtcLayoutMenu_radio_input" type="radio" name="filter" value="all" t-on-click="layoutMenu.onClickFilter" t-att-checked="!layoutMenu.callViewer.filterVideoGrid"/>
|
||||
<span class="o_RtcLayoutMenu_input_text">Show All</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="o_RtcLayoutMenu_radio">
|
||||
<div class="o_RtcLayoutMenu_radio_inputContainer">
|
||||
<input class="o_RtcLayoutMenu_radio_input" type="radio" name="filter" value="video" t-on-click="layoutMenu.onClickFilter" t-att-checked="layoutMenu.callViewer.filterVideoGrid"/>
|
||||
<span class="o_RtcLayoutMenu_input_text">Show only video</span>
|
||||
</div>
|
||||
</label>
|
||||
<hr class="o_RtcLayoutMenu_separator"/>
|
||||
</t>
|
||||
<label class="o_RtcLayoutMenu_radio">
|
||||
<div class="o_RtcLayoutMenu_radio_inputContainer">
|
||||
<input class="o_RtcLayoutMenu_radio_input" type="radio" name="layout" value="tiled" t-on-click="layoutMenu.onClickLayout" t-att-checked="messaging.userSetting.rtcLayout === 'tiled'"/>
|
||||
<span class="o_RtcLayoutMenu_input_text">Tiled</span>
|
||||
</div>
|
||||
<span class="o_RtcLayoutMenu_radio_icon">
|
||||
<t t-call="mail.RtcLayoutMenu_grid_svg"/>
|
||||
</span>
|
||||
</label>
|
||||
<label class="o_RtcLayoutMenu_radio">
|
||||
<div class="o_RtcLayoutMenu_radio_inputContainer">
|
||||
<input class="o_RtcLayoutMenu_radio_input" type="radio" name="layout" value="spotlight" t-on-click="layoutMenu.onClickLayout" t-att-checked="messaging.userSetting.rtcLayout === 'spotlight'"/>
|
||||
<span class="o_RtcLayoutMenu_input_text">Spotlight</span>
|
||||
</div>
|
||||
<span class="o_RtcLayoutMenu_radio_icon">
|
||||
<t t-call="mail.RtcLayoutMenu_spotlight_svg"/>
|
||||
</span>
|
||||
</label>
|
||||
<label class="o_RtcLayoutMenu_radio">
|
||||
<div class="o_RtcLayoutMenu_radio_inputContainer">
|
||||
<input class="o_RtcLayoutMenu_radio_input" type="radio" name="layout" value="sidebar" t-on-click="layoutMenu.onClickLayout" t-att-checked="messaging.userSetting.rtcLayout === 'sidebar'"/>
|
||||
<span class="o_RtcLayoutMenu_input_text">Sidebar</span>
|
||||
</div>
|
||||
<span class="o_RtcLayoutMenu_radio_icon">
|
||||
<t t-call="mail.RtcLayoutMenu_sidebar_svg"/>
|
||||
</span>
|
||||
</label>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- TODO TEMPORARY ICONS -->
|
||||
|
||||
<t t-name="mail.RtcLayoutMenu_grid_svg" owl="1">
|
||||
<svg width="5rem" height="5rem" viewBox="0 0 300 300">
|
||||
<g transform="matrix(1.08 0 0 1.29 63.85 96.61)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 150 96.61)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 236 96.61)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 63.85 150)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 63.85 204)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 150 150)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 150 204)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 236 150)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(1.08 0 0 1.29 236 204)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
</svg>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.RtcLayoutMenu_spotlight_svg" owl="1">
|
||||
<svg width="5rem" height="5rem" viewBox="0 0 300 300">
|
||||
<g transform="matrix(3.38 0 0 4.04 150 150)">
|
||||
<rect style="fill: currentColor" x="-38" y="-20" rx="0" ry="0" width="75" height="39"/>
|
||||
</g>
|
||||
</svg>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.RtcLayoutMenu_sidebar_svg" owl="1">
|
||||
<svg width="5rem" height="5rem" viewBox="0 0 300 300">
|
||||
<g transform="matrix(2.66 0 0 4.04 122.89 150.02)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(0.63 0 0 0.77 252.6 150.07)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(0.63 0 0 0.77 252.6 118.07)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(0.63 0 0 0.77 252.6 86.07)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(0.63 0 0 0.77 252.6 182.07)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
<g transform="matrix(0.63 0 0 0.77 252.6 214.07)">
|
||||
<rect style="fill: currentColor" x="-37.46" y="-19.415" rx="0" ry="0" width="74.92" height="38.83"/>
|
||||
</g>
|
||||
</svg>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcOptionList" owl="1">
|
||||
<div class="o_RtcOptionList">
|
||||
<t t-if="rtcOptionList">
|
||||
<button class="o_RtcOptionList_button" t-on-click="rtcOptionList.onClickLayout">
|
||||
<i class="o_RtcOptionList_button_icon fa fa-lg fa-th-large"/>
|
||||
<span class="o_RtcOptionList_button_text">Change layout</span>
|
||||
</button>
|
||||
<button class="o_RtcOptionList_button" t-on-click="rtcOptionList.onClickFullScreen">
|
||||
<i class="o_RtcOptionList_button_icon fa fa-lg fa-arrows-alt"/>
|
||||
<span class="o_RtcOptionList_button_text">Full screen</span>
|
||||
</button>
|
||||
<button class="o_RtcOptionList_button" t-on-click="rtcOptionList.onClickOptions">
|
||||
<i class="o_RtcOptionList_button_icon fa fa-lg fa-cog"/>
|
||||
<span class="o_RtcOptionList_button_text">Settings</span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.RtcVideo" owl="1">
|
||||
<video class="o_RtcVideo_video"
|
||||
t-ref="video"
|
||||
t-on-loadedmetadata="_onVideoLoadedMetaData"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -49,6 +49,10 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.o_ThreadView_videoRoom {
|
||||
min-height: 60%;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Style
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
</t>
|
||||
<div class="o_ThreadView_bottomPart d-flex flex-grow-1">
|
||||
<div class="o_ThreadView_core d-flex flex-column flex-grow-1">
|
||||
<t t-if="threadView.rtcCallViewer">
|
||||
<RtcCallViewer localId="threadView.rtcCallViewer.localId"/>
|
||||
</t>
|
||||
<t t-if="threadView.isLoading and !threadView.threadCache.isLoaded" name="loadingCondition">
|
||||
<div class="o_ThreadView_loading">
|
||||
<span><i class="o_ThreadView_loadingIcon fa fa-circle-o-notch fa-spin" title="Loading..." role="img"/>Loading...</span>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread === messaging.starred">
|
||||
<button class="o_ThreadViewTopbar_unstarAllButton btn btn-secondary" t-att-disabled="threadViewTopBar.threadView.messages.length === 0" t-on-click="threadViewTopBar.onClickUnstarAll">Unstar all</button>
|
||||
</t>
|
||||
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread.model === 'mail.channel' and threadViewTopBar.thread.rtcSessions.length === 0">
|
||||
<button class="o_ThreadViewTopbar_callButton o_ThreadViewTopbar_button o-active" t-att-disabled="threadViewTopBar.thread.hasPendingRtcRequest" title="Start a Call" t-on-click="_onClickPhone">
|
||||
<i class="fa fa-lg fa-phone"/>
|
||||
</button>
|
||||
<button class="o_ThreadViewTopbar_callButton o_ThreadViewTopbar_button o-active" t-att-disabled="threadViewTopBar.thread.hasPendingRtcRequest" title="Start a Video Call" t-on-click="_onClickCamera">
|
||||
<i class="fa fa-lg fa-video-camera"/>
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread.hasInviteFeature">
|
||||
<Popover popoverClass="'o_ThreadViewTopbar_invitePopover'" position="'bottom'">
|
||||
<button class="o_ThreadViewTopbar_inviteButton o_ThreadViewTopbar_button" t-att-class="{ 'o-active': threadViewTopBar.threadView.channelInvitationForm.component }" title="Add users" t-on-click="threadViewTopBar.onClickInviteButton">
|
||||
|
||||
@@ -49,6 +49,17 @@ function factory(dependencies) {
|
||||
const channel = this.messaging.models['mail.thread'].insert(
|
||||
this.messaging.models['mail.thread'].convertData(channelData)
|
||||
);
|
||||
if (this.thread.mailRtc) {
|
||||
/**
|
||||
* if we were in a RTC call on the current thread, we move to the new group chat.
|
||||
* A smoother transfer would be moving the RTC sessions from one channel to
|
||||
* the other (server-side too), but it would be considerably more complex.
|
||||
*/
|
||||
await this.async(() => channel.toggleCall({
|
||||
startWithVideo: !!this.thread.mailRtc.videoTrack,
|
||||
videoType: this.thread.mailRtc.sendUserVideo ? 'user-video' : 'display',
|
||||
}));
|
||||
}
|
||||
channel.open();
|
||||
} else {
|
||||
await this.env.services.rpc(({
|
||||
@@ -57,6 +68,7 @@ function factory(dependencies) {
|
||||
args: [[this.thread.id]],
|
||||
kwargs: {
|
||||
partner_ids: this.selectedPartners.map(partner => partner.id),
|
||||
invite_to_rtc_call: !!this.thread.mailRtc,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -172,6 +172,14 @@ function factory(dependencies) {
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_computeHasCallButtons() {
|
||||
return this.thread && this.thread.rtcSessions.length === 0 && ['channel', 'chat', 'group'].includes(this.thread.channel_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
@@ -259,6 +267,7 @@ function factory(dependencies) {
|
||||
*/
|
||||
_computeThreadViewer() {
|
||||
const threadViewerData = {
|
||||
compact: true,
|
||||
hasThreadView: this.hasThreadView,
|
||||
thread: this.thread ? link(this.thread) : unlink(),
|
||||
};
|
||||
@@ -346,6 +355,13 @@ function factory(dependencies) {
|
||||
}
|
||||
|
||||
ChatWindow.fields = {
|
||||
/**
|
||||
* Determines whether the buttons to start a RTC call should be displayed.
|
||||
*/
|
||||
hasCallButtons: attr({
|
||||
default: false,
|
||||
compute: '_computeHasCallButtons',
|
||||
}),
|
||||
/**
|
||||
* Determines whether "new message form" should be displayed.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, many2many, many2one, one2many, one2one } from '@mail/model/model_field';
|
||||
import { create } from '@mail/model/model_field_command';
|
||||
import { OnChange } from '@mail/model/model_onchange';
|
||||
import { create, replace, link, unlink } from '@mail/model/model_field_command';
|
||||
import { makeDeferred } from '@mail/utils/deferred/deferred';
|
||||
|
||||
const { EventBus } = owl.core;
|
||||
@@ -147,6 +148,25 @@ function factory(dependencies) {
|
||||
this.update({ isNotificationPermissionDefault: this._computeIsNotificationPermissionDefault() });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} sessionId
|
||||
*/
|
||||
toggleFocusedRtcSession(sessionId) {
|
||||
const rtcSession = this.messaging.models['mail.rtc_session'].findFromIdentifyingData({
|
||||
id: sessionId,
|
||||
});
|
||||
const focusedSessionId = this.focusedRtcSession && this.focusedRtcSession.id;
|
||||
if (!sessionId || focusedSessionId === sessionId || !rtcSession.videoStream) {
|
||||
this.update({ focusedRtcSession: unlink() });
|
||||
return;
|
||||
}
|
||||
this.update({ focusedRtcSession: link(rtcSession) });
|
||||
if (this.userSetting.rtcLayout !== 'tiled') {
|
||||
return;
|
||||
}
|
||||
this.userSetting.update({ rtcLayout: 'sidebar' });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
@@ -179,6 +199,15 @@ function factory(dependencies) {
|
||||
return browserNotification ? browserNotification.permission === 'default' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.partner[]}
|
||||
*/
|
||||
_computeRingingThreads() {
|
||||
const threads = this.messaging.models['mail.thread'].all().filter(thread => !!thread.rtcInvitingSession);
|
||||
return replace(threads);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -189,6 +218,24 @@ function factory(dependencies) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeRingingThreads() {
|
||||
if (this.ringingThreads && this.ringingThreads.length > 0) {
|
||||
this.soundEffects.incomingCall.play({ loop: true });
|
||||
} else {
|
||||
this.soundEffects.incomingCall.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeFocusedRtcSession() {
|
||||
this.mailRtc.filterIncomingVideoTraffic(this.focusedRtcSession && [this.focusedRtcSession.peerToken]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Messaging.fields = {
|
||||
@@ -238,6 +285,9 @@ function factory(dependencies) {
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
}),
|
||||
focusedRtcSession: one2one('mail.rtc_session', {
|
||||
inverse: 'focusingMessaging',
|
||||
}),
|
||||
/**
|
||||
* Mailbox History.
|
||||
*/
|
||||
@@ -283,6 +333,11 @@ function factory(dependencies) {
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
}),
|
||||
mailRtc: one2one('mail.rtc', {
|
||||
default: create(),
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* Determines after how much time in ms a "loading" indicator should be
|
||||
* shown. Useful to avoid flicker for almost instant loading.
|
||||
@@ -322,12 +377,38 @@ function factory(dependencies) {
|
||||
* which are special partners notably used in livechat.
|
||||
*/
|
||||
publicPartners: many2many('mail.partner'),
|
||||
/**
|
||||
* Threads for which the current partner has a pending invitation
|
||||
*/
|
||||
ringingThreads: many2many('mail.thread', {
|
||||
compute: '_computeRingingThreads',
|
||||
}),
|
||||
soundEffects: one2one('mail.sound_effects', {
|
||||
default: create(),
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* Mailbox Starred.
|
||||
*/
|
||||
starred: one2one('mail.thread'),
|
||||
userSetting: one2one('mail.user_setting', {
|
||||
default: create(),
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
Messaging.onChanges = [
|
||||
new OnChange({
|
||||
dependencies: ['ringingThreads'],
|
||||
methodName: '_onChangeRingingThreads',
|
||||
}),
|
||||
new OnChange({
|
||||
dependencies: ['focusedRtcSession'],
|
||||
methodName: '_onChangeFocusedRtcSession',
|
||||
}),
|
||||
];
|
||||
Messaging.modelName = 'mail.messaging';
|
||||
|
||||
return Messaging;
|
||||
|
||||
@@ -220,9 +220,27 @@ function factory(dependencies) {
|
||||
* @param {integer} resUsersSettings.id
|
||||
* @param {boolean} resUsersSettings.is_discuss_sidebar_category_channel_open
|
||||
* @param {boolean} resUsersSettings.is_discuss_sidebar_category_chat_open
|
||||
* @param {boolean} resUsersSettings.use_push_to_talk
|
||||
* @param {String} resUsersSettings.push_to_talk_key
|
||||
* @param {number} resUsersSettings.voice_active_duration
|
||||
* @param {Object} [resUsersSettings.volume_settings]
|
||||
*/
|
||||
_initResUsersSettings({ id, is_discuss_sidebar_category_channel_open, is_discuss_sidebar_category_chat_open }) {
|
||||
_initResUsersSettings({
|
||||
id,
|
||||
is_discuss_sidebar_category_channel_open,
|
||||
is_discuss_sidebar_category_chat_open,
|
||||
use_push_to_talk,
|
||||
push_to_talk_key,
|
||||
voice_active_duration,
|
||||
volume_settings = [],
|
||||
}) {
|
||||
this.messaging.currentUser.update({ resUsersSettingsId: id });
|
||||
this.messaging.userSetting.update({
|
||||
usePushToTalk: use_push_to_talk,
|
||||
pushToTalkKey: push_to_talk_key,
|
||||
voiceActiveDuration: voice_active_duration,
|
||||
volumeSettings: insert(volume_settings.map(volumeSetting => this.messaging.models['mail.volume_setting'].convertData(volumeSetting))),
|
||||
});
|
||||
this.messaging.discuss.update({
|
||||
categoryChannel: create({
|
||||
autocompleteMethod: 'channel',
|
||||
|
||||
+125
-1
@@ -1,7 +1,7 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { decrement, increment, insert, link } from '@mail/model/model_field_command';
|
||||
import { decrement, increment, insert, link, unlink } from '@mail/model/model_field_command';
|
||||
import { htmlToTextContentInline } from '@mail/js/utils';
|
||||
|
||||
import { str_to_datetime } from 'web.time';
|
||||
@@ -93,6 +93,20 @@ function factory(dependencies) {
|
||||
return this.messaging.models['mail.message'].insert(message.payload);
|
||||
case 'res.users_settings_changed':
|
||||
return this._handleNotificationResUsersSettings(message.payload);
|
||||
case 'rtc_peer_notification':
|
||||
return this._handleNotificationRtcPeerToPeer(message.payload);
|
||||
case 'rtc_incoming_invitation_update':
|
||||
return this._handleNotificationRtcInvitation(message.payload);
|
||||
case 'rtc_outgoing_invitation_ended':
|
||||
return this._handleNotificationRtcInvitationEnded(message.payload);
|
||||
case 'rtc_sessions_update':
|
||||
return this._handleNotificationRtcSessionUpdate(message.payload);
|
||||
case 'rtc_session_ended':
|
||||
return this._handleNotificationRtcSessionEnded(message.payload);
|
||||
case 'rtc_session_data_update':
|
||||
return this._handleNotificationRtcSessionDataUpdate(message.payload);
|
||||
case 'res_users_settings_volumes_update':
|
||||
return this._handleNotificationVolumeSettingUpdate(message.payload);
|
||||
}
|
||||
}
|
||||
const [, model, id] = channel;
|
||||
@@ -426,8 +440,12 @@ function factory(dependencies) {
|
||||
/**
|
||||
* @private
|
||||
* @param {object} settings
|
||||
* @param {boolean} settings.use_push_to_talk
|
||||
* @param {String} settings.push_to_talk_key
|
||||
* @param {number} settings.voice_active_duration
|
||||
* @param {boolean} [settings.is_discuss_sidebar_category_channel_open]
|
||||
* @param {boolean} [settings.is_discuss_sidebar_category_chat_open]
|
||||
* @param {Object} [payload.volume_settings]
|
||||
*/
|
||||
_handleNotificationResUsersSettings(settings) {
|
||||
if ('is_discuss_sidebar_category_channel_open' in settings) {
|
||||
@@ -440,6 +458,12 @@ function factory(dependencies) {
|
||||
isServerOpen: settings.is_discuss_sidebar_category_chat_open,
|
||||
});
|
||||
}
|
||||
this.messaging.userSetting.update({
|
||||
usePushToTalk: settings.use_push_to_talk,
|
||||
pushToTalkKey: settings.push_to_talk_key,
|
||||
voiceActiveDuration: settings.voice_active_duration,
|
||||
volumeSettings: settings.volume_settings && insert(settings.volume_settings.map(volumeSetting => this.messaging.models['mail.volume_setting'].convertData(volumeSetting))),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,6 +523,106 @@ function factory(dependencies) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {string} data.channelId
|
||||
* @param {string} [data.rtcSession]
|
||||
*/
|
||||
async _handleNotificationRtcInvitation({ channelId, rtcSession }) {
|
||||
const channel = this.messaging.models['mail.thread'].findFromIdentifyingData({ id: channelId, model: 'mail.channel' });
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
if (channel.mailRtc) {
|
||||
return;
|
||||
}
|
||||
if (rtcSession) {
|
||||
return channel.update({ rtcInvitingSession: insert(this.messaging.models['mail.rtc_session'].convertData(rtcSession)) });
|
||||
}
|
||||
return channel.update({ rtcInvitingSession: unlink() });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {number} [data.channelId]
|
||||
* @param {number} [data.partnerId]
|
||||
*/
|
||||
async _handleNotificationRtcInvitationEnded({ channelId, partnerId, guestId }) {
|
||||
const channel = this.messaging.models['mail.thread'].findFromIdentifyingData({ id: channelId, model: 'mail.channel' });
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
const partner = partnerId && this.messaging.models['mail.partner'].findFromIdentifyingData({ id: partnerId });
|
||||
const guest = guestId && this.messaging.models['mail.guest'].findFromIdentifyingData({ id: guestId });
|
||||
channel.update({
|
||||
invitedPartners: partner && unlink(partner),
|
||||
invitedGuests: guest && unlink(guest),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {string} [data.sender]
|
||||
* @param {string} [data.content]
|
||||
*/
|
||||
_handleNotificationRtcPeerToPeer({ sender, content }) {
|
||||
this.messaging.mailRtc.handleNotification(sender, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object} [data.rtcSession]
|
||||
*/
|
||||
async _handleNotificationRtcSessionDataUpdate({ rtcSession }) {
|
||||
const rtcSessionModel = this.messaging.models['mail.rtc_session'];
|
||||
rtcSessionModel.insert(rtcSessionModel.convertData(rtcSession));
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object} [data.volumeSettings]
|
||||
*/
|
||||
async _handleNotificationVolumeSettingUpdate({ volumeSettings }) {
|
||||
this.messaging && this.messaging.userSetting.update({
|
||||
volumeSettings: volumeSettings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {number} [sessionId]
|
||||
*/
|
||||
async _handleNotificationRtcSessionEnded({ sessionId }) {
|
||||
const currentSession = this.messaging.mailRtc.currentRtcSession;
|
||||
if (currentSession && currentSession.id === sessionId) {
|
||||
this.messaging.mailRtc.channel.endCall();
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("Disconnected from the RTC call by the server"),
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {string} [data.channelId]
|
||||
* @param {Object} [data.rtcSessions]
|
||||
*/
|
||||
async _handleNotificationRtcSessionUpdate({ channelId, rtcSessions }) {
|
||||
const channel = this.messaging.models['mail.thread'].findFromIdentifyingData({ id: channelId, model: 'mail.channel' });
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
channel.updateRtcSessions(rtcSessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
|
||||
@@ -43,6 +43,9 @@ function factory(dependencies) {
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('is_muted' in data) {
|
||||
data2.isMuted = data.is_muted;
|
||||
}
|
||||
if ('im_status' in data) {
|
||||
data2.im_status = data.im_status;
|
||||
}
|
||||
@@ -478,6 +481,9 @@ function factory(dependencies) {
|
||||
isOnline: attr({
|
||||
compute: '_computeIsOnline',
|
||||
}),
|
||||
isMuted: attr({
|
||||
default: false,
|
||||
}),
|
||||
memberThreads: many2many('mail.thread', {
|
||||
inverse: 'members',
|
||||
}),
|
||||
@@ -491,9 +497,15 @@ function factory(dependencies) {
|
||||
nameOrDisplayName: attr({
|
||||
compute: '_computeNameOrDisplayName',
|
||||
}),
|
||||
rtcSessions: one2many('mail.rtc_session', {
|
||||
inverse: 'partner',
|
||||
}),
|
||||
user: one2one('mail.user', {
|
||||
inverse: 'partner',
|
||||
}),
|
||||
volumeSetting: one2one('mail.volume_setting', {
|
||||
inverse: 'partner',
|
||||
}),
|
||||
};
|
||||
|
||||
Partner.modelName = 'mail.partner';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, many2one, one2one } from '@mail/model/model_field';
|
||||
import { unlink } from '@mail/model/model_field_command';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcCallParticipantCard extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
super._created();
|
||||
this.onChangeVolume = this.onChangeVolume.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onClickVideo = this.onClickVideo.bind(this);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onChangeVolume(ev) {
|
||||
this.rtcSession && this.rtcSession.setVolume(parseFloat(ev.target.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClick(ev) {
|
||||
if (!this.invitedPartner || this.invitedGuest) {
|
||||
return;
|
||||
}
|
||||
await this.env.services.rpc(({
|
||||
route: '/mail/rtc/channel/cancel_call_invitation',
|
||||
params: {
|
||||
channel_id: this.channel.id,
|
||||
partner_ids: this.invitedPartner && [this.invitedPartner.id],
|
||||
guest_ids: this.invitedGuest && [this.invitedGuest.id],
|
||||
},
|
||||
}));
|
||||
this.channel.update({
|
||||
invitedGuests: this.invitedGuest && unlink(this.invitedGuest),
|
||||
invitedPartners: this.invitedPartner && unlink(this.invitedPartner),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickVideo(ev) {
|
||||
ev.stopPropagation();
|
||||
this.messaging.toggleFocusedRtcSession(this.rtcSession.id);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static _createRecordLocalId(data) {
|
||||
return `${this.modelName}_${data.relationalId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeAvatarUrl() {
|
||||
if (!this.channel) {
|
||||
return;
|
||||
}
|
||||
if (this.rtcSession) {
|
||||
return this.rtcSession.avatarUrl;
|
||||
}
|
||||
if (this.invitedPartner) {
|
||||
return `/mail/channel/${this.channel.id}/partner/${this.invitedPartner.id}/avatar_128`;
|
||||
}
|
||||
if (this.invitedGuest) {
|
||||
return `/mail/channel/${this.channel.id}/guest/${this.invitedGuest.id}/avatar_128`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_computeIsMinimized() {
|
||||
const callViewer = this.rtcCallViewerOfMainCard || this.rtcCallViewerOfTile;
|
||||
return callViewer && callViewer.isMinimized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_computeIsTalking() {
|
||||
return this.rtcSession && this.rtcSession.isTalking && !this.rtcSession.isMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeName() {
|
||||
if (this.rtcSession) {
|
||||
return this.rtcSession.name;
|
||||
}
|
||||
if (this.invitedPartner) {
|
||||
return this.invitedPartner.name;
|
||||
}
|
||||
if (this.invitedGuest) {
|
||||
return this.invitedGuest.name;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcCallParticipantCard.fields = {
|
||||
/**
|
||||
* The relative url of the image that represents the card.
|
||||
*/
|
||||
avatarUrl: attr({
|
||||
compute: '_computeAvatarUrl',
|
||||
}),
|
||||
/**
|
||||
* The channel of the call.
|
||||
*/
|
||||
channel: many2one('mail.thread', {
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* If set, this card represents an invitation of this guest to this call.
|
||||
*/
|
||||
invitedGuest: many2one('mail.guest'),
|
||||
/**
|
||||
* If set, this card represents an invitation of this partner to this call.
|
||||
*/
|
||||
invitedPartner: many2one('mail.partner'),
|
||||
/**
|
||||
* Determines if this card has to be displayed in a minimized form.
|
||||
*/
|
||||
isMinimized: attr({
|
||||
default: false,
|
||||
compute: '_computeIsMinimized',
|
||||
}),
|
||||
/**
|
||||
* Determines if the rtcSession is in a valid "talking" state.
|
||||
*/
|
||||
isTalking: attr({
|
||||
default: false,
|
||||
compute: '_computeIsTalking',
|
||||
}),
|
||||
/**
|
||||
* The name of the rtcSession or the invited partner.
|
||||
*/
|
||||
name: attr({
|
||||
default: 'Anonymous',
|
||||
compute: '_computeName',
|
||||
}),
|
||||
/**
|
||||
* Unique id for this session provided when instantiated.
|
||||
*/
|
||||
relationalId: attr({
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* The callViewer for which this card is the spotlight.
|
||||
*/
|
||||
rtcCallViewerOfMainCard: one2one('mail.rtc_call_viewer', {
|
||||
inverse: 'mainParticipantCard',
|
||||
}),
|
||||
/**
|
||||
* The callViewer for which this card is one of the tiles.
|
||||
*/
|
||||
rtcCallViewerOfTile: many2one('mail.rtc_call_viewer', {
|
||||
inverse: 'tileParticipantCards',
|
||||
}),
|
||||
/**
|
||||
* If set, this card represents a rtcSession.
|
||||
*/
|
||||
rtcSession: many2one('mail.rtc_session'),
|
||||
};
|
||||
|
||||
RtcCallParticipantCard.modelName = 'mail.rtc_call_participant_card';
|
||||
|
||||
return RtcCallParticipantCard;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_call_participant_card', factory);
|
||||
@@ -0,0 +1,424 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one, one2many } from '@mail/model/model_field';
|
||||
import { OnChange } from '@mail/model/model_onchange';
|
||||
import { clear, create, insert, insertAndReplace, link, unlink } from '@mail/model/model_field_command';
|
||||
|
||||
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcCallViewer extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
super._created();
|
||||
this._timeoutId = undefined;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onLayoutSettingsDialogClosed = this.onLayoutSettingsDialogClosed.bind(this);
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.onMouseMoveOverlay = this.onMouseMoveOverlay.bind(this);
|
||||
this.onRtcSettingsDialogClosed = this.onRtcSettingsDialogClosed.bind(this);
|
||||
this._onFullScreenChange = this._onFullScreenChange.bind(this);
|
||||
browser.addEventListener('fullscreenchange', this._onFullScreenChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_willDelete() {
|
||||
browser.removeEventListener('fullscreenchange', this._onFullScreenChange);
|
||||
return super._willDelete(...arguments);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
this._showOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onLayoutSettingsDialogClosed(ev) {
|
||||
this.toggleLayoutMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseMove(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (isEventHandled(ev, 'RtcCallViewer.MouseMoveOverlay')) {
|
||||
return;
|
||||
}
|
||||
this._showOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseMoveOverlay(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
markEventHandled(ev, 'RtcCallViewer.MouseMoveOverlay');
|
||||
this.update({
|
||||
showOverlay: true,
|
||||
});
|
||||
this._timeoutId && browser.clearTimeout(this._timeoutId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onRtcSettingsDialogClosed(ev) {
|
||||
this.messaging.userSetting.rtcConfigurationMenu.toggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} force Force the fullScreen state.
|
||||
*/
|
||||
async toggleFullScreen(force) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
const el = document.body;
|
||||
const fullScreenElement = document.webkitFullscreenElement || document.fullscreenElement;
|
||||
if (force !== undefined ? force : !fullScreenElement) {
|
||||
try {
|
||||
if (el.requestFullscreen) {
|
||||
await el.requestFullscreen();
|
||||
} else if (el.mozRequestFullScreen) {
|
||||
await el.mozRequestFullScreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
await el.webkitRequestFullscreen();
|
||||
}
|
||||
this.update({ isFullScreen: true });
|
||||
} catch (e) {
|
||||
this.update({ isFullScreen: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (fullScreenElement) {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
await document.mozCancelFullScreen();
|
||||
} else if (document.webkitCancelFullScreen) {
|
||||
await document.webkitCancelFullScreen();
|
||||
}
|
||||
this.update({ isFullScreen: false });
|
||||
}
|
||||
}
|
||||
|
||||
toggleLayoutMenu() {
|
||||
if (!this.rtcLayoutMenu) {
|
||||
this.update({ rtcLayoutMenu: create() });
|
||||
return;
|
||||
}
|
||||
this.update({ rtcLayoutMenu: unlink() });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeAspectRatio() {
|
||||
const rtcAspectRatio = this.messaging.mailRtc.videoConfig && this.messaging.mailRtc.videoConfig.aspectRatio;
|
||||
const aspectRatio = rtcAspectRatio || 16 / 9;
|
||||
// if we are in minimized mode (round avatar frames), we treat the cards like squares.
|
||||
return this.isMinimized ? 1 : aspectRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeIsControllerFloating() {
|
||||
return (
|
||||
this.isFullScreen ||
|
||||
this.layout !== "tiled" && !this.threadView.compact
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeIsMinimized() {
|
||||
if (!this.threadView) {
|
||||
return true;
|
||||
}
|
||||
if (this.isFullScreen || this.threadView.compact) {
|
||||
return false;
|
||||
}
|
||||
return !this.threadView.thread.mailRtc || this.threadView.thread.videoCount === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeLayout() {
|
||||
if (!this.threadView) {
|
||||
return 'tiled';
|
||||
}
|
||||
if (!this.threadView.thread.mailRtc) {
|
||||
return 'tiled';
|
||||
}
|
||||
if (!this.threadView.thread.videoCount || !this.mainParticipantCard) {
|
||||
return 'tiled';
|
||||
}
|
||||
if (
|
||||
this.threadView.thread.rtcSessions.length +
|
||||
this.threadView.thread.invitedPartners.length +
|
||||
this.threadView.thread.invitedGuests.length < 2
|
||||
) {
|
||||
return "tiled";
|
||||
}
|
||||
if (this.threadView.compact && this.messaging.userSetting.rtcLayout === 'sidebar') {
|
||||
return 'spotlight';
|
||||
}
|
||||
return this.messaging.userSetting.rtcLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeMainParticipantCard() {
|
||||
if (!this.messaging || !this.threadView) {
|
||||
return unlink();
|
||||
}
|
||||
if (this.messaging.focusedRtcSession && this.messaging.focusedRtcSession.channel === this.threadView.thread) {
|
||||
return insert({
|
||||
relationalId: `rtc_session_${this.messaging.focusedRtcSession.localId}_${this.threadView.thread.localId}`,
|
||||
rtcSession: link(this.messaging.focusedRtcSession),
|
||||
channel: link(this.threadView.thread),
|
||||
});
|
||||
}
|
||||
return unlink();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeTileParticipantCards() {
|
||||
if (!this.threadView) {
|
||||
return clear();
|
||||
}
|
||||
const tileCards = [];
|
||||
const sessionPartners = new Set();
|
||||
const sessionGuests = new Set();
|
||||
for (const rtcSession of this.threadView.thread.rtcSessions) {
|
||||
rtcSession.partner && sessionPartners.add(rtcSession.partner.id);
|
||||
rtcSession.guest && sessionPartners.add(rtcSession.guest.id);
|
||||
tileCards.push({
|
||||
relationalId: `rtc_session_${rtcSession.localId}_${this.threadView.thread.localId}`,
|
||||
rtcSession: link(rtcSession),
|
||||
channel: link(this.threadView.thread),
|
||||
});
|
||||
}
|
||||
for (const partner of this.threadView.thread.invitedPartners) {
|
||||
if (sessionPartners.has(partner.id)) {
|
||||
continue;
|
||||
}
|
||||
tileCards.push({
|
||||
relationalId: `invited_partner_${partner.localId}_${this.threadView.thread.localId}`,
|
||||
invitedPartner: link(partner),
|
||||
channel: link(this.threadView.thread),
|
||||
});
|
||||
}
|
||||
for (const guest of this.threadView.thread.invitedGuests) {
|
||||
if (sessionGuests.has(guest.id)) {
|
||||
continue;
|
||||
}
|
||||
tileCards.push({
|
||||
relationalId: `invited_guest_${guest.localId}_${this.threadView.thread.localId}`,
|
||||
invitedGuest: link(guest),
|
||||
channel: link(this.threadView.thread),
|
||||
});
|
||||
}
|
||||
return insertAndReplace(tileCards);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_debounce(f, { delay = 0 } = {}) {
|
||||
this._timeoutId && browser.clearTimeout(this._timeoutId);
|
||||
this._timeoutId = browser.setTimeout(() => {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
f();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeMailRtcChannel() {
|
||||
this.toggleFullScreen(false);
|
||||
this.update({ filterVideoGrid: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeVideoCount() {
|
||||
if (this.threadView.thread.videoCount === 0) {
|
||||
this.update({ filterVideoGrid: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the overlay (buttons) for a set a mount of time.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_showOverlay() {
|
||||
this.update({
|
||||
showOverlay: true,
|
||||
});
|
||||
this._debounce(() => {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ showOverlay: false });
|
||||
}, { delay: 3000 });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Handlers
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onFullScreenChange() {
|
||||
const fullScreenElement = document.webkitFullscreenElement || document.fullscreenElement;
|
||||
if (fullScreenElement) {
|
||||
this.update({ isFullScreen: true });
|
||||
return;
|
||||
}
|
||||
this.update({ isFullScreen: false });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcCallViewer.fields = {
|
||||
/**
|
||||
* The aspect ratio of the tiles.
|
||||
*/
|
||||
aspectRatio: attr({
|
||||
default: 16 / 9,
|
||||
compute: '_computeAspectRatio',
|
||||
}),
|
||||
/**
|
||||
* Determines whether we only display the videos or all the participants
|
||||
*/
|
||||
filterVideoGrid: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the controller is an overlay or a bottom bar.
|
||||
*/
|
||||
isControllerFloating: attr({
|
||||
default: false,
|
||||
compute: '_computeIsControllerFloating',
|
||||
}),
|
||||
/**
|
||||
* Determines if the viewer should be displayed fullScreen.
|
||||
*/
|
||||
isFullScreen: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the tiles are in a minimized format:
|
||||
* small circles instead of cards, smaller display area.
|
||||
*/
|
||||
isMinimized: attr({
|
||||
default: false,
|
||||
compute: '_computeIsMinimized',
|
||||
}),
|
||||
/**
|
||||
* Determines the layout use for the tiling of the participant cards.
|
||||
*/
|
||||
layout: attr({
|
||||
default: 'tiled',
|
||||
compute: '_computeLayout',
|
||||
}),
|
||||
/**
|
||||
* If set, the card to be displayed as the "main/spotlight" card.
|
||||
*/
|
||||
mainParticipantCard: one2one('mail.rtc_call_participant_card', {
|
||||
compute: '_computeMainParticipantCard',
|
||||
inverse: 'rtcCallViewerOfMainCard',
|
||||
}),
|
||||
/**
|
||||
* The model for the controller (buttons).
|
||||
*/
|
||||
rtcController: one2one('mail.rtc_controller', {
|
||||
default: create(),
|
||||
readonly: true,
|
||||
required: true,
|
||||
inverse: 'callViewer',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* The model for the menu to control the layout of the viewer.
|
||||
*/
|
||||
rtcLayoutMenu: one2one('mail.rtc_layout_menu', {
|
||||
inverse: 'callViewer',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Determines if we show the overlay with the control buttons.
|
||||
*/
|
||||
showOverlay: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* ThreadView on which the call viewer is attached.
|
||||
*/
|
||||
threadView: one2one('mail.thread_view', {
|
||||
inverse: 'rtcCallViewer',
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* List of all participant cards (can either be invitations or rtcSessions).
|
||||
*/
|
||||
tileParticipantCards: one2many('mail.rtc_call_participant_card', {
|
||||
compute: '_computeTileParticipantCards',
|
||||
inverse: 'rtcCallViewerOfTile',
|
||||
}),
|
||||
};
|
||||
RtcCallViewer.onChanges = [
|
||||
new OnChange({
|
||||
dependencies: ['threadView.thread.mailRtc'],
|
||||
methodName: '_onChangeMailRtcChannel',
|
||||
}),
|
||||
new OnChange({
|
||||
dependencies: ['threadView.thread.videoCount'],
|
||||
methodName: '_onChangeVideoCount',
|
||||
}),
|
||||
];
|
||||
RtcCallViewer.modelName = 'mail.rtc_call_viewer';
|
||||
|
||||
return RtcCallViewer;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_call_viewer', factory);
|
||||
@@ -0,0 +1,124 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one } from '@mail/model/model_field';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcConfigurationMenu extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
const res = super._created(...arguments);
|
||||
this._onKeyDown = this._onKeyDown.bind(this);
|
||||
this._onKeyUp = this._onKeyUp.bind(this);
|
||||
browser.addEventListener('keydown', this._onKeyDown);
|
||||
browser.addEventListener('keyup', this._onKeyUp);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_willDelete() {
|
||||
browser.removeEventListener('keydown', this._onKeyDown);
|
||||
browser.removeEventListener('keyup', this._onKeyUp);
|
||||
return super._willDelete(...arguments);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
*/
|
||||
onChangeDelay(value) {
|
||||
this.userSetting.setDelayValue(value);
|
||||
}
|
||||
|
||||
onChangePushToTalk() {
|
||||
if (this.userSetting.usePushToTalk) {
|
||||
this.update({
|
||||
isRegisteringKey: false,
|
||||
});
|
||||
}
|
||||
this.userSetting.togglePushToTalk();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
*/
|
||||
onChangeSelectAudioInput(value) {
|
||||
this.userSetting.setAudioInputDevice(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
*/
|
||||
onChangeThreshold(value) {
|
||||
this.userSetting.setThresholdValue(parseFloat(value));
|
||||
}
|
||||
|
||||
onClickRegisterKeyButton() {
|
||||
this.update({
|
||||
isRegisteringKey: !this.isRegisteringKey,
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.update({ isOpen: !this.isOpen });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
_onKeyDown(ev) {
|
||||
if (!this.isRegisteringKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.userSetting.setPushToTalkKey(ev);
|
||||
}
|
||||
|
||||
_onKeyUp(ev) {
|
||||
if (!this.isRegisteringKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.update({
|
||||
isRegisteringKey: false,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcConfigurationMenu.fields = {
|
||||
isOpen: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* true if listening to keyboard input to register the push to talk key.
|
||||
*/
|
||||
isRegisteringKey: attr({
|
||||
default: false,
|
||||
}),
|
||||
userSetting: one2one('mail.user_setting', {
|
||||
inverse: 'rtcConfigurationMenu',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
RtcConfigurationMenu.modelName = 'mail.rtc_configuration_menu';
|
||||
|
||||
return RtcConfigurationMenu;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_configuration_menu', factory);
|
||||
@@ -0,0 +1,113 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one } from '@mail/model/model_field';
|
||||
import { create } from '@mail/model/model_field_command';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcController extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
super._created();
|
||||
this.onClickCamera = this.onClickCamera.bind(this);
|
||||
this.onClickDeafen = this.onClickDeafen.bind(this);
|
||||
this.onClickMicrophone = this.onClickMicrophone.bind(this);
|
||||
this.onClickRejectCall = this.onClickRejectCall.bind(this);
|
||||
this.onClickScreen = this.onClickScreen.bind(this);
|
||||
this.onClickToggleAudioCall = this.onClickToggleAudioCall.bind(this);
|
||||
this.onClickToggleVideoCall = this.onClickToggleVideoCall.bind(this);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCamera(ev) {
|
||||
this.messaging.mailRtc.toggleUserVideo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickDeafen(ev) {
|
||||
await this.messaging.mailRtc.currentRtcSession.toggleDeaf();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMicrophone(ev) {
|
||||
this.messaging.mailRtc.toggleMicrophone();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickRejectCall(ev) {
|
||||
await this.callViewer.threadView.thread.leaveCall();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickScreen(ev) {
|
||||
this.messaging.mailRtc.toggleScreenShare();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickToggleAudioCall(ev) {
|
||||
await this.callViewer.threadView.thread.toggleCall();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickToggleVideoCall(ev) {
|
||||
await this.callViewer.threadView.thread.toggleCall({
|
||||
startWithVideo: true,
|
||||
});
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeIsSmall() {
|
||||
return this.callViewer && this.callViewer.threadView.compact && !this.callViewer.isFullScreen;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcController.fields = {
|
||||
callViewer: one2one('mail.rtc_call_viewer', {
|
||||
inverse: 'rtcController',
|
||||
}),
|
||||
isSmall: attr({
|
||||
compute: '_computeIsSmall',
|
||||
}),
|
||||
rtcOptionList: one2one('mail.rtc_option_list', {
|
||||
default: create(),
|
||||
inverse: 'rtcController',
|
||||
isCausal: true,
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
RtcController.modelName = 'mail.rtc_controller';
|
||||
|
||||
return RtcController;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_controller', factory);
|
||||
@@ -0,0 +1,67 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one } from '@mail/model/model_field';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcLayoutMenu extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
super._created();
|
||||
this.onClickFilter = this.onClickFilter.bind(this);
|
||||
this.onClickLayout = this.onClickLayout.bind(this);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickFilter(ev) {
|
||||
ev.preventDefault();
|
||||
switch (ev.target.value) {
|
||||
case 'all':
|
||||
this.callViewer.update({
|
||||
filterVideoGrid: false,
|
||||
});
|
||||
break;
|
||||
case 'video':
|
||||
this.callViewer.update({
|
||||
filterVideoGrid: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickLayout(ev) {
|
||||
ev.preventDefault();
|
||||
this.messaging.userSetting.update({
|
||||
rtcLayout: ev.target.value,
|
||||
});
|
||||
this.component.trigger('dialog-closed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcLayoutMenu.fields = {
|
||||
component: attr(),
|
||||
callViewer: one2one('mail.rtc_call_viewer', {
|
||||
inverse: 'rtcLayoutMenu',
|
||||
}),
|
||||
};
|
||||
|
||||
RtcLayoutMenu.modelName = 'mail.rtc_layout_menu';
|
||||
|
||||
return RtcLayoutMenu;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_layout_menu', factory);
|
||||
@@ -0,0 +1,65 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one } from '@mail/model/model_field';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcOptionList extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
super._created();
|
||||
this.onClickFullScreen = this.onClickFullScreen.bind(this);
|
||||
this.onClickLayout = this.onClickLayout.bind(this);
|
||||
this.onClickOptions = this.onClickOptions.bind(this);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickFullScreen(ev) {
|
||||
this.rtcController.callViewer.toggleFullScreen();
|
||||
this.component.trigger('o-popover-close');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickLayout(ev) {
|
||||
this.rtcController.callViewer.toggleLayoutMenu();
|
||||
this.component.trigger('o-popover-close');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickOptions(ev) {
|
||||
this.messaging.userSetting.rtcConfigurationMenu.toggle();
|
||||
this.component.trigger('o-popover-close');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcOptionList.fields = {
|
||||
/**
|
||||
* States the OWL component of this option list.
|
||||
*/
|
||||
component: attr(),
|
||||
rtcController: one2one('mail.rtc_controller', {
|
||||
inverse: 'rtcOptionList',
|
||||
}),
|
||||
};
|
||||
|
||||
RtcOptionList.modelName = 'mail.rtc_option_list';
|
||||
|
||||
return RtcOptionList;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_option_list', factory);
|
||||
@@ -0,0 +1,473 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, many2one, one2one, one2many } from '@mail/model/model_field';
|
||||
import { clear, insert, unlink } from '@mail/model/model_field_command';
|
||||
import { OnChange } from '@mail/model/model_onchange';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class RtcSession extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
super._created();
|
||||
this._timeoutId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_willDelete() {
|
||||
this.reset();
|
||||
return super._willDelete(...arguments);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {Object} data
|
||||
* @return {Object}
|
||||
*/
|
||||
static convertData(data) {
|
||||
const data2 = {};
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('is_camera_on' in data) {
|
||||
data2.isCameraOn = data.is_camera_on;
|
||||
}
|
||||
if ('is_deaf' in data) {
|
||||
data2.isDeaf = data.is_deaf;
|
||||
}
|
||||
if ('is_muted' in data) {
|
||||
data2.isMuted = data.is_muted;
|
||||
}
|
||||
if ('is_screen_sharing_on' in data) {
|
||||
data2.isScreenSharingOn = data.is_screen_sharing_on;
|
||||
}
|
||||
|
||||
// relations
|
||||
if ('partner' in data) {
|
||||
data2.partner = insert(data.partner);
|
||||
}
|
||||
if ('guest' in data) {
|
||||
data2.guest = insert(data.guest);
|
||||
}
|
||||
if ('channel' in data) {
|
||||
data2.channel = insert(data.channel);
|
||||
}
|
||||
return data2;
|
||||
}
|
||||
|
||||
/**
|
||||
* restores the session to its default values
|
||||
*/
|
||||
reset() {
|
||||
this._timeoutId && browser.clearTimeout(this._timeoutId);
|
||||
this._removeAudio();
|
||||
this.removeVideo();
|
||||
this.update({
|
||||
audioElement: clear(),
|
||||
isTalking: clear(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* cleanly removes the video stream of the session
|
||||
*
|
||||
* @param {Object} [param0]
|
||||
* @param {Object} [param0.stopTracks] true if tracks have to be stopped,
|
||||
* it is optional as tracks can be removed but still necessary for transceivers.
|
||||
*/
|
||||
removeVideo({ stopTracks = true } = {}) {
|
||||
if (this.videoStream && stopTracks) {
|
||||
for (const track of this.videoStream.getTracks() || []) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
this.update({
|
||||
videoStream: clear(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {MediaStream} param0.audioStream
|
||||
* @param {boolean} param0.isMuted
|
||||
* @param {boolean} param0.isTalking
|
||||
*/
|
||||
setAudio({ audioStream, isMuted, isTalking }) {
|
||||
this._removeAudio();
|
||||
const audioElement = this.audioElement || new window.Audio();
|
||||
audioElement.srcObject = audioStream;
|
||||
audioElement.volume = this.partner && this.partner.volumeSetting ? this.partner.volumeSetting.volume : this.volume;
|
||||
audioElement.muted = this.messaging.mailRtc.currentRtcSession.isDeaf;
|
||||
audioElement.play();
|
||||
this.update({
|
||||
audioElement,
|
||||
audioStream,
|
||||
isMuted,
|
||||
isTalking,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} volume
|
||||
*/
|
||||
setVolume(volume) {
|
||||
/**
|
||||
* Manually updating the volume field as it will not update based on
|
||||
* the change of the volume property of the audioElement alone.
|
||||
*/
|
||||
this.update({ volume });
|
||||
if (this.audioElement) {
|
||||
this.audioElement.volume = volume;
|
||||
}
|
||||
if (!this.partner || this.isOwnSession) {
|
||||
return;
|
||||
}
|
||||
if (this.partner.volumeSetting) {
|
||||
this.partner.volumeSetting.update({ volume });
|
||||
}
|
||||
this.messaging.userSetting.saveVolumeSetting(this.partner.id, volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the deaf state of the current session, this must be a session
|
||||
* of the current partner.
|
||||
*/
|
||||
async toggleDeaf() {
|
||||
if (!this.mailRtc) {
|
||||
return;
|
||||
}
|
||||
this.updateAndBroadcast({
|
||||
isDeaf: !this.isDeaf,
|
||||
});
|
||||
for (const session of this.messaging.models['mail.rtc_session'].all()) {
|
||||
if (!session.audioElement) {
|
||||
continue;
|
||||
}
|
||||
session.audioElement.muted = this.isDeaf;
|
||||
}
|
||||
if (this.channel.mailRtc) {
|
||||
/**
|
||||
* Ensures that the state of the microphone matches the deaf state
|
||||
* and notifies peers.
|
||||
*/
|
||||
await this.async(() => this.channel.mailRtc.toggleMicrophone({
|
||||
requestAudioDevice: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the record and notifies the server of the change
|
||||
*
|
||||
* @param {Object} data
|
||||
*/
|
||||
updateAndBroadcast(data) {
|
||||
if (!this.mailRtc) {
|
||||
return;
|
||||
}
|
||||
this.update(data);
|
||||
this._debounce(async () => {
|
||||
await this.async(() => {
|
||||
this.env.services.rpc(
|
||||
{
|
||||
route: '/mail/rtc/session/update_and_broadcast',
|
||||
params: {
|
||||
session_id: this.id,
|
||||
values: {
|
||||
is_camera_on: this.isCameraOn,
|
||||
is_deaf: this.isDeaf,
|
||||
is_muted: this.isMuted,
|
||||
is_screen_sharing_on: this.isScreenSharingOn,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ shadow: true }
|
||||
);
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeAvatarUrl() {
|
||||
if (!this.channel) {
|
||||
return;
|
||||
}
|
||||
if (this.partner) {
|
||||
return `/mail/channel/${this.channel.id}/partner/${this.partner.id}/avatar_128`;
|
||||
}
|
||||
if (this.guest) {
|
||||
return `/mail/channel/${this.channel.id}/guest/${this.guest.id}/avatar_128`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_computeIsOwnSession() {
|
||||
if (!this.messaging) {
|
||||
return;
|
||||
}
|
||||
return (this.partner && this.messaging.currentPartner === this.partner) ||
|
||||
(this.guest && this.messaging.currentGuest === this.guest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeName() {
|
||||
if (this.partner) {
|
||||
return this.partner.name;
|
||||
}
|
||||
if (this.guest) {
|
||||
return this.guest.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computePeerToken() {
|
||||
return String(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {number} float
|
||||
*/
|
||||
_computeVolume() {
|
||||
if (this.partner && this.partner.volumeSetting) {
|
||||
return this.partner.volumeSetting.volume;
|
||||
}
|
||||
if (this.audioElement) {
|
||||
return this.audioElement.volume;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static _createRecordLocalId(data) {
|
||||
return `${this.modelName}_${data.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_debounce(f, delay) {
|
||||
this._timeoutId && browser.clearTimeout(this._timeoutId);
|
||||
this._timeoutId = browser.setTimeout(() => {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
f();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeVideoStream() {
|
||||
if (!this.videoStream) {
|
||||
this.update({ focusingMessaging: unlink() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* cleanly removes the audio stream of the session
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_removeAudio() {
|
||||
if (this.audioStream) {
|
||||
for (const track of this.audioStream.getTracks() || []) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
if (this.audioElement) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.srcObject = undefined;
|
||||
}
|
||||
this.update({
|
||||
audioStream: clear(),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RtcSession.fields = {
|
||||
/**
|
||||
* HTMLAudioElement that plays and control the audioStream of the user,
|
||||
* it is not mounted on the DOM as it can operate from the JS.
|
||||
*/
|
||||
audioElement: attr(),
|
||||
/**
|
||||
* MediaStream
|
||||
*/
|
||||
audioStream: attr(),
|
||||
/**
|
||||
* The relative url of the image that represents the session.
|
||||
*/
|
||||
avatarUrl: attr({
|
||||
compute: '_computeAvatarUrl',
|
||||
}),
|
||||
/**
|
||||
* The mail.channel of the session, rtc sessions are part and managed by
|
||||
* mail.channel
|
||||
*/
|
||||
channel: many2one('mail.thread', {
|
||||
inverse: 'rtcSessions',
|
||||
}),
|
||||
/**
|
||||
* State of the connection with this session, uses RTCPeerConnection.iceConnectionState
|
||||
* once a peerConnection has been initialized.
|
||||
*/
|
||||
connectionState: attr({
|
||||
default: 'Waiting for the peer to send a RTC offer',
|
||||
}),
|
||||
guest: many2one('mail.guest'),
|
||||
/**
|
||||
* If this field is set, this session is 'focused' which means that it will
|
||||
* be displayed as the main session.
|
||||
*/
|
||||
focusingMessaging: one2one('mail.messaging', {
|
||||
inverse: 'focusedRtcSession',
|
||||
}),
|
||||
/**
|
||||
* Id of the record on the server.
|
||||
*/
|
||||
id: attr({
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* Channels on which this session is inviting the current partner,
|
||||
* this serves as an explicit inverse as it seems to confuse it with
|
||||
* other session-channel relations otherwise.
|
||||
*/
|
||||
calledChannels: one2many('mail.thread', {
|
||||
inverse: 'rtcInvitingSession',
|
||||
}),
|
||||
/**
|
||||
* Determines if the user is broadcasting a video from a user device (camera).
|
||||
*/
|
||||
isCameraOn: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the user is deafened, which means that all incoming
|
||||
* audio tracks are disabled.
|
||||
*/
|
||||
isDeaf: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the user's microphone is in a muted state, which
|
||||
* means that they cannot send sound regardless of the push to talk or
|
||||
* voice activation (isTalking) state.
|
||||
*/
|
||||
isMuted: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the session is a session of the current partner.
|
||||
* This can be true for many sessions, as one user can have multiple
|
||||
* sessions active across several tabs, browsers and devices.
|
||||
* To determine if this session is the active session of this tab,
|
||||
* use this.mailRtc instead.
|
||||
*/
|
||||
isOwnSession: attr({
|
||||
compute: '_computeIsOwnSession',
|
||||
}),
|
||||
/**
|
||||
* Determines if the user is sharing their screen.
|
||||
*/
|
||||
isScreenSharingOn: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the user is currently talking, which is based on
|
||||
* voice activation or push to talk.
|
||||
*/
|
||||
isTalking: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* If set, this session is the session of the current user and is in the active RTC call.
|
||||
* This information is distinct from this.isOwnSession as there can be other
|
||||
* sessions from other channels with the same partner (sessions opened from different
|
||||
* tabs or devices).
|
||||
*/
|
||||
mailRtc: one2one('mail.rtc', {
|
||||
inverse: 'currentRtcSession',
|
||||
}),
|
||||
/**
|
||||
* Name of the session, based on the partner name if set.
|
||||
*/
|
||||
name: attr({
|
||||
compute: '_computeName',
|
||||
}),
|
||||
/**
|
||||
* If set, the partner who owns this rtc session,
|
||||
* there can be multiple rtc sessions per partner if the partner
|
||||
* has open sessions in multiple channels, but only one session per
|
||||
* channel is allowed.
|
||||
*/
|
||||
partner: many2one('mail.partner', {
|
||||
inverse: 'rtcSessions',
|
||||
}),
|
||||
/**
|
||||
* Token to identify the session, it is currently just the toString
|
||||
* id of the record.
|
||||
*/
|
||||
peerToken: attr({
|
||||
compute: '_computePeerToken',
|
||||
}),
|
||||
/**
|
||||
* MediaStream of the user's video.
|
||||
*
|
||||
* Should be divided into userVideoStream and displayStream,
|
||||
* once we allow both share and cam feeds simultaneously.
|
||||
*/
|
||||
videoStream: attr(),
|
||||
/**
|
||||
* The volume of the audio played from this session.
|
||||
*/
|
||||
volume: attr({
|
||||
default: 0.5,
|
||||
compute: '_computeVolume',
|
||||
}),
|
||||
};
|
||||
RtcSession.onChanges = [
|
||||
new OnChange({
|
||||
dependencies: ['videoStream'],
|
||||
methodName: '_onChangeVideoStream',
|
||||
}),
|
||||
];
|
||||
|
||||
RtcSession.modelName = 'mail.rtc_session';
|
||||
|
||||
return RtcSession;
|
||||
}
|
||||
|
||||
registerNewModel('mail.rtc_session', factory);
|
||||
@@ -0,0 +1,87 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class SoundEffect extends dependencies['mail.model'] {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {boolean} [param0.loop] true if we want to make the audio loop, will only stop if stop() is called
|
||||
* @param {boolean} [param0.volume]
|
||||
*/
|
||||
play({ loop = false, volume = 1 } = {}) {
|
||||
if (typeof(Audio) === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (!this.audio) {
|
||||
const audio = new window.Audio();
|
||||
const ext = audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3";
|
||||
audio.src = this.path + this.filename + ext;
|
||||
this.update({ audio });
|
||||
}
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.loop = loop;
|
||||
this.audio.volume = volume;
|
||||
this.audio.play().catch(()=>{});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the audio to the start of the track and pauses it.
|
||||
*/
|
||||
stop() {
|
||||
if (this.audio) {
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static _createRecordLocalId(data) {
|
||||
return `${this.modelName}_${data.path}_${data.filename}`;
|
||||
}
|
||||
}
|
||||
|
||||
SoundEffect.fields = {
|
||||
/**
|
||||
* HTMLAudioElement
|
||||
* Does not require to be mounted on the DOM to operate.
|
||||
*
|
||||
* Set the first time the audio is played so the file is lazy loaded and
|
||||
* then cached.
|
||||
*/
|
||||
audio: attr(),
|
||||
/**
|
||||
* Name of the audio file.
|
||||
*/
|
||||
filename: attr({
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* Path to the audio file.
|
||||
*/
|
||||
path: attr({
|
||||
default: '/mail/static/src/audio/',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
SoundEffect.modelName = 'mail.sound_effect';
|
||||
|
||||
return SoundEffect;
|
||||
}
|
||||
|
||||
registerNewModel('mail.sound_effect', factory);
|
||||
@@ -0,0 +1,55 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { one2one } from '@mail/model/model_field';
|
||||
import { create } from '@mail/model/model_field_command';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class SoundEffects extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static _createRecordLocalId(data) {
|
||||
return _.uniqueId(`${this.modelName}_`);
|
||||
}
|
||||
}
|
||||
|
||||
SoundEffects.fields = {
|
||||
channelJoin: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'channel_01_in' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
channelLeave: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'channel_04_out' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
incomingCall: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'call_02_in_' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
memberLeave: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'channel_01_out' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
newMessage: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'dm_02' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
pushToTalk: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'dm_01' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
screenSharing: one2one('mail.sound_effect', {
|
||||
default: create({ filename: 'share_02' }),
|
||||
isCausal: true,
|
||||
}),
|
||||
};
|
||||
|
||||
SoundEffects.modelName = 'mail.sound_effects';
|
||||
|
||||
return SoundEffects;
|
||||
}
|
||||
|
||||
registerNewModel('mail.sound_effects', factory);
|
||||
@@ -255,6 +255,12 @@ function factory(dependencies) {
|
||||
))];
|
||||
}
|
||||
}
|
||||
if ('rtc_inviting_session' in data) {
|
||||
data2.rtcInvitingSession = insert(data.rtc_inviting_session);
|
||||
}
|
||||
if ('rtc_sessions' in data) {
|
||||
data2.rtcSessions = insert(data.rtc_sessions.map(record => this.messaging.models['mail.rtc_session'].convertData(record)));
|
||||
}
|
||||
if ('seen_partners_info' in data) {
|
||||
if (!data.seen_partners_info) {
|
||||
data2.partnerSeenInfos = unlinkAll();
|
||||
@@ -307,6 +313,22 @@ function factory(dependencies) {
|
||||
return data2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side ending of the call.
|
||||
*/
|
||||
endCall() {
|
||||
if (this.mailRtc) {
|
||||
this.mailRtc.reset();
|
||||
this.messaging.soundEffects.channelLeave.play();
|
||||
}
|
||||
this.update({
|
||||
invitedGuests: clear(),
|
||||
invitedPartners: clear(),
|
||||
mailRtc: unlink(),
|
||||
rtcInvitingSession: unlink(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches threads matching the given composer search state to extend
|
||||
* the JS knowledge and to update the suggestion list accordingly.
|
||||
@@ -699,6 +721,98 @@ function factory(dependencies) {
|
||||
this.fetchAndUpdateSuggestedRecipients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the current call if there is one, joins the call if the user was
|
||||
* not yet in it.
|
||||
*
|
||||
* @param {Object} options
|
||||
*/
|
||||
async toggleCall(options) {
|
||||
const isActiveCall = !!this.mailRtc;
|
||||
if (this.messaging.mailRtc.channel) {
|
||||
await this.messaging.mailRtc.channel.leaveCall();
|
||||
}
|
||||
if (isActiveCall) {
|
||||
return;
|
||||
}
|
||||
await this._joinCall(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} [param0]
|
||||
* @param {boolean} [param0.startWithVideo] whether or not to start the call with the video
|
||||
* @param {boolean} [param0.videoType] type of the video: 'user-video' or 'display'
|
||||
*/
|
||||
async _joinCall({ startWithVideo = false, videoType } = {}) {
|
||||
if (this.model !== 'mail.channel') {
|
||||
return;
|
||||
}
|
||||
if (!this.messaging.mailRtc.isClientRtcCompatible) {
|
||||
this.env.services.notification.notify({
|
||||
message: this.env._t("Your browser does not support webRTC."),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
this.update({ hasPendingRtcRequest: true });
|
||||
const { rtcSessions, iceServers, sessionId, invitedPartners, invitedGuests } = await this.async(() => this.env.services.rpc({
|
||||
route: '/mail/rtc/channel/join_call',
|
||||
params: {
|
||||
channel_id: this.id,
|
||||
},
|
||||
}, { shadow: true }));
|
||||
this.update({
|
||||
hasPendingRtcRequest: false,
|
||||
mailRtc: link(this.messaging.mailRtc),
|
||||
rtcInvitingSession: unlink(),
|
||||
rtcSessions: insertAndReplace(rtcSessions.map(record => this.messaging.models['mail.rtc_session'].convertData(record))),
|
||||
invitedGuests: insertAndReplace(invitedGuests),
|
||||
invitedPartners: insertAndReplace(invitedPartners),
|
||||
});
|
||||
await this.async(() => this.messaging.mailRtc.initSession({
|
||||
currentSessionId: sessionId,
|
||||
iceServers,
|
||||
startWithAudio: true,
|
||||
startWithVideo,
|
||||
videoType,
|
||||
}));
|
||||
this.messaging.soundEffects.channelJoin.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the server and does the cleanup of the current call.
|
||||
*/
|
||||
async leaveCall() {
|
||||
if (this.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this._leaveCall();
|
||||
this.endCall();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Object>} rtcSessions server representation of the current rtc sessions of the channel
|
||||
*/
|
||||
updateRtcSessions(rtcSessions) {
|
||||
const oldCount = this.rtcSessions.length;
|
||||
this.update({
|
||||
rtcSessions: insertAndReplace(rtcSessions.map(record => this.messaging.models['mail.rtc_session'].convertData(record)))
|
||||
});
|
||||
if (this.mailRtc) {
|
||||
const newCount = this.rtcSessions.length;
|
||||
if (newCount > oldCount) {
|
||||
this.messaging.soundEffects.channelJoin.play();
|
||||
}
|
||||
if (newCount < oldCount) {
|
||||
this.messaging.soundEffects.memberLeave.play();
|
||||
}
|
||||
}
|
||||
this.mailRtc && this.mailRtc.filterCallees(this.rtcSessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text that identifies this thread in a mention.
|
||||
*
|
||||
@@ -1136,6 +1250,7 @@ function factory(dependencies) {
|
||||
* Unsubscribe current user from provided channel.
|
||||
*/
|
||||
unsubscribe() {
|
||||
this.leaveCall();
|
||||
this.messaging.chatWindowManager.closeThread(this);
|
||||
this.unpin();
|
||||
}
|
||||
@@ -1647,6 +1762,14 @@ function factory(dependencies) {
|
||||
return `${baseHref}#model=${this.model}&id=${this.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {number}
|
||||
*/
|
||||
_computeVideoCount() {
|
||||
return this.rtcSessions.filter(session => session.videoStream).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
@@ -1695,6 +1818,18 @@ function factory(dependencies) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _leaveCall() {
|
||||
this.update({ hasPendingRtcRequest: true });
|
||||
await this.async(() => this.env.services.rpc({
|
||||
route: '/mail/rtc/channel/leave_call',
|
||||
params: { channel_id: this.id },
|
||||
}, { shadow: true }));
|
||||
this.update({ hasPendingRtcRequest: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles change of fold state coming from the server. Useful to
|
||||
* synchronize corresponding chat window.
|
||||
@@ -1935,6 +2070,13 @@ function factory(dependencies) {
|
||||
hasMemberListFeature: attr({
|
||||
compute: '_computeHasMemberListFeature',
|
||||
}),
|
||||
/**
|
||||
* States whether there is a server request for joining or leaving the RTC session.
|
||||
* TODO Should maybe be on messaging (after messaging env rebase) to lock the rpc across all threads.
|
||||
*/
|
||||
hasPendingRtcRequest: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determine whether this thread has the seen indicators (V and VV)
|
||||
* enabled or not.
|
||||
@@ -1949,6 +2091,16 @@ function factory(dependencies) {
|
||||
invitationLink: attr({
|
||||
compute: '_computeInvitationLink',
|
||||
}),
|
||||
/**
|
||||
* List of guests that have been invited to the RTC call of this channel.
|
||||
* FIXME should be simplified if we have the mail.channel.partner model
|
||||
* in which case the two following fields should be a single relation to that model.
|
||||
*/
|
||||
invitedGuests: many2many('mail.guest'),
|
||||
/**
|
||||
* List of partners that have been invited to the RTC call of this channel.
|
||||
*/
|
||||
invitedPartners: many2many('mail.partner'),
|
||||
/**
|
||||
* Determines whether this description can be changed.
|
||||
* Only makes sense for channels.
|
||||
@@ -2059,6 +2211,12 @@ function factory(dependencies) {
|
||||
localMessageUnreadCounter: attr({
|
||||
compute: '_computeLocalMessageUnreadCounter',
|
||||
}),
|
||||
/**
|
||||
* If set, the current thread is the thread that hosts the current RTC call.
|
||||
*/
|
||||
mailRtc: one2one('mail.rtc', {
|
||||
inverse: 'channel',
|
||||
}),
|
||||
/**
|
||||
* States the number of members in this thread according to the server.
|
||||
* Guests are excluded from the count.
|
||||
@@ -2183,6 +2341,17 @@ function factory(dependencies) {
|
||||
*/
|
||||
pendingSeenMessageId: attr(),
|
||||
public: attr(),
|
||||
/**
|
||||
* The session that invited the current user, it is only set when the
|
||||
* invitation is still pending.
|
||||
*/
|
||||
rtcInvitingSession: many2one('mail.rtc_session', {
|
||||
inverse: 'calledChannels',
|
||||
}),
|
||||
rtcSessions: one2many('mail.rtc_session', {
|
||||
inverse: 'channel',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Determine the last fold state known by the server, which is the fold
|
||||
* state displayed after initialization or when the last pending
|
||||
@@ -2260,6 +2429,13 @@ function factory(dependencies) {
|
||||
default: '',
|
||||
}),
|
||||
uuid: attr(),
|
||||
/**
|
||||
* The amount of videos broadcast in the current Rtc call
|
||||
*/
|
||||
videoCount: attr({
|
||||
compute: '_computeVideoCount',
|
||||
default: 0,
|
||||
}),
|
||||
};
|
||||
Thread.onChanges = [
|
||||
new OnChange({
|
||||
|
||||
@@ -67,6 +67,16 @@ function factory(dependencies) {
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_computeRtcCallViewer() {
|
||||
if (this.thread && this.thread.model === 'mail.channel' && this.thread.rtcSessions.length > 0) {
|
||||
return create();
|
||||
}
|
||||
return unlink();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.channel_invitation_form}
|
||||
@@ -227,6 +237,9 @@ function factory(dependencies) {
|
||||
}
|
||||
|
||||
ThreadView.fields = {
|
||||
compact: attr({
|
||||
related: 'threadViewer.compact',
|
||||
}),
|
||||
/**
|
||||
* States which channel invitation form is operating this thread view.
|
||||
* Only applies if this thread is a channel.
|
||||
@@ -340,6 +353,15 @@ function factory(dependencies) {
|
||||
nonEmptyMessages: many2many('mail.message', {
|
||||
related: 'threadCache.nonEmptyMessages',
|
||||
}),
|
||||
/**
|
||||
* Determines the Rtc call viewer of this thread.
|
||||
*/
|
||||
rtcCallViewer: one2one('mail.rtc_call_viewer', {
|
||||
compute: '_computeRtcCallViewer',
|
||||
inverse: 'threadView',
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* Determines the keyboard shortcuts that are available to send a message
|
||||
* from the composer of this thread viewer.
|
||||
|
||||
@@ -77,6 +77,12 @@ function factory(dependencies) {
|
||||
}
|
||||
|
||||
ThreadViewer.fields = {
|
||||
/**
|
||||
* true if the viewer is in a compact format, like in a chat window.
|
||||
*/
|
||||
compact: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines which extra class this thread view component should have.
|
||||
*/
|
||||
|
||||
@@ -8,18 +8,6 @@ function factory(dependencies) {
|
||||
|
||||
class User extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_willDelete() {
|
||||
if (this.messaging) {
|
||||
if (this === this.messaging.currentUser) {
|
||||
this.messaging.update({ currentUser: unlink() });
|
||||
}
|
||||
}
|
||||
return super._willDelete(...arguments);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one, one2many } from '@mail/model/model_field';
|
||||
import { create } from '@mail/model/model_field_command';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class UserSetting extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
const res = super._created(...arguments);
|
||||
this._timeoutIds = {};
|
||||
this._loadLocalSettings();
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_willDelete() {
|
||||
for (const timeoutId of Object.values(this._timeoutIds)) {
|
||||
browser.clearTimeout(timeoutId);
|
||||
}
|
||||
return super._willDelete(...arguments);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
static convertData(data) {
|
||||
const data2 = {};
|
||||
if ('use_push_to_talk' in data) {
|
||||
data2.usePushToTalk = data.use_push_to_talk;
|
||||
}
|
||||
if ('push_to_talk_key' in data) {
|
||||
data2.pushToTalkKey = data.push_to_talk_key || '';
|
||||
}
|
||||
if ('voice_active_duration' in data) {
|
||||
data2.voiceActiveDuration = data.voice_active_duration;
|
||||
}
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
return data2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object} MediaTrackConstraints
|
||||
*/
|
||||
getAudioConstraints() {
|
||||
const constraints = {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
};
|
||||
if (this.audioInputDeviceId) {
|
||||
constraints.deviceId = this.audioInputDeviceId;
|
||||
}
|
||||
return constraints;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {event} ev
|
||||
* @param {Object} param1
|
||||
* @param {boolean} param1.ignoreModifiers
|
||||
*/
|
||||
isPushToTalkKey(ev, { ignoreModifiers = false } = {}) {
|
||||
if (!this.usePushToTalk || !this.pushToTalkKey) {
|
||||
return;
|
||||
}
|
||||
const { key, shiftKey, ctrlKey, altKey } = this.pushToTalkKeyFormat();
|
||||
if (ignoreModifiers) {
|
||||
return ev.key === key;
|
||||
}
|
||||
return (
|
||||
ev.key === key &&
|
||||
ev.shiftKey === shiftKey &&
|
||||
ev.ctrlKey === ctrlKey &&
|
||||
ev.altKey === altKey
|
||||
);
|
||||
}
|
||||
|
||||
pushToTalkKeyFormat() {
|
||||
if (!this.pushToTalkKey) {
|
||||
return;
|
||||
}
|
||||
const [shiftKey, ctrlKey, altKey, key] = this.pushToTalkKey.split('.');
|
||||
return {
|
||||
shiftKey: !!shiftKey,
|
||||
ctrlKey: !!ctrlKey,
|
||||
altKey: !!altKey,
|
||||
key: key || false,
|
||||
};
|
||||
}
|
||||
|
||||
pushToTalkKeyToString() {
|
||||
const { shiftKey, ctrlKey, altKey, key } = this.pushToTalkKeyFormat();
|
||||
const f = (k, name) => k ? name : '';
|
||||
return `${f(ctrlKey, 'Ctrl + ')}${f(altKey, 'Alt + ')}${f(shiftKey, 'Shift + ')}${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} audioInputDeviceId
|
||||
*/
|
||||
async setAudioInputDevice(audioInputDeviceId) {
|
||||
this.update({
|
||||
audioInputDeviceId,
|
||||
});
|
||||
this.env.services.local_storage.setItem('mail_user_setting_audio_input_device_id', audioInputDeviceId);
|
||||
await this.messaging.mailRtc.updateLocalAudioTrack(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
*/
|
||||
setDelayValue(value) {
|
||||
const voiceActiveDuration = parseInt(value, 10);
|
||||
this.update({ voiceActiveDuration });
|
||||
this._saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {event} ev
|
||||
*/
|
||||
async setPushToTalkKey(ev) {
|
||||
const pushToTalkKey = `${ev.shiftKey || ''}.${ev.ctrlKey || ev.metaKey || ''}.${ev.altKey || ''}.${ev.key}`;
|
||||
this.update({ pushToTalkKey });
|
||||
this._saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {mail.partner} partner
|
||||
* @param {number} volume
|
||||
*/
|
||||
async saveVolumeSetting(partnerId, volume) {
|
||||
this._debounce(async () => {
|
||||
await this.async(() => this.env.services.rpc(
|
||||
{
|
||||
model: 'res.users.settings',
|
||||
method: 'set_volume_setting',
|
||||
args: [
|
||||
[this.messaging.currentUser.resUsersSettingsId],
|
||||
partnerId,
|
||||
volume,
|
||||
],
|
||||
},
|
||||
{ shadow: true },
|
||||
));
|
||||
}, 5000, `sound_${partnerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {float} voiceActivationThreshold
|
||||
*/
|
||||
async setThresholdValue(voiceActivationThreshold) {
|
||||
this.update({ voiceActivationThreshold });
|
||||
this.env.services.local_storage.setItem('mail_user_setting_voice_threshold', voiceActivationThreshold);
|
||||
await this.messaging.mailRtc.updateVoiceActivation();
|
||||
}
|
||||
|
||||
async togglePushToTalk() {
|
||||
this.update({ usePushToTalk: !this.usePushToTalk });
|
||||
await this.messaging.mailRtc.updateVoiceActivation();
|
||||
this._saveSettings();
|
||||
}
|
||||
|
||||
toggleLayoutSettingsWindow() {
|
||||
this.update({ isRtcLayoutSettingDialogOpen: !this.isRtcLayoutSettingDialogOpen });
|
||||
}
|
||||
|
||||
/**
|
||||
* toggles the display of the option window
|
||||
*/
|
||||
toggleWindow() {
|
||||
this.update({ isOpen: !this.isOpen });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {function} f
|
||||
* @param {number} delay in ms
|
||||
* @param {any} key
|
||||
*/
|
||||
_debounce(f, delay, key) {
|
||||
this._timeoutIds[key] && browser.clearTimeout(this._timeoutIds[key]);
|
||||
this._timeoutIds[key] = browser.setTimeout(() => {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
f();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_loadLocalSettings() {
|
||||
const voiceActivationThresholdString = this.env.services.local_storage.getItem(
|
||||
"mail_user_setting_voice_threshold"
|
||||
);
|
||||
const audioInputDeviceId = this.env.services.local_storage.getItem(
|
||||
"mail_user_setting_audio_input_device_id"
|
||||
);
|
||||
const voiceActivationThreshold = parseFloat(voiceActivationThresholdString);
|
||||
if (voiceActivationThreshold > 0) {
|
||||
this.update({
|
||||
voiceActivationThreshold,
|
||||
audioInputDeviceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _saveSettings() {
|
||||
this._debounce(async () => {
|
||||
await this.async(() => this.env.services.rpc(
|
||||
{
|
||||
model: 'res.users.settings',
|
||||
method: 'set_res_users_settings',
|
||||
args: [[this.messaging.currentUser.resUsersSettingsId], {
|
||||
push_to_talk_key: this.pushToTalkKey,
|
||||
use_push_to_talk: this.usePushToTalk,
|
||||
voice_active_duration: this.voiceActiveDuration,
|
||||
}],
|
||||
},
|
||||
{ shadow: true },
|
||||
));
|
||||
}, 2000, 'globalSettings');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
UserSetting.fields = {
|
||||
/**
|
||||
* DeviceId of the audio input selected by the user
|
||||
*/
|
||||
audioInputDeviceId: attr({
|
||||
default: '',
|
||||
}),
|
||||
id: attr(),
|
||||
/**
|
||||
* true if the dialog for the call viewer layout is open
|
||||
*/
|
||||
isRtcLayoutSettingDialogOpen: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Formatted string that represent the push to talk key with its modifiers.
|
||||
*/
|
||||
pushToTalkKey: attr({
|
||||
default: '',
|
||||
}),
|
||||
/**
|
||||
* Model for the component with the controls for RTC related settings.
|
||||
*/
|
||||
rtcConfigurationMenu: one2one('mail.rtc_configuration_menu', {
|
||||
default: create(),
|
||||
inverse: 'userSetting',
|
||||
isCausal: true,
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* layout of the rtc session display chosen by the user
|
||||
* possible values: tiled, spotlight, sidebar
|
||||
*/
|
||||
rtcLayout: attr({
|
||||
default: 'tiled',
|
||||
}),
|
||||
/**
|
||||
* true if the user wants to use push to talk (over voice activation)
|
||||
*/
|
||||
usePushToTalk: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Normalized volume at which the voice activation system must consider the user as "talking".
|
||||
*/
|
||||
voiceActivationThreshold: attr({
|
||||
default: 0.2,
|
||||
}),
|
||||
/**
|
||||
* how long the voice remains active after releasing the push-to-talk key in ms
|
||||
*/
|
||||
voiceActiveDuration: attr({
|
||||
default: 0,
|
||||
}),
|
||||
/**
|
||||
* Models that represent the volume chosen by the user for each partner.
|
||||
*/
|
||||
volumeSettings: one2many('mail.volume_setting', {
|
||||
inverse: 'userSetting',
|
||||
isCausal: true,
|
||||
}),
|
||||
};
|
||||
|
||||
UserSetting.modelName = 'mail.user_setting';
|
||||
|
||||
return UserSetting;
|
||||
}
|
||||
|
||||
registerNewModel('mail.user_setting', factory);
|
||||
@@ -0,0 +1,95 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, one2one, many2one } from '@mail/model/model_field';
|
||||
import { insert } from '@mail/model/model_field_command';
|
||||
import { OnChange } from '@mail/model/model_onchange';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class VolumeSetting extends dependencies['mail.model'] {
|
||||
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
static convertData(data) {
|
||||
const data2 = {};
|
||||
if ('volume' in data) {
|
||||
data2.volume = data.volume;
|
||||
}
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
|
||||
// relations
|
||||
if ('partner_id' in data) {
|
||||
const partnerNameGet = data['partner_id'];
|
||||
const partnerData = {
|
||||
display_name: partnerNameGet[1],
|
||||
id: partnerNameGet[0],
|
||||
};
|
||||
data2.partner = insert(partnerData);
|
||||
}
|
||||
return data2;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static _createRecordLocalId(data) {
|
||||
return `${this.modelName}_${data.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeVolume() {
|
||||
for (const rtcSession of this.partner.rtcSessions) {
|
||||
if (rtcSession.audioElement) {
|
||||
rtcSession.audioElement.volume = this.volume;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VolumeSetting.fields = {
|
||||
id: attr({
|
||||
required: true,
|
||||
}),
|
||||
partner: one2one('mail.partner', {
|
||||
inverse: 'volumeSetting',
|
||||
required: true,
|
||||
}),
|
||||
userSetting: many2one('mail.user_setting', {
|
||||
inverse: 'volumeSettings',
|
||||
required: true,
|
||||
}),
|
||||
volume: attr({
|
||||
default: 0.5,
|
||||
}),
|
||||
};
|
||||
|
||||
VolumeSetting.onChanges = [
|
||||
new OnChange({
|
||||
dependencies: ['volume'],
|
||||
methodName: '_onChangeVolume',
|
||||
}),
|
||||
];
|
||||
|
||||
VolumeSetting.modelName = 'mail.volume_setting';
|
||||
|
||||
return VolumeSetting;
|
||||
}
|
||||
|
||||
registerNewModel('mail.volume_setting', factory);
|
||||
@@ -0,0 +1,179 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
// Broad human voice range of frequencies in hz.
|
||||
const HUMAN_VOICE_FREQUENCY_RANGE = [80, 1000];
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Public
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* monitors the activity of an audio mediaStreamTrack
|
||||
*
|
||||
* @param {MediaStreamTrack} track
|
||||
* @param {Object} [processorOptions] options for the audio processor
|
||||
* @param {Array<number>} [processorOptions.frequencyRange] the range of frequencies to monitor in hz
|
||||
* @param {number} [processorOptions.minimumActiveCycles] how many cycles have to pass since the
|
||||
* last time the threshold was exceeded to go back to inactive state, this prevents
|
||||
* stuttering when the speech volume oscillates around the threshold value.
|
||||
* @param {function(boolean):void} [processorOptions.onThreshold] a function to be called when the threshold is passed
|
||||
* @param {function(number):void} [processorOptions] a function to be called at each tics
|
||||
* @param {number} [processorOptions.volumeThreshold] the normalized minimum value for audio detection
|
||||
* @returns {Object} returnValue
|
||||
* @returns {function} returnValue.disconnect callback to cleanly end the monitoring
|
||||
*/
|
||||
export async function monitorAudio(track, processorOptions) {
|
||||
// cloning the track so it is not affected by the enabled change of the original track.
|
||||
const monitoredTrack = track.clone();
|
||||
monitoredTrack.enabled = true;
|
||||
const stream = new window.MediaStream([monitoredTrack]);
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContext) {
|
||||
throw 'missing audio context';
|
||||
}
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
let processor;
|
||||
try {
|
||||
processor = await _loadAudioWorkletProcessor(source, audioContext, processorOptions);
|
||||
} catch (e) {
|
||||
// In case Worklets are not supported by the browser (eg: Safari)
|
||||
processor = _loadScriptProcessor(source, audioContext, processorOptions);
|
||||
}
|
||||
|
||||
return () => {
|
||||
processor.disconnect();
|
||||
source.disconnect();
|
||||
monitoredTrack.stop();
|
||||
}
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Private
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {MediaStreamSource} source
|
||||
* @param {AudioContext} audioContext
|
||||
* @param {Object} [param2] options
|
||||
* @returns {Object} returnValue
|
||||
* @returns {function} returnValue.disconnect disconnect callback
|
||||
*/
|
||||
function _loadScriptProcessor(source, audioContext, { frequencyRange = HUMAN_VOICE_FREQUENCY_RANGE, minimumActiveCycles = 10, onThreshold, onTic, volumeThreshold = 0.3 } = {}) {
|
||||
// audio setup
|
||||
const bitSize = 1024;
|
||||
const analyser = audioContext.createAnalyser();
|
||||
source.connect(analyser);
|
||||
const scriptProcessorNode = audioContext.createScriptProcessor(bitSize, 1, 1);
|
||||
analyser.connect(scriptProcessorNode);
|
||||
analyser.fftsize = bitSize;
|
||||
scriptProcessorNode.connect(audioContext.destination);
|
||||
|
||||
// timing variables
|
||||
const processInterval = 50; // how many ms between each computation
|
||||
const intervalInFrames = processInterval / 1000 * analyser.context.sampleRate;
|
||||
let nextUpdateFrame = processInterval;
|
||||
|
||||
// process variables
|
||||
let activityBuffer = 0;
|
||||
let wasAboveThreshold = undefined;
|
||||
let isAboveThreshold = false;
|
||||
|
||||
scriptProcessorNode.onaudioprocess = () => {
|
||||
// throttles down the processing tic rate
|
||||
nextUpdateFrame -= bitSize;
|
||||
if (nextUpdateFrame >= 0) {
|
||||
return;
|
||||
}
|
||||
nextUpdateFrame += intervalInFrames;
|
||||
|
||||
// computes volume and threshold
|
||||
const normalizedVolume = getFrequencyAverage(analyser, frequencyRange[0], frequencyRange[1]);
|
||||
if (normalizedVolume >= volumeThreshold) {
|
||||
activityBuffer = minimumActiveCycles;
|
||||
} else if (normalizedVolume < volumeThreshold && activityBuffer > 0) {
|
||||
activityBuffer--;
|
||||
}
|
||||
isAboveThreshold = activityBuffer > 0;
|
||||
|
||||
onTic && onTic(normalizedVolume);
|
||||
if (wasAboveThreshold !== isAboveThreshold) {
|
||||
wasAboveThreshold = isAboveThreshold;
|
||||
onThreshold && onThreshold(isAboveThreshold);
|
||||
}
|
||||
};
|
||||
return {
|
||||
disconnect: () => {
|
||||
analyser.disconnect();
|
||||
scriptProcessorNode.disconnect();
|
||||
scriptProcessorNode.onaudioprocess = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaStreamSource} source
|
||||
* @param {AudioContext} audioContext
|
||||
* @param {Object} [param2] options
|
||||
* @returns {Object} returnValue
|
||||
* @returns {function} returnValue.disconnect disconnect callback
|
||||
*/
|
||||
async function _loadAudioWorkletProcessor(source, audioContext, { frequencyRange = HUMAN_VOICE_FREQUENCY_RANGE, minimumActiveCycles = 10, onThreshold, onTic, volumeThreshold = 0.3 } = {}) {
|
||||
await audioContext.resume();
|
||||
// Safari does not support Worklet.addModule
|
||||
await audioContext.audioWorklet.addModule('/mail/rtc/audio_worklet_processor');
|
||||
const thresholdProcessor = new window.AudioWorkletNode(audioContext, 'audio-processor', {
|
||||
processorOptions: {
|
||||
minimumActiveCycles,
|
||||
volumeThreshold,
|
||||
frequencyRange,
|
||||
postAllTics: !!onTic,
|
||||
}
|
||||
});
|
||||
source.connect(thresholdProcessor).connect(audioContext.destination);
|
||||
thresholdProcessor.port.onmessage = (event) => {
|
||||
const { isAboveThreshold, volume } = event.data;
|
||||
onThreshold && isAboveThreshold !== undefined && onThreshold(isAboveThreshold);
|
||||
onTic && volume !== undefined && onTic(volume);
|
||||
};
|
||||
return {
|
||||
disconnect: () => {
|
||||
thresholdProcessor.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AnalyserNode} analyser
|
||||
* @param {number} lowerFrequency lower bound for relevant frequencies to monitor
|
||||
* @param {number} higherFrequency upper bound for relevant frequencies to monitor
|
||||
* @returns {number} normalized [0...1] average quantity of the relevant frequencies
|
||||
*/
|
||||
function getFrequencyAverage(analyser, lowerFrequency, higherFrequency) {
|
||||
const frequencies = new window.Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(frequencies);
|
||||
const sampleRate = analyser.context.sampleRate;
|
||||
const startIndex = _getFrequencyIndex(lowerFrequency, sampleRate, analyser.frequencyBinCount);
|
||||
const endIndex = _getFrequencyIndex(higherFrequency, sampleRate, analyser.frequencyBinCount);
|
||||
const count = endIndex - startIndex;
|
||||
let sum = 0;
|
||||
for (let index = startIndex; index < endIndex; index++) {
|
||||
sum += frequencies[index] / 255;
|
||||
}
|
||||
if (!count) {
|
||||
return 0;
|
||||
}
|
||||
return sum / count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} targetFrequency in Hz
|
||||
* @param {number} sampleRate the sample rate of the audio
|
||||
* @param {number} binCount AnalyserNode.frequencyBinCount
|
||||
* @returns {number} the index of the targetFrequency within binCount
|
||||
*/
|
||||
function _getFrequencyIndex(targetFrequency, sampleRate, binCount) {
|
||||
const index = Math.round(targetFrequency / (sampleRate / 2) * binCount);
|
||||
return Math.min(Math.max(0, index), binCount);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { getMessagingComponent } from '@mail/utils/messaging_component';
|
||||
|
||||
import SystrayMenu from 'web.SystrayMenu';
|
||||
import Widget from 'web.Widget';
|
||||
|
||||
/**
|
||||
* Odoo Widget, necessary to instantiate component.
|
||||
*/
|
||||
export const RtcActivityNoticeWidget = Widget.extend({
|
||||
template: 'mail.widgets.RtcActivityNotice',
|
||||
sequence: 99, // position in the systray, right to left.
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.component = undefined;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
destroy() {
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
this._super(...arguments);
|
||||
},
|
||||
async on_attach_callback() {
|
||||
const RtcActivityNotice = getMessagingComponent('RtcActivityNotice');
|
||||
this.component = new RtcActivityNotice(null);
|
||||
await this.component.mount(this.el);
|
||||
// unwrap
|
||||
this.el.parentNode.insertBefore(this.component.el, this.el);
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
},
|
||||
});
|
||||
|
||||
SystrayMenu.Items.push(RtcActivityNoticeWidget);
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.widgets.RtcActivityNotice">
|
||||
<li class="o_widget_RtcActivityNotice"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,95 @@
|
||||
// This processor allows access to audio channels directly on the audio rendering thread.
|
||||
// This can be useful for running possibly expensive audio monitoring or processing operations
|
||||
// off the main thread.
|
||||
class ThresholdProcessor extends globalThis.AudioWorkletProcessor {
|
||||
/**
|
||||
* @param {Object} param0 options
|
||||
* @param {Object} param0.processorOptions
|
||||
* @param {Array<number>} param0.processorOptions.frequencyRange array of two numbers that represent the range of
|
||||
frequencies that we want to monitor in hz.
|
||||
* @param {number} [param0.processorOptions.minimumActiveCycles] - how many cycles have to pass since the last time the
|
||||
threshold was exceeded to go back to inactive state. It prevents the microphone to shut down
|
||||
when the user's voice drops in volume mid-sentence.
|
||||
* @param {boolean} [param0.processorOptions.postAllTics] true if we need to postMessage at each tics, this prevents
|
||||
sending events to the main thread on all tics when not necessary.
|
||||
* @param {number} [param0.processorOptions.volumeThreshold] the minimum value for audio detection
|
||||
*/
|
||||
constructor({ processorOptions: { frequencyRange, minimumActiveCycles = 10, postAllTics, volumeThreshold = 0.3 } }) {
|
||||
super();
|
||||
|
||||
// timing variables
|
||||
this.processInterval = 50; // how many ms between each computation
|
||||
this.minimumActiveCycles = minimumActiveCycles;
|
||||
this.intervalInFrames = this.processInterval / 1000 * globalThis.sampleRate;
|
||||
this.nextUpdateFrame = this.processInterval;
|
||||
|
||||
// process variables
|
||||
this.activityBuffer = 0;
|
||||
this.volumeThreshold = volumeThreshold;
|
||||
this.frequencyRange = frequencyRange || [80, 400];
|
||||
this.isAboveThreshold = false;
|
||||
this.postAllTics = postAllTics;
|
||||
this.volume = 0;
|
||||
this.wasAboveThreshold = undefined;
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input.length < 1) {
|
||||
return;
|
||||
}
|
||||
const samples = input[0];
|
||||
|
||||
// throttles down the processing tic rate
|
||||
this.nextUpdateFrame -= samples.length;
|
||||
if (this.nextUpdateFrame >= 0) {
|
||||
return true;
|
||||
}
|
||||
this.nextUpdateFrame += this.intervalInFrames;
|
||||
|
||||
// computes volume and threshold
|
||||
const startIndex = _getFrequencyIndex(this.frequencyRange[0], globalThis.sampleRate, samples.length);
|
||||
const endIndex = _getFrequencyIndex(this.frequencyRange[1], globalThis.sampleRate, samples.length);
|
||||
let sum = 0;
|
||||
for (let i = startIndex; i < endIndex; ++i) {
|
||||
sum += samples[i];
|
||||
}
|
||||
// Normalizing the volume so that volume mostly fits in the [0,1] range.
|
||||
const preNormalizationVolume = sum / (endIndex - startIndex);
|
||||
const preLogarithmVolume = (preNormalizationVolume * 50) + 1;
|
||||
if (preLogarithmVolume <= 0) {
|
||||
this.volume = 0;
|
||||
} else {
|
||||
this.volume = Math.log10(preLogarithmVolume);
|
||||
}
|
||||
|
||||
if (this.volume >= this.volumeThreshold) {
|
||||
this.activityBuffer = this.minimumActiveCycles;
|
||||
} else if (this.volume < this.volumeThreshold && this.activityBuffer > 0) {
|
||||
this.activityBuffer--;
|
||||
}
|
||||
this.isAboveThreshold = this.activityBuffer > 0;
|
||||
|
||||
if (this.wasAboveThreshold !== this.isAboveThreshold) {
|
||||
this.wasAboveThreshold = this.isAboveThreshold;
|
||||
this.port.postMessage({ volume: this.volume, isAboveThreshold: this.isAboveThreshold });
|
||||
return true;
|
||||
}
|
||||
this.postAllTics && this.port.postMessage({ volume: this.volume, isAboveThreshold: this.isAboveThreshold });
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} targetFrequency in Hz
|
||||
* @param {number} sampleRate the sample rate of the audio
|
||||
* @param {number} samplesSize amount of samples in the audio input
|
||||
* @returns {number} the index of the targetFrequency within samplesSize
|
||||
*/
|
||||
function _getFrequencyIndex(targetFrequency, sampleRate, samplesSize) {
|
||||
const index = Math.round(targetFrequency / (sampleRate / 2) * samplesSize);
|
||||
return Math.min(Math.max(0, index), samplesSize);
|
||||
}
|
||||
|
||||
globalThis.registerProcessor("audio-processor", ThresholdProcessor);
|
||||
@@ -24,6 +24,7 @@
|
||||
<field name="partner_id"/>
|
||||
<field name="channel_id"/>
|
||||
<field name="seen_message_id"/>
|
||||
<field name="rtc_inviting_session_id"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
|
||||
<record id="mail_channel_rtc_session_view_search" model="ir.ui.view">
|
||||
<field name="name">mail.channel.rtc.session.search</field>
|
||||
<field name="model">mail.channel.rtc.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search RTC session">
|
||||
<field name="channel_partner_id"/>
|
||||
<filter name="group_by_channel" string="Channel" domain="[]" context="{'group_by':'channel_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_channel_rtc_session_view_tree" model="ir.ui.view">
|
||||
<field name="name">mail.channel.rtc.session.tree</field>
|
||||
<field name="model">mail.channel.rtc.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="RTC Session">
|
||||
<header>
|
||||
<button name="action_disconnect" type="object" string="Disconnect"/>
|
||||
</header>
|
||||
<field name="id"/>
|
||||
<field name="channel_partner_id"/>
|
||||
<field name="channel_id"/>
|
||||
<field name="write_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_channel_rtc_session_view_form" model="ir.ui.view">
|
||||
<field name="name">mail.channel.rtc.session.form</field>
|
||||
<field name="model">mail.channel.rtc.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="RTC Session">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="channel_partner_id"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="channel_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="guest_id"/>
|
||||
</group>
|
||||
<group string="State">
|
||||
<field name="is_screen_sharing_on"/>
|
||||
<field name="is_camera_on"/>
|
||||
<field name="is_muted"/>
|
||||
<field name="is_deaf"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_channel_rtc_session_action" model="ir.actions.act_window">
|
||||
<field name="name">RTC sessions</field>
|
||||
<field name="res_model">mail.channel.rtc.session</field>
|
||||
<field name="context">{'search_default_group_by_channel': True}</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<record id="action_ice_servers" model="ir.actions.act_window">
|
||||
<field name="name">ICE servers</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">mail.ice.server</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_ice_server_tree">
|
||||
<field name="name">mail.ice.server.tree</field>
|
||||
<field name="model">mail.ice.server</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom">
|
||||
<field colspan="1" name="server_type"/>
|
||||
<field name="uri"/>
|
||||
<field name="username"/>
|
||||
<field name="credential"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_ice_server_form">
|
||||
<field name="name">mail.ice.server.form</field>
|
||||
<field name="model">mail.ice.server</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="ICE server">
|
||||
<sheet>
|
||||
<group>
|
||||
<label for="uri"/>
|
||||
<div class="oe_inline" name="URI" style="display: inline;">
|
||||
<field name="server_type" class="oe_inline"/><field name="uri" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<field name="username"/>
|
||||
<field name="credential"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -103,6 +103,16 @@
|
||||
action="mail_guest_action"
|
||||
parent="mail.mail_menu_technical"
|
||||
sequence="51"/>
|
||||
<menuitem id="mail_channel_rtc_session_menu"
|
||||
name="RTC sessions"
|
||||
action="mail_channel_rtc_session_action"
|
||||
parent="mail.mail_menu_technical"
|
||||
sequence="52"/>
|
||||
<menuitem id="mail_channel_ice_servers_menu"
|
||||
name="ICE servers"
|
||||
action="action_ice_servers"
|
||||
parent="mail.mail_menu_technical"
|
||||
sequence="53"/>
|
||||
|
||||
<!--
|
||||
This menuitem will be activated by integrations modules (like github, twitter, ...). It
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.mail</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
@@ -44,6 +45,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="use_twilio_rtc_servers"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="use_twilio_rtc_servers"/>
|
||||
<div class="text-muted col-md-12">
|
||||
Add your twilio credentials for ICE servers
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('use_twilio_rtc_servers', '=', False)]}">
|
||||
<div class="row mt16" id="mail_twilio_sid">
|
||||
<label for="twilio_account_sid" class="col-lg-3"/>
|
||||
<field name="twilio_account_sid" placeholder="e.g. ACd5543a0b450ar4c7t95f1b6e8a39t543"/>
|
||||
</div>
|
||||
<div class="row mt16" id="mail_twilio_auth_token">
|
||||
<label for="twilio_account_token" class="col-lg-3"/>
|
||||
<field name="twilio_account_token" placeholder="e.g. 65ea4f9e948b693N5156F350256bd152"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Custom ICE server list</span>
|
||||
<div class="row">
|
||||
<div class="text-muted col-md-12">
|
||||
Configure your ICE server list for webRTC
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="row col-lg-4">
|
||||
<button type="action" name="%(mail.action_ice_servers)d" string="ICE Servers" icon="fa-arrow-right" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
id="restrict_template_rendering_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
|
||||
@@ -9,10 +9,46 @@
|
||||
<tree string="User Settings">
|
||||
<field name="id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="use_push_to_talk"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="res_users_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.users.settings.form</field>
|
||||
<field name="model">res.users.settings</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="User Settings">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="user_id"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Discuss sidebar">
|
||||
<field name="is_discuss_sidebar_category_channel_open"/>
|
||||
<field name="is_discuss_sidebar_category_chat_open"/>
|
||||
</group>
|
||||
<group string="Voice">
|
||||
<field name="use_push_to_talk"/>
|
||||
<field name="push_to_talk_key" placeholder="e.g. true.true..f" attrs="{'invisible': [('use_push_to_talk', '=', False)]}"/>
|
||||
<field name="voice_active_duration" attrs="{'invisible': [('use_push_to_talk', '=', True)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook colspan="4">
|
||||
<page string="Volume per partner">
|
||||
<field name="volume_settings_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="partner_id"/>
|
||||
<field name="volume"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="res_users_settings_action" model="ir.actions.act_window">
|
||||
<field name="name">User Settings</field>
|
||||
<field name="res_model">res.users.settings</field>
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestDiscussFullPerformance(TransactionCase):
|
||||
self.maxDiff = None
|
||||
self.users[0].flush()
|
||||
self.users[0].invalidate_cache()
|
||||
with self.assertQueryCount(emp=49):
|
||||
with self.assertQueryCount(emp=50):
|
||||
init_messaging = self.users[0].with_user(self.users[0])._init_messaging()
|
||||
|
||||
self.assertEqual(init_messaging, {
|
||||
@@ -734,6 +734,10 @@ class TestDiscussFullPerformance(TransactionCase):
|
||||
'is_discuss_sidebar_category_channel_open': True,
|
||||
'is_discuss_sidebar_category_chat_open': True,
|
||||
'is_discuss_sidebar_category_livechat_open': True,
|
||||
'push_to_talk_key': False,
|
||||
'use_push_to_talk': False,
|
||||
'user_id': (self.users[0].id, 'Ernest Employee'),
|
||||
'voice_active_duration': 0,
|
||||
'volume_settings': [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ GLOBAL = ','.join([
|
||||
'moment',
|
||||
'py',
|
||||
'ClipboardJS',
|
||||
'globalThis',
|
||||
])
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
Reference in New Issue
Block a user