diff --git a/addons/im_livechat/tests/test_message.py b/addons/im_livechat/tests/test_message.py
index 32f758dd640..7ef9dde9c5a 100644
--- a/addons/im_livechat/tests/test_message.py
+++ b/addons/im_livechat/tests/test_message.py
@@ -57,6 +57,7 @@ class TestImLivechatMessage(TransactionCase):
'is_discussion': False,
'is_note': True,
'is_notification': False,
+ 'linkPreviews': [],
'message_type': 'notification',
'messageReactionGroups': [],
'model': 'mail.channel',
diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index c164f8b9914..ebde3fd3b0f 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -79,6 +79,7 @@ For more specific needs, you may also assign custom-defined actions
'views/mail_ice_server_views.xml',
'views/mail_channel_member_views.xml',
'views/mail_channel_rtc_session_views.xml',
+ 'views/mail_link_preview_views.xml',
'views/mail_channel_views.xml',
'views/mail_shortcode_views.xml',
'views/mail_activity_views.xml',
diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py
index b09d94e4e30..b3e96cd452b 100644
--- a/addons/mail/controllers/discuss.py
+++ b/addons/mail/controllers/discuss.py
@@ -577,3 +577,29 @@ class DiscussController(http.Controller):
if guest_to_rename_sudo != guest and not request.env.user._is_admin():
raise NotFound()
guest_to_rename_sudo._update_name(name)
+
+ # --------------------------------------------------------------------------
+ # Link preview API
+ # --------------------------------------------------------------------------
+
+ @http.route('/mail/link_preview', methods=['POST'], type='json', auth='public')
+ def mail_link_preview(self, message_id):
+ if not request.env['mail.link.preview'].sudo()._is_link_preview_enabled():
+ return
+ guest = request.env['mail.guest']._get_guest_from_request(request)
+ message = guest.env['mail.message'].search([('id', '=', int(message_id))])
+ if not message:
+ return
+ if not message.is_current_user_or_guest_author and not guest.env.user._is_admin():
+ return
+ guest.env['mail.link.preview'].sudo()._create_link_previews(message)
+
+ @http.route('/mail/link_preview/delete', methods=['POST'], type='json', auth='public')
+ def mail_link_preview_delete(self, link_preview_id):
+ guest = request.env['mail.guest']._get_guest_from_request(request)
+ link_preview_sudo = guest.env['mail.link.preview'].sudo().search([('id', '=', int(link_preview_id))])
+ if not link_preview_sudo:
+ return
+ if not link_preview_sudo.message_id.is_current_user_or_guest_author and not guest.env.user._is_admin():
+ return
+ link_preview_sudo._delete_and_notify()
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
index 9d02399d0e2..a7eaa787c3b 100644
--- a/addons/mail/models/__init__.py
+++ b/addons/mail/models/__init__.py
@@ -23,6 +23,7 @@ from . import mail_activity
from . import mail_blacklist
from . import mail_followers
from . import mail_gateway_allowed
+from . import mail_link_preview
from . import mail_message_reaction
from . import mail_message_schedule
from . import mail_message_subtype
diff --git a/addons/mail/models/mail_guest.py b/addons/mail/models/mail_guest.py
index 29da04a6ed1..1d2f668dade 100644
--- a/addons/mail/models/mail_guest.py
+++ b/addons/mail/models/mail_guest.py
@@ -113,6 +113,7 @@ class MailGuest(models.Model):
'current_partner': False,
'current_user_id': False,
'current_user_settings': False,
+ 'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(),
'menu_id': False,
'needaction_inbox_counter': False,
'partner_root': {
diff --git a/addons/mail/models/mail_link_preview.py b/addons/mail/models/mail_link_preview.py
new file mode 100644
index 00000000000..6377734e06b
--- /dev/null
+++ b/addons/mail/models/mail_link_preview.py
@@ -0,0 +1,151 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+from lxml import html, etree
+from urllib.parse import urlparse
+import requests
+
+from odoo import api, models, fields
+
+
+class LinkPreview(models.Model):
+ _name = 'mail.link.preview'
+ _description = "Store link preview data"
+
+ message_id = fields.Many2one('mail.message', string='Message', index=True, ondelete='cascade', required=True)
+ source_url = fields.Char('url', required=True)
+ og_type = fields.Char('type')
+ og_title = fields.Char('title')
+ og_image = fields.Char('image')
+ og_description = fields.Text('description')
+ og_mimetype = fields.Char('mimetype')
+ image_mimetype = fields.Char('image_mimetype')
+ create_date = fields.Datetime(index=True)
+
+ @api.model
+ def _create_link_previews(self, message):
+ tree = html.fromstring(message.body)
+ urls = tree.xpath('//a/@href')
+ link_previews = self.env['mail.link.preview']
+ requests_session = requests.Session()
+ # Some websites are blocking non browser user agent.
+ requests_session.headers.update({
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0'
+ })
+ for url in set(urls):
+ if len(link_previews) >= 5:
+ break
+ link_previews |= self.env['mail.link.preview']._create_link_preview(url, message.id, requests_session)
+ if not link_previews:
+ return
+ guest = self.env['mail.guest']._get_guest_from_context()
+ if message.model == 'mail.channel' and message.res_id:
+ target = self.env['mail.channel'].browse(message.res_id)
+ elif self.env.user._is_public() and guest:
+ target = guest
+ else:
+ target = self.env.user.partner_id
+ self.env['bus.bus']._sendmany([(target, 'mail.link.preview/insert', link_previews._link_preview_format())])
+
+ @api.model
+ def _create_link_preview(self, url, message_id, request_session):
+ if self._is_domain_throttled(url):
+ return self.env['mail.link.preview']
+ link_preview_data = self._get_link_preview_from_url(url, request_session)
+ if link_preview_data:
+ link_preview_data['message_id'] = message_id
+ return self.create(link_preview_data)
+ return self.env['mail.link.preview']
+
+ def _delete_and_notify(self):
+ notifications = []
+ guest = self.env['mail.guest']._get_guest_from_context()
+ for link_preview in self:
+ if link_preview.message_id.model == 'mail.channel' and link_preview.message_id.res_id:
+ target = self.env['mail.channel'].browse(link_preview.message_id.res_id)
+ elif self.env.user._is_public() and guest:
+ target = guest
+ else:
+ target = self.env.user.partner_id
+ notifications.append((target, 'mail.link.preview/delete', {'id': link_preview.id}))
+ self.env['bus.bus']._sendmany(notifications)
+ self.unlink()
+
+ @api.model
+ def _is_link_preview_enabled(self):
+ link_preview_throttle = int(self.env['ir.config_parameter'].sudo().get_param('mail.link_preview_throttle', 99))
+ return link_preview_throttle > 0
+
+ @api.model
+ def _is_domain_throttled(self, url):
+ domain = urlparse(url).netloc
+ date_interval = fields.Datetime.to_string((datetime.now() - relativedelta(seconds=10)))
+ call_counter = self.search_count([
+ ('source_url', 'ilike', domain),
+ ('create_date', '>', date_interval),
+ ])
+ link_preview_throttle = int(self.env['ir.config_parameter'].get_param('mail.link_preview_throttle', 99))
+ return call_counter > link_preview_throttle
+
+ @api.model
+ def _get_link_preview_from_url(self, url, request_session):
+ try:
+ response = request_session.head(url, timeout=3, allow_redirects=True)
+ except requests.exceptions.RequestException:
+ return False
+ if response.status_code != requests.codes.ok:
+ return False
+ image_mimetype = (
+ 'image/bmp',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/tiff',
+ 'image/x-icon',
+ )
+ # Content-Type header can return a charset, but we just need the
+ # mimetype (eg: image/jpeg;charset=ISO-8859-1)
+ content_type = response.headers['Content-Type'].split(';')
+ if response.headers['Content-Type'].startswith(image_mimetype):
+ return {
+ 'image_mimetype': content_type[0],
+ 'source_url': url,
+ }
+ if response.headers['Content-Type'].startswith('text/html'):
+ return self._get_link_preview_from_html(url, request_session)
+ return False
+
+ def _get_link_preview_from_html(self, url, request_session):
+ response = request_session.get(url, timeout=3)
+ parser = etree.HTMLParser(encoding=response.encoding)
+ tree = html.fromstring(response.content, parser=parser)
+ og_title = tree.xpath('//meta[@property="og:title"]/@content')
+ if not og_title:
+ return False
+ og_description = tree.xpath('//meta[@property="og:description"]/@content')
+ og_type = tree.xpath('//meta[@property="og:type"]/@content')
+ og_image = tree.xpath('//meta[@property="og:image"]/@content')
+ og_mimetype = tree.xpath('//meta[@property="og:image:type"]/@content')
+ return {
+ 'og_description': og_description[0] if og_description else None,
+ 'og_image': og_image[0] if og_image else None,
+ 'og_mimetype': og_mimetype[0] if og_mimetype else None,
+ 'og_title': og_title[0],
+ 'og_type': og_type[0] if og_type else None,
+ 'source_url': url,
+ }
+
+ def _link_preview_format(self):
+ return [{
+ 'id': preview.id,
+ 'message': {'id': preview.message_id.id},
+ 'image_mimetype': preview.image_mimetype,
+ 'og_description': preview.og_description,
+ 'og_image': preview.og_image,
+ 'og_mimetype': preview.og_mimetype,
+ 'og_title': preview.og_title,
+ 'og_type': preview.og_type,
+ 'source_url': preview.source_url,
+ } for preview in self]
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index a0fcbdb2012..c6f17d6802a 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -104,6 +104,7 @@ class Message(models.Model):
model = fields.Char('Related Document Model')
res_id = fields.Many2oneReference('Related Document ID', model_field='model')
record_name = fields.Char('Message Record Name') # name_get() of the related document
+ link_preview_ids = fields.One2many('mail.link.preview', 'message_id', string='Link Previews', groups="base.group_erp_manager")
# characteristics
message_type = fields.Selection([
('email', 'Email'),
@@ -859,6 +860,7 @@ class Message(models.Model):
'notifications': message_sudo.notification_ids._filtered_for_web_client()._notification_format(),
'attachment_ids': message_sudo.attachment_ids._attachment_format() if not legacy else message_sudo.attachment_ids._attachment_format(legacy=True),
'trackingValues': allowed_tracking_ids._tracking_value_format(),
+ 'linkPreviews': message_sudo.link_preview_ids._link_preview_format(),
'messageReactionGroups': reaction_groups,
'record_name': record_name,
})
diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py
index 5c0a3baec40..714c5c5de20 100644
--- a/addons/mail/models/res_users.py
+++ b/addons/mail/models/res_users.py
@@ -189,6 +189,7 @@ class Users(models.Model):
'current_partner': self.partner_id.mail_partner_format().get(self.partner_id),
'current_user_id': self.id,
'current_user_settings': self.env['res.users.settings']._find_or_create_for_user(self)._res_users_settings_format(),
+ 'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(),
'internalUserGroupId': self.env.ref('base.group_user').id,
'menu_id': self.env['ir.model.data']._xmlid_to_res_id('mail.menu_root_discuss'),
'needaction_inbox_counter': self.partner_id._get_needaction_count(),
diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv
index 5ee0c537886..82d355e198a 100644
--- a/addons/mail/security/ir.model.access.csv
+++ b/addons/mail/security/ir.model.access.csv
@@ -65,3 +65,4 @@ access_res_users_settings_volumes_all,res.users.settings.volumes,model_res_users
access_res_users_settings_volumes_user,res.users.settings.volumes,model_res_users_settings_volumes,base.group_user,1,1,1,1
access_mail_template_reset,access.mail.template.reset,model_mail_template_reset,mail.group_mail_template_editor,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
+access_mail_link_preview_admin,mail.link.preview.admin,model_mail_link_preview,base.group_erp_manager,1,1,1,1
diff --git a/addons/mail/static/src/components/link_preview_aside_view/link_preview_aside_view.js b/addons/mail/static/src/components/link_preview_aside_view/link_preview_aside_view.js
new file mode 100644
index 00000000000..5fc35d5456f
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_aside_view/link_preview_aside_view.js
@@ -0,0 +1,23 @@
+/** @odoo-module **/
+
+import { registerMessagingComponent } from '@mail/utils/messaging_component';
+
+const { Component } = owl;
+
+class LinkPreviewAsideView extends Component {
+
+ /**
+ * @returns {LinkPreviewAsideView}
+ */
+ get linkPreviewAsideView() {
+ return this.props.record;
+ }
+
+}
+
+Object.assign(LinkPreviewAsideView, {
+ props: { record: Object },
+ template: 'mail.LinkPreviewAsideView',
+});
+
+registerMessagingComponent(LinkPreviewAsideView);
diff --git a/addons/mail/static/src/components/link_preview_aside_view/link_preview_aside_view.xml b/addons/mail/static/src/components/link_preview_aside_view/link_preview_aside_view.xml
new file mode 100644
index 00000000000..72325c39f36
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_aside_view/link_preview_aside_view.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.js b/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.js
new file mode 100644
index 00000000000..b59008d181e
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.js
@@ -0,0 +1,23 @@
+/** @odoo-module **/
+
+import { registerMessagingComponent } from '@mail/utils/messaging_component';
+
+const { Component } = owl;
+
+class LinkPreviewCardView extends Component {
+
+ /**
+ * @returns {LinkPreviewCardView}
+ */
+ get linkPreviewCardView() {
+ return this.props.record;
+ }
+
+}
+
+Object.assign(LinkPreviewCardView, {
+ props: { record: Object },
+ template: 'mail.LinkPreviewCardView',
+});
+
+registerMessagingComponent(LinkPreviewCardView);
diff --git a/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.scss b/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.scss
new file mode 100644
index 00000000000..f1be2d8f4ce
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.scss
@@ -0,0 +1,18 @@
+.o_LinkPreviewCardView {
+ max-width: $o-mail-link-preview-width;
+}
+.o_LinkPreviewCardView .row {
+ min-height: $o-mail-link-preview-card-height;
+}
+
+.o_LinkPreviewCardView_hasMultineLines {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+
+ &.o_LinkPreviewCardView_description {
+ -webkit-line-clamp: 2;
+ }
+ &.o_LinkPreviewCardView_title {
+ -webkit-line-clamp: 3;
+ }
+}
diff --git a/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.xml b/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.xml
new file mode 100644
index 00000000000..ec1e48b34e1
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_card_view/link_preview_card_view.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/link_preview_delete_confirm_view/link_preview_delete_confirm_view.js b/addons/mail/static/src/components/link_preview_delete_confirm_view/link_preview_delete_confirm_view.js
new file mode 100644
index 00000000000..f7cfac2f224
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_delete_confirm_view/link_preview_delete_confirm_view.js
@@ -0,0 +1,32 @@
+/** @odoo-module **/
+
+import { useComponentToModel } from '@mail/component_hooks/use_component_to_model';
+import { registerMessagingComponent } from '@mail/utils/messaging_component';
+
+const { Component } = owl;
+
+export class LinkPreviewDeleteConfirmView extends Component {
+
+ /**
+ * @override
+ */
+ setup() {
+ super.setup();
+ useComponentToModel({ fieldName: 'component' });
+ }
+
+ /**
+ * @returns {DeleteMessageConfirmView}
+ */
+ get linkPreviewDeleteConfirmView() {
+ return this.props.record;
+ }
+
+}
+
+Object.assign(LinkPreviewDeleteConfirmView, {
+ props: { record: Object },
+ template: 'mail.LinkPreviewDeleteConfirmView',
+});
+
+registerMessagingComponent(LinkPreviewDeleteConfirmView);
diff --git a/addons/mail/static/src/components/link_preview_delete_confirm_view/link_preview_delete_confirm_view.xml b/addons/mail/static/src/components/link_preview_delete_confirm_view/link_preview_delete_confirm_view.xml
new file mode 100644
index 00000000000..f779fa88437
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_delete_confirm_view/link_preview_delete_confirm_view.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
Confirmation
+
+
Do you really want to delete this preview?
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.js b/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.js
new file mode 100644
index 00000000000..4b9fbf73234
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.js
@@ -0,0 +1,23 @@
+/** @odoo-module **/
+
+import { registerMessagingComponent } from '@mail/utils/messaging_component';
+
+const { Component } = owl;
+
+class LinkPreviewImageView extends Component {
+
+ /**
+ * @returns {LinkPreviewImageView}
+ */
+ get linkPreviewImageView() {
+ return this.props.record;
+ }
+
+}
+
+Object.assign(LinkPreviewImageView, {
+ props: { record: Object },
+ template: 'mail.LinkPreviewImageView',
+});
+
+registerMessagingComponent(LinkPreviewImageView);
diff --git a/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.scss b/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.scss
new file mode 100644
index 00000000000..06652971bf5
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.scss
@@ -0,0 +1,4 @@
+.o_LinkPreviewImageView img {
+ max-height: $o-mail-link-preview-height;
+ max-width: $o-mail-link-preview-width;
+}
diff --git a/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.xml b/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.xml
new file mode 100644
index 00000000000..07ba2ac6424
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_image_view/link_preview_image_view.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/link_preview_list_view/link_preview_list_view.js b/addons/mail/static/src/components/link_preview_list_view/link_preview_list_view.js
new file mode 100644
index 00000000000..3c6d96e3fe6
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_list_view/link_preview_list_view.js
@@ -0,0 +1,23 @@
+/** @odoo-module **/
+
+import { registerMessagingComponent } from '@mail/utils/messaging_component';
+
+const { Component } = owl;
+
+class LinkPreviewListView extends Component {
+
+ /**
+ * @returns {LinkPreviewListView}
+ */
+ get linkPreviewListView() {
+ return this.props.record;
+ }
+
+}
+
+Object.assign(LinkPreviewListView, {
+ props: { record: Object },
+ template: 'mail.LinkPreviewListView',
+});
+
+registerMessagingComponent(LinkPreviewListView);
diff --git a/addons/mail/static/src/components/link_preview_list_view/link_preview_list_view.xml b/addons/mail/static/src/components/link_preview_list_view/link_preview_list_view.xml
new file mode 100644
index 00000000000..5212cb00515
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_list_view/link_preview_list_view.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.js b/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.js
new file mode 100644
index 00000000000..81a5ed017bc
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.js
@@ -0,0 +1,23 @@
+/** @odoo-module **/
+
+import { registerMessagingComponent } from '@mail/utils/messaging_component';
+
+const { Component } = owl;
+
+class LinkPreviewVideoView extends Component {
+
+ /**
+ * @returns {LinkPreviewVideoView}
+ */
+ get linkPreviewVideoView() {
+ return this.props.record;
+ }
+
+}
+
+Object.assign(LinkPreviewVideoView, {
+ props: { record: Object },
+ template: 'mail.LinkPreviewVideoView',
+});
+
+registerMessagingComponent(LinkPreviewVideoView);
diff --git a/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.scss b/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.scss
new file mode 100644
index 00000000000..40edd534840
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.scss
@@ -0,0 +1,18 @@
+.o_LinkPreviewVideoView {
+ max-width: $o-mail-link-preview-width;
+}
+.o_LinkPreviewVideoView .row {
+ min-height: $o-mail-link-preview-card-height;
+}
+
+.o_LinkPreviewVideoView_hasMultineLines {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+
+ &.o_LinkPreviewVideoView_description {
+ -webkit-line-clamp: 2;
+ }
+ &.o_LinkPreviewVideoView_title {
+ -webkit-line-clamp: 3;
+ }
+}
diff --git a/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.xml b/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.xml
new file mode 100644
index 00000000000..466a14f55c9
--- /dev/null
+++ b/addons/mail/static/src/components/link_preview_video_view/link_preview_video_view.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml
index 324ba95608e..c88a02819d0 100644
--- a/addons/mail/static/src/components/message/message.xml
+++ b/addons/mail/static/src/components/message/message.xml
@@ -206,6 +206,7 @@
+
diff --git a/addons/mail/static/src/models/composer_view.js b/addons/mail/static/src/models/composer_view.js
index 8b5a1ac2371..d50c4269034 100644
--- a/addons/mail/static/src/models/composer_view.js
+++ b/addons/mail/static/src/models/composer_view.js
@@ -586,6 +586,14 @@ registerModel({
const message = messaging.models['Message'].insert(
messaging.models['Message'].convertData(messageData)
);
+ if (this.messaging.hasLinkPreviewFeature) {
+ this.messaging.rpc({
+ route: `/mail/link_preview`,
+ params: {
+ message_id: message.id
+ }
+ }, { shadow: true });
+ }
for (const threadView of message.originThread.threadViews) {
// Reset auto scroll to be able to see the newly posted message.
threadView.update({ hasAutoScrollOnMessageReceived: true });
@@ -841,7 +849,7 @@ registerModel({
return Boolean(
(this.hasThreadName && this.composer.thread) ||
(this.hasFollowers && !this.composer.isLog) ||
- this.threadView && this.threadView.replyingToMessageView
+ (this.threadView && this.threadView.replyingToMessageView)
);
},
/**
diff --git a/addons/mail/static/src/models/dialog.js b/addons/mail/static/src/models/dialog.js
index d1303137fce..b31be9e8bd1 100644
--- a/addons/mail/static/src/models/dialog.js
+++ b/addons/mail/static/src/models/dialog.js
@@ -88,6 +88,9 @@ registerModel({
if (this.followerSubtypeList) {
return 'FollowerSubtypeList';
}
+ if (this.linkPreviewDeleteConfirmView) {
+ return 'LinkPreviewDeleteConfirmView';
+ }
return clear();
},
/**
@@ -104,7 +107,6 @@ registerModel({
_computeFollowerSubtypeList() {
return this.followerOwnerAsSubtypeList ? {} : clear();
},
- /**
/**
* @private
* @returns {boolean}
@@ -119,6 +121,13 @@ registerModel({
}
return true;
},
+ /**
+ * @private
+ * @returns {FieldCommand}
+ */
+ _computeLinkPreviewDeleteConfirmView() {
+ return this.linkPreviewAsideViewOwnerAsLinkPreviewDeleteConfirm ? {} : clear();
+ },
/**
* @private
* @returns {FieldCommand}
@@ -143,6 +152,9 @@ registerModel({
if (this.deleteMessageConfirmView) {
return this.deleteMessageConfirmView;
}
+ if (this.linkPreviewDeleteConfirmView) {
+ return this.linkPreviewDeleteConfirmView;
+ }
if (this.followerSubtypeList) {
return this.followerSubtypeList;
}
@@ -218,6 +230,15 @@ registerModel({
inverse: 'dialogOwner',
isCausal: true,
}),
+ linkPreviewAsideViewOwnerAsLinkPreviewDeleteConfirm: one('LinkPreviewAsideView', {
+ inverse: 'linkPreviewDeleteConfirmDialog',
+ readonly: true,
+ }),
+ linkPreviewDeleteConfirmView: one('LinkPreviewDeleteConfirmView', {
+ compute: '_computeLinkPreviewDeleteConfirmView',
+ inverse: 'dialogOwner',
+ isCausal: true,
+ }),
followerOwnerAsSubtypeList: one('Follower', {
identifying: true,
inverse: 'followerSubtypeListDialog',
diff --git a/addons/mail/static/src/models/link_preview.js b/addons/mail/static/src/models/link_preview.js
new file mode 100644
index 00000000000..2fb597c3c0a
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview.js
@@ -0,0 +1,93 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { attr, many, one } from '@mail/model/model_field';
+import { clear } from '@mail/model/model_field_command';
+
+registerModel({
+ name: 'LinkPreview',
+ recordMethods: {
+ async remove() {
+ await this.messaging.rpc({
+ route: '/mail/link_preview/delete',
+ params: { link_preview_id: this.id },
+ }, { shadow: true });
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCard() {
+ return !this.isVideo && !this.isImage;
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsDeletable() {
+ if (!this.message) {
+ return false;
+ }
+ return (
+ (this.message.author && this.message.author === this.messaging.currentPartner) ||
+ (this.message.guestAuthor && this.message.guestAuthor === this.messaging.currentGuest)
+ );
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsImage() {
+ return Boolean(this.image_mimetype || this.og_mimetype === 'image/gif');
+ },
+ /**
+ * @private
+ * @returns {boolean|FieldCommand}
+ */
+ _computeIsVideo() {
+ if (!this.og_type) {
+ return clear();
+ }
+ return this.og_type.startsWith('video') && !this.isImage;
+ },
+ },
+ fields: {
+ id: attr({
+ identifying: true,
+ }),
+ image_mimetype: attr(),
+ isCard: attr({
+ compute: '_computeIsCard',
+ }),
+ isDeletable: attr({
+ compute: '_computeIsDeletable',
+ }),
+ isImage: attr({
+ compute: '_computeIsImage',
+ }),
+ isVideo: attr({
+ compute: '_computeIsVideo',
+ }),
+ linkPreviewCardView: many('LinkPreviewCardView', {
+ inverse: 'linkPreview',
+ isCausal: true,
+ }),
+ linkPreviewImageView: many('LinkPreviewImageView', {
+ inverse: 'linkPreview',
+ isCausal: true,
+ }),
+ linkPreviewVideoView: many('LinkPreviewVideoView', {
+ inverse: 'linkPreview',
+ isCausal: true,
+ }),
+ message: one('Message', {
+ inverse: 'linkPreviews',
+ }),
+ og_description: attr(),
+ og_image: attr(),
+ og_mimetype: attr(),
+ og_title: attr(),
+ og_type: attr(),
+ source_url: attr(),
+ },
+});
diff --git a/addons/mail/static/src/models/link_preview_aside_view.js b/addons/mail/static/src/models/link_preview_aside_view.js
new file mode 100644
index 00000000000..3dd4b9a11c5
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview_aside_view.js
@@ -0,0 +1,56 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { one } from '@mail/model/model_field';
+import { clear } from '@mail/model/model_field_command';
+
+registerModel({
+ name: 'LinkPreviewAsideView',
+ identifyingMode: 'xor',
+ recordMethods: {
+ /**
+ * Handles the click on delete link preview and open the confirm dialog.
+ */
+ onClick() {
+ this.update({ linkPreviewDeleteConfirmDialog: {} });
+ },
+ /**
+ * @private
+ * @returns {FieldCommand}
+ */
+ _computeLinkPreview() {
+ if (this.linkPreviewCardView) {
+ return this.linkPreviewCardView.linkPreview;
+ }
+ if (this.linkPreviewImageView) {
+ return this.linkPreviewImageView.linkPreview;
+ }
+ if (this.linkPreviewVideoView) {
+ return this.linkPreviewVideoView.linkPreview;
+ }
+ return clear();
+ }
+ },
+ fields: {
+ linkPreview: one('LinkPreview', {
+ compute: '_computeLinkPreview',
+ required: true,
+ }),
+ linkPreviewCardView: one('LinkPreviewCardView', {
+ identifying: true,
+ inverse: 'linkPreviewAsideView',
+ }),
+ linkPreviewDeleteConfirmDialog: one('Dialog', {
+ inverse: 'linkPreviewAsideViewOwnerAsLinkPreviewDeleteConfirm',
+ isCausal: true,
+ }),
+ linkPreviewImageView: one('LinkPreviewImageView', {
+ identifying: true,
+ inverse: 'linkPreviewAsideView',
+ }),
+ linkPreviewVideoView: one('LinkPreviewVideoView', {
+ identifying: true,
+ inverse: 'linkPreviewAsideView',
+ }),
+ },
+});
diff --git a/addons/mail/static/src/models/link_preview_card_view.js b/addons/mail/static/src/models/link_preview_card_view.js
new file mode 100644
index 00000000000..a0163dc95b8
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview_card_view.js
@@ -0,0 +1,57 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { attr, one } from '@mail/model/model_field';
+import { clear } from '@mail/model/model_field_command';
+
+registerModel({
+ name: 'LinkPreviewCardView',
+ recordMethods: {
+ /**
+ * Handles mouse enter event for the container of this element.
+ */
+ onMouseEnter() {
+ this.update({ isHovered: true });
+ },
+ /**
+ * Handles mouse leave event for the container of this element.
+ */
+ onMouseLeave() {
+ this.update({ isHovered: false });
+ },
+ /**
+ * @private
+ * @returns {LinkPreviewAsideView|FieldCommand}
+ */
+ _computeLinkPreviewAsideView() {
+ if (!this.linkPreview.isDeletable) {
+ return clear();
+ }
+ if (this.messaging.device.isMobileDevice) {
+ return {};
+ }
+ if (this.isHovered || (this.linkPreviewAsideView && this.linkPreviewAsideView.linkPreviewDeleteConfirmDialog)) {
+ return {};
+ }
+ return clear();
+ },
+ },
+ fields: {
+ isHovered: attr({
+ default: false,
+ }),
+ linkPreview: one('LinkPreview', {
+ identifying: true,
+ inverse: 'linkPreviewCardView',
+ }),
+ linkPreviewAsideView: one('LinkPreviewAsideView', {
+ compute: '_computeLinkPreviewAsideView',
+ inverse: 'linkPreviewCardView',
+ isCausal: true,
+ }),
+ linkPreviewListViewOwner: one('LinkPreviewListView', {
+ identifying: true,
+ inverse: 'linkPreviewAsCardViews',
+ }),
+ },
+});
diff --git a/addons/mail/static/src/models/link_preview_delete_confirm_view.js b/addons/mail/static/src/models/link_preview_delete_confirm_view.js
new file mode 100644
index 00000000000..8f24508b50c
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview_delete_confirm_view.js
@@ -0,0 +1,43 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { attr, one } from '@mail/model/model_field';
+
+registerModel({
+ name: 'LinkPreviewDeleteConfirmView',
+ recordMethods: {
+ /**
+ * Returns whether the given html element is inside this attachment delete confirm view.
+ *
+ * @param {Element} element
+ * @returns {boolean}
+ */
+ containsElement(element) {
+ return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
+ },
+ onClickCancel() {
+ this.dialogOwner.delete();
+ },
+ onClickOk() {
+ this.linkPreview.remove();
+ },
+ /**
+ * @private
+ * @returns {LinkPreview}
+ */
+ _computeLinkPreview() {
+ return this.dialogOwner.linkPreviewAsideViewOwnerAsLinkPreviewDeleteConfirm.linkPreview;
+ },
+ },
+ fields: {
+ component: attr(),
+ dialogOwner: one('Dialog', {
+ identifying: true,
+ inverse: 'linkPreviewDeleteConfirmView',
+ }),
+ linkPreview: one('LinkPreview', {
+ compute: '_computeLinkPreview',
+ required: true,
+ }),
+ },
+});
diff --git a/addons/mail/static/src/models/link_preview_image_view.js b/addons/mail/static/src/models/link_preview_image_view.js
new file mode 100644
index 00000000000..6a1cfa45326
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview_image_view.js
@@ -0,0 +1,67 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { attr, one } from '@mail/model/model_field';
+import { clear } from '@mail/model/model_field_command';
+
+registerModel({
+ name: 'LinkPreviewImageView',
+ recordMethods: {
+ /**
+ * Handles mouse enter event for the container of this element.
+ */
+ onMouseEnter() {
+ this.update({ isHovered: true });
+ },
+ /**
+ * Handles mouse leave event for the container of this element.
+ */
+ onMouseLeave() {
+ this.update({ isHovered: false });
+ },
+ /**
+ * @private
+ * @returns {LinkPreviewAsideView|FieldCommand}
+ */
+ _computeLinkPreviewAsideView() {
+ if (!this.linkPreview.isDeletable) {
+ return clear();
+ }
+ if (this.messaging.device.isMobileDevice) {
+ return {};
+ }
+ if (this.isHovered || (this.linkPreviewAsideView && this.linkPreviewAsideView.linkPreviewDeleteConfirmDialog)) {
+ return {};
+ }
+ return clear();
+ },
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeImageUrl() {
+ return this.linkPreview.og_image ? this.linkPreview.og_image : this.linkPreview.source_url;
+ },
+ },
+ fields: {
+ imageUrl: attr({
+ compute: '_computeImageUrl',
+ }),
+ isHovered: attr({
+ default: false,
+ }),
+ linkPreview: one('LinkPreview', {
+ identifying: true,
+ inverse: 'linkPreviewImageView',
+ }),
+ linkPreviewAsideView: one('LinkPreviewAsideView', {
+ compute: '_computeLinkPreviewAsideView',
+ inverse: 'linkPreviewImageView',
+ isCausal: true,
+ }),
+ linkPreviewListViewOwner: one('LinkPreviewListView', {
+ identifying: true,
+ inverse: 'linkPreviewAsImageViews',
+ }),
+ },
+});
diff --git a/addons/mail/static/src/models/link_preview_list_view.js b/addons/mail/static/src/models/link_preview_list_view.js
new file mode 100644
index 00000000000..263dddfd9bd
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview_list_view.js
@@ -0,0 +1,55 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { many, one } from '@mail/model/model_field';
+
+registerModel({
+ name: 'LinkPreviewListView',
+ recordMethods: {
+ /**
+ * @private
+ * @returns {LinkPreviewCardView[]}
+ */
+ _computeLinkPreviewAsCardViews() {
+ return this.messageViewOwner.message.linkPreviews.filter(linkPreview => linkPreview.isCard).map(linkPreview => ({ linkPreview }));
+ },
+ /**
+ * @private
+ * @returns {LinkPreviewImageView[]}
+ */
+ _computeLinkPreviewAsImageViews() {
+ return this.messageViewOwner.message.linkPreviews.filter(linkPreview => linkPreview.isImage).map(linkPreview => ({ linkPreview }));
+ },
+ /**
+ * @private
+ * @returns {LinkPreviewVideoView[]}
+ */
+ _computeLinkPreviewAsVideoViews() {
+ return this.messageViewOwner.message.linkPreviews.filter(linkPreview => linkPreview.isVideo).map(linkPreview => ({ linkPreview }));
+ },
+ },
+ fields: {
+ /**
+ * Determines if we are in the ChatWindow view AND if the message is left aligned
+ */
+ linkPreviewAsCardViews: many('LinkPreviewCardView', {
+ compute: '_computeLinkPreviewAsCardViews',
+ inverse: 'linkPreviewListViewOwner',
+ isCausal: true,
+ }),
+ linkPreviewAsImageViews: many('LinkPreviewImageView', {
+ compute: '_computeLinkPreviewAsImageViews',
+ inverse: 'linkPreviewListViewOwner',
+ isCausal: true,
+ }),
+ linkPreviewAsVideoViews: many('LinkPreviewVideoView', {
+ compute: '_computeLinkPreviewAsVideoViews',
+ inverse: 'linkPreviewListViewOwner',
+ isCausal: true,
+ }),
+ messageViewOwner: one('MessageView', {
+ identifying: true,
+ inverse: 'linkPreviewListView',
+ }),
+ },
+});
diff --git a/addons/mail/static/src/models/link_preview_video_view.js b/addons/mail/static/src/models/link_preview_video_view.js
new file mode 100644
index 00000000000..9ac2d63469d
--- /dev/null
+++ b/addons/mail/static/src/models/link_preview_video_view.js
@@ -0,0 +1,57 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { one, attr } from '@mail/model/model_field';
+import { clear } from '@mail/model/model_field_command';
+
+registerModel({
+ name: 'LinkPreviewVideoView',
+ recordMethods: {
+ /**
+ * Handles mouse enter event for the container of this element.
+ */
+ onMouseEnter() {
+ this.update({ isHovered: true });
+ },
+ /**
+ * Handles mouse leave event for the container of this element.
+ */
+ onMouseLeave() {
+ this.update({ isHovered: false });
+ },
+ /**
+ * @private
+ * @returns {LinkPreviewAsideView|FieldCommand}
+ */
+ _computeLinkPreviewAsideView() {
+ if (!this.linkPreview.isDeletable) {
+ return clear();
+ }
+ if (this.messaging.device.isMobileDevice) {
+ return {};
+ }
+ if (this.isHovered || (this.linkPreviewAsideView && this.linkPreviewAsideView.linkPreviewDeleteConfirmDialog)) {
+ return {};
+ }
+ return clear();
+ },
+ },
+ fields: {
+ isHovered: attr({
+ default: false,
+ }),
+ linkPreview: one('LinkPreview', {
+ identifying: true,
+ inverse: 'linkPreviewVideoView',
+ }),
+ linkPreviewAsideView: one('LinkPreviewAsideView', {
+ compute: '_computeLinkPreviewAsideView',
+ inverse: 'linkPreviewVideoView',
+ isCausal: true,
+ }),
+ linkPreviewListViewOwner: one('LinkPreviewListView', {
+ identifying: true,
+ inverse: 'linkPreviewAsVideoViews',
+ }),
+ },
+});
diff --git a/addons/mail/static/src/models/message.js b/addons/mail/static/src/models/message.js
index 68dd83d78a0..46cfe222d29 100644
--- a/addons/mail/static/src/models/message.js
+++ b/addons/mail/static/src/models/message.js
@@ -51,6 +51,7 @@ registerModel({
if ('is_notification' in data) {
data2.is_notification = data.is_notification;
}
+ data2.linkPreviews = data.linkPreviews;
if ('messageReactionGroups' in data) {
data2.messageReactionGroups = data.messageReactionGroups;
}
@@ -795,6 +796,9 @@ registerModel({
lastTrackingValue: one('TrackingValue', {
compute: '_computeLastTrackingValue',
}),
+ linkPreviews: many('LinkPreview', {
+ inverse: 'message',
+ }),
/**
* Groups of reactions per content allowing to know the number of
* reactions for each.
diff --git a/addons/mail/static/src/models/message_view.js b/addons/mail/static/src/models/message_view.js
index c26a0634b94..fb00c1948ce 100644
--- a/addons/mail/static/src/models/message_view.js
+++ b/addons/mail/static/src/models/message_view.js
@@ -386,6 +386,9 @@ registerModel({
}
return clear();
},
+ _computeLinkPreviewListView() {
+ return (this.message && this.message.linkPreviews.length > 0) ? {} : clear();
+ },
/**
* @private
* @returns {FieldCommand}
@@ -611,6 +614,11 @@ registerModel({
compute: '_computeIsSquashed',
default: false,
}),
+ linkPreviewListView: one('LinkPreviewListView', {
+ compute: '_computeLinkPreviewListView',
+ inverse: 'messageViewOwner',
+ isCausal: true,
+ }),
/**
* Determines the message action list of this message view (if any).
*/
diff --git a/addons/mail/static/src/models/messaging.js b/addons/mail/static/src/models/messaging.js
index b21522f4d13..9c08a173cb3 100644
--- a/addons/mail/static/src/models/messaging.js
+++ b/addons/mail/static/src/models/messaging.js
@@ -394,6 +394,7 @@ registerModel({
fetchImStatusTimerDuration: attr({
default: 50 * 1000,
}),
+ hasLinkPreviewFeature: attr(),
history: one('Mailbox', {
default: {},
inverse: 'messagingAsHistory',
diff --git a/addons/mail/static/src/models/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer.js
index fbc780473a7..36f2b72c346 100644
--- a/addons/mail/static/src/models/messaging_initializer.js
+++ b/addons/mail/static/src/models/messaging_initializer.js
@@ -46,6 +46,8 @@ registerModel({
* @param {Object} param0.current_partner
* @param {integer} param0.current_user_id
* @param {Object} param0.current_user_settings
+ * @param {boolean} [param0.hasLinkPreviewFeature]
+ * @param {integer} [param0.internalUserGroupId]
* @param {integer} [param0.needaction_inbox_counter=0]
* @param {Object} param0.partner_root
* @param {Object[]} [param0.shortcodes=[]]
@@ -59,12 +61,13 @@ registerModel({
currentGuest,
current_user_id,
current_user_settings,
+ hasLinkPreviewFeature,
internalUserGroupId,
menu_id,
needaction_inbox_counter = 0,
partner_root,
shortcodes = [],
- starred_counter = 0
+ starred_counter = 0,
}) {
const discuss = this.messaging.discuss;
// partners first because the rest of the code relies on them
@@ -99,9 +102,7 @@ registerModel({
}
discuss.update({ menu_id });
// company related data
- this.messaging.update({ companyName });
-
- this.messaging.update({ internalUserGroupId });
+ this.messaging.update({ companyName, hasLinkPreviewFeature, internalUserGroupId });
},
/**
* @private
diff --git a/addons/mail/static/src/models/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler.js
index fe099abefc8..9e976be4e3a 100644
--- a/addons/mail/static/src/models/messaging_notification_handler.js
+++ b/addons/mail/static/src/models/messaging_notification_handler.js
@@ -69,6 +69,16 @@ registerModel({
return;
}
return this._handleNotificationChannelMessage(message.payload);
+ case 'mail.link.preview/insert':
+ this.messaging.models['LinkPreview'].insert(message.payload);
+ return;
+ case 'mail.link.preview/delete': {
+ const linkPreview = this.messaging.models['LinkPreview'].findFromIdentifyingData(message.payload);
+ if (linkPreview) {
+ linkPreview.delete();
+ }
+ return;
+ }
case 'mail.message/delete':
return this._handleNotificationMessageDelete(message.payload);
case 'mail.message/inbox':
diff --git a/addons/mail/static/src/scss/variables/primary_variables.scss b/addons/mail/static/src/scss/variables/primary_variables.scss
index 5d7c5f986b4..e30df7ba6a6 100644
--- a/addons/mail/static/src/scss/variables/primary_variables.scss
+++ b/addons/mail/static/src/scss/variables/primary_variables.scss
@@ -30,3 +30,7 @@ $o-mail-discuss-sidebar-scrollbar-width: 15px !default;
$o-mail-message-sidebar-width: 48px !default;
$o-mail-composer-text-input-height: 40px !default;
+
+$o-mail-link-preview-width: 320px !default;
+$o-mail-link-preview-height: 240px !default;
+$o-mail-link-preview-card-height: 80px !default;
diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js
index 3e78555620c..bc671b6485c 100644
--- a/addons/mail/static/tests/helpers/mock_server.js
+++ b/addons/mail/static/tests/helpers/mock_server.js
@@ -87,6 +87,9 @@ patch(MockServer.prototype, 'mail', {
if (route === '/mail/init_messaging') {
return this._mockRouteMailInitMessaging();
}
+ if (route === '/mail/link_preview') {
+ return this._mockRouteMailLinkPreview(args.message_id);
+ }
if (route === '/mail/load_message_failures') {
return this._mockRouteMailLoadMessageFailures();
}
@@ -423,6 +426,32 @@ patch(MockServer.prototype, 'mail', {
_mockRouteMailInitMessaging() {
return this._mockResUsers_InitMessaging([this.currentUserId]);
},
+ /**
+ * Simulates `/mail/link_preview` route.
+ *
+ * @private
+ * @param {integer} message_id
+ * @returns {Object}
+ */
+ _mockRouteMailLinkPreview(message_id) {
+ const linkPreviews = [];
+ const [message] = this.pyEnv['mail.message'].searchRead([['id', '=', message_id]]);
+ if (message.body === 'https://make-link-preview.com') {
+ const linkPreviewId = this.pyEnv['mail.link.preview'].create({
+ og_description: "test description",
+ og_title: 'Article title',
+ og_type: 'article',
+ source_url: 'https://make-link-preview.com',
+ });
+ const [linkPreview] = this.pyEnv['mail.link.preview'].searchRead([['id', '=', linkPreviewId]]);
+ linkPreviews.push(this._mockMailLinkPreviewFormat(linkPreview));
+ let target = this.currentPartner;
+ if (message.model === 'mail.channel') {
+ target = this.pyEnv['mail.channel'].search([['id', '=', message.res_id]]);
+ }
+ this.pyEnv['bus.bus']._sendmany([[target, 'mail.link.preview/insert', linkPreviews]]);
+ }
+ },
/**
* Simulates the `/mail/load_message_failures` route.
*
@@ -1368,6 +1397,24 @@ patch(MockServer.prototype, 'mail', {
seen_message_id: message_id,
});
},
+ /**
+ * Simulates the `_link_preview_format` method of `mail.link.preview`.
+ *
+ * @private
+ * @param {object} linkPreview
+ */
+ _mockMailLinkPreviewFormat(linkPreview) {
+ return {
+ id: linkPreview.id,
+ image_mimetype: linkPreview.image_mimetype,
+ og_description: linkPreview.og_description,
+ og_image: linkPreview.og_image,
+ og_mimetype: linkPreview.og_mimetype,
+ og_title: linkPreview.og_title,
+ og_type: linkPreview.og_type,
+ source_url: linkPreview.source_url
+ };
+ },
/**
* Simulates `mark_all_as_read` on `mail.message`.
*
@@ -1496,10 +1543,16 @@ patch(MockServer.prototype, 'mail', {
'res.partner',
[['id', 'in', message.partner_ids]],
);
+ const linkPreviews = this.getRecords('mail.link.preview', [
+ ['id', 'in', message.link_preview_ids],
+ ]);
+ const linkPreviewsFormatted = linkPreviews.map(linkPreview => this._mockMailLinkPreviewFormat(linkPreview));
+
const response = Object.assign({}, message, {
attachment_ids: formattedAttachments,
author: formattedAuthor,
history_partner_ids: historyPartnerIds,
+ linkPreviews: linkPreviewsFormatted,
needaction_partner_ids: needactionPartnerIds,
notifications,
parentMessage: message.parent_id ? this._mockMailMessageMessageFormat([message.parent_id])[0] : false,
diff --git a/addons/mail/static/tests/helpers/model_definitions_setup.js b/addons/mail/static/tests/helpers/model_definitions_setup.js
index ee93e4b0885..f76295e3dcb 100644
--- a/addons/mail/static/tests/helpers/model_definitions_setup.js
+++ b/addons/mail/static/tests/helpers/model_definitions_setup.js
@@ -14,9 +14,9 @@ import {
addModelNamesToFetch([
'mail.activity', 'mail.activity.type', 'mail.channel', 'mail.channel.member',
- 'mail.followers', 'mail.message', 'mail.message.subtype', 'mail.notification',
- 'mail.shortcode', 'mail.template', 'mail.tracking.value', 'res.users.settings',
- 'res.users.settings.volumes'
+ 'mail.followers', 'mail.link.preview', 'mail.message', 'mail.message.subtype',
+ 'mail.notification', 'mail.shortcode', 'mail.template', 'mail.tracking.value',
+ 'res.users.settings', 'res.users.settings.volumes'
]);
addFakeModel('res.fake', {
diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js
index eb5ea0efb5b..b23c6bca529 100644
--- a/addons/mail/static/tests/helpers/test_utils.js
+++ b/addons/mail/static/tests/helpers/test_utils.js
@@ -107,6 +107,14 @@ function getClick({ afterNextRender }) {
};
}
+function getMouseenter({ afterNextRender }) {
+ return async function mouseenter(selector) {
+ await afterNextRender(() =>
+ document.querySelector(selector).dispatchEvent(new window.MouseEvent('mouseenter'))
+ );
+ };
+}
+
function getOpenDiscuss(afterEvent, webClient, { context = {}, params, ...props } = {}) {
return async function openDiscuss({ waitUntilMessagesLoaded = true } = {}) {
const actionOpenDiscuss = {
@@ -280,6 +288,7 @@ async function start(param0 = {}) {
env: webClient.env,
insertText,
messaging: webClient.env.services.messaging.modelManager.messaging,
+ mouseenter: getMouseenter({ afterNextRender }),
openDiscuss: getOpenDiscuss(afterEvent, webClient, discuss),
openView,
openFormView: getOpenFormView(afterEvent, openView),
diff --git a/addons/mail/static/tests/qunit_suite_tests/components/link_preview_tests.js b/addons/mail/static/tests/qunit_suite_tests/components/link_preview_tests.js
new file mode 100644
index 00000000000..650da773a96
--- /dev/null
+++ b/addons/mail/static/tests/qunit_suite_tests/components/link_preview_tests.js
@@ -0,0 +1,384 @@
+/** @odoo-module **/
+
+import {
+ start,
+ startServer,
+} from '@mail/../tests/helpers/test_utils';
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('link_preview_tests.js');
+
+const linkPreviewGifPayload = {
+ og_description: 'test description',
+ og_image: 'https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif',
+ og_mimetype: 'image/gif',
+ og_title: 'Yay Minions GIF - Yay Minions Happiness - Discover & Share GIFs',
+ og_type: 'video.other',
+ source_url: 'https://tenor.com/view/yay-minions-happiness-happy-excited-gif-15324023',
+};
+const linkPreviewCardPayload = {
+ og_description: 'Description',
+ og_title: 'Article title',
+ og_type: 'article',
+ source_url: 'https://www.odoo.com',
+};
+const linkPreviewCardImagePayload = {
+ og_description: 'Description',
+ og_image: 'https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif',
+ og_title: 'Article title',
+ og_type: 'article',
+ source_url: 'https://www.odoo.com',
+};
+const linkPreviewVideoPayload = {
+ og_description: 'Description',
+ og_image: 'https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif',
+ og_title: 'video title',
+ og_type: 'video.other',
+ source_url: 'https://www.odoo.com',
+};
+const linkPreviewImagePayload = {
+ image_mimetype: 'image/jpg',
+ source_url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg',
+};
+
+QUnit.test('auto layout with link preview list', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewGifPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message .o_LinkPreviewListView',
+ "Should have a link preview list in the DOM"
+ );
+});
+
+QUnit.test('auto layout with link preview as gif', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewGifPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewImageView',
+ "Should have a link preview gif in the DOM"
+ );
+});
+
+QUnit.test('simplest card layout', async function (assert) {
+ assert.expect(3);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewCardPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView',
+ "should have link preview in DOM"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView_title',
+ "Should display the link preview title"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView_description',
+ "Link preview should show the link description"
+ );
+});
+
+QUnit.test('simplest card layout with image', async function (assert) {
+ assert.expect(4);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewCardImagePayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView',
+ "should have link preview in DOM"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView_title',
+ "Should display the link preview title"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView_description',
+ "Link preview should show the link description"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewCardView_image',
+ "Should display an image inside the link preview card"
+ );
+});
+
+QUnit.test('Link preview video layout', async function (assert) {
+ assert.expect(4);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewVideoPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewVideoView',
+ "should have link preview video in DOM"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewVideoView_title',
+ "Should display the link preview title"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewVideoView_description',
+ "Link preview should show the link description"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_linkPreviewVideo_overlay',
+ "Should display overlay inside the link preview video image"
+ );
+});
+
+QUnit.test('Link preview image layout', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewImagePayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewImageView',
+ "should have link preview image"
+ );
+});
+
+QUnit.test('Remove link preview Gif', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewGifPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss, click, mouseenter } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ await mouseenter('.o_LinkPreviewImageView');
+ await click('.o_LinkPreviewAside');
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewDeleteConfirmView',
+ 'Should have a link preview confirmation dialog'
+ );
+});
+
+QUnit.test('Remove link preview card', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewCardPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss, click, mouseenter } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ await mouseenter('.o_LinkPreviewCardView');
+ await click('.o_LinkPreviewAside');
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewDeleteConfirmView',
+ 'Should have a link preview confirmation dialog'
+ );
+});
+
+QUnit.test('Remove link preview video', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewVideoPayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss, click, mouseenter } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ await mouseenter('.o_LinkPreviewVideoView');
+ await click('.o_LinkPreviewAside');
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewDeleteConfirmView',
+ 'Should have a link preview confirmation dialog'
+ );
+});
+
+QUnit.test('Remove link preview image', async function (assert) {
+ assert.expect(1);
+
+ const pyEnv = await startServer();
+ const linkPreviewId = pyEnv['mail.link.preview'].create(linkPreviewImagePayload);
+ const mailChannelId = pyEnv['mail.channel'].create({});
+ pyEnv['mail.message'].create({
+ body: 'not empty',
+ link_preview_ids: [linkPreviewId],
+ message_type: 'comment',
+ model: "mail.channel",
+ res_id: mailChannelId,
+ });
+ const { openDiscuss, click, mouseenter } = await start({
+ discuss: {
+ params: {
+ default_active_id: `mail.channel_${mailChannelId}`,
+ },
+ },
+ });
+ await openDiscuss();
+
+ await mouseenter('.o_LinkPreviewImageView');
+ await click('.o_LinkPreviewAside');
+ assert.containsOnce(
+ document.body,
+ '.o_LinkPreviewDeleteConfirmView',
+ 'Should have a link preview confirmation dialog'
+ );
+});
+
+});
+});
diff --git a/addons/mail/tests/__init__.py b/addons/mail/tests/__init__.py
index 4f5ca3cec3d..eaf51ea9ab6 100644
--- a/addons/mail/tests/__init__.py
+++ b/addons/mail/tests/__init__.py
@@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_get_model_definitions
+from . import test_link_preview
from . import test_mail_channel
from . import test_mail_channel_as_guest
from . import test_mail_channel_member
diff --git a/addons/mail/tests/test_link_preview.py b/addons/mail/tests/test_link_preview.py
new file mode 100644
index 00000000000..d45697a4df3
--- /dev/null
+++ b/addons/mail/tests/test_link_preview.py
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from functools import partial
+
+from odoo.addons.mail.tests.common import mail_new_test_user
+from odoo.addons.mail.tests.common import MailCommon
+from unittest.mock import patch
+import requests
+
+mail_channel_new_test_user = partial(mail_new_test_user, context={'mail_channel_nosubscribe': False})
+
+
+def _patched_get_html(*args, **kwargs):
+ response = requests.Response()
+ response.status_code = 200
+ response._content = b"""
+
+
+
+
+
+
+ """
+ response.headers["Content-Type"] = 'text/html'
+ return response
+
+def _patch_head_html(*args, **kwargs):
+ response = requests.Response()
+ response.status_code = 200
+ response.headers["Content-Type"] = 'text/html'
+ return response
+
+
+class TestLinkPreview(MailCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.user_1 = mail_channel_new_test_user(
+ cls.env, login='user_1',
+ name='User 1',
+ groups='base.group_user')
+
+ cls.public_channel = cls.env['mail.channel'].create({
+ 'name': 'Public channel of user 1',
+ 'public': 'public',
+ 'channel_type': 'channel',
+ })
+ cls.public_channel.channel_member_ids.unlink()
+
+ def test_01_link_preview_throttle(self):
+ with patch.object(requests.Session, 'get', _patched_get_html), patch.object(requests.Session, 'head', _patch_head_html):
+ throttle = int(self.env['ir.config_parameter'].sudo().get_param('mail.link_preview_throttle', 99))
+ link_previews = []
+ for _ in range(throttle):
+ link_previews.append({'source_url': 'https://thisdomainedoentexist.nothing', 'message_id': 1})
+ self.env['mail.link.preview'].create(link_previews)
+ message = self.env['mail.message'].create({
+ 'model': 'mail.channel',
+ 'res_id': self.public_channel.id,
+ 'body': 'Nothing link',
+ })
+ self.env['mail.link.preview']._create_link_previews(message)
+ link_preview_count = self.env['mail.link.preview'].search_count([('source_url', '=', 'https://thisdomainedoentexist.nothing')])
+ self.assertEqual(link_preview_count, throttle + 1)
+
+ def test_02_link_preview_create(self):
+ with patch.object(requests.Session, 'get', _patched_get_html), patch.object(requests.Session, 'head', _patch_head_html):
+ message = self.env['mail.message'].create({
+ 'model': 'mail.channel',
+ 'res_id': self.public_channel.id,
+ 'body': 'Nothing link',
+ })
+ self.env['mail.link.preview']._create_link_previews(message)
+ self.assertBusNotifications(
+ [(self.cr.dbname, 'mail.channel', self.public_channel.id)],
+ message_items=[{
+ 'type': 'mail.link.preview/insert',
+ 'payload': [{
+ 'id': link_preview.id,
+ 'message': {'id': message.id},
+ 'image_mimetype': False,
+ 'og_description': 'Test description',
+ 'og_image': False,
+ 'og_mimetype': False,
+ 'og_title': 'Test title',
+ 'og_type': False,
+ 'source_url': 'https://thisdomainedoentexist.nothing',
+ } for link_preview in message.link_preview_ids]
+ }]
+ )
diff --git a/addons/mail/views/mail_guest_views.xml b/addons/mail/views/mail_guest_views.xml
index 095e55bbaa2..df591610a71 100644
--- a/addons/mail/views/mail_guest_views.xml
+++ b/addons/mail/views/mail_guest_views.xml
@@ -6,7 +6,7 @@
mail.guest
10
-
+
@@ -17,7 +17,7 @@
- User Settings
+ Guests
mail.guest
tree,form
diff --git a/addons/mail/views/mail_link_preview_views.xml b/addons/mail/views/mail_link_preview_views.xml
new file mode 100644
index 00000000000..36ad3b44d12
--- /dev/null
+++ b/addons/mail/views/mail_link_preview_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ mail.link.preview.tree
+ mail.link.preview
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+ Link Previews
+ mail.link.preview
+ tree,form
+
+
+
diff --git a/addons/mail/views/mail_menus.xml b/addons/mail/views/mail_menus.xml
index 4ea13471017..e59e6413790 100644
--- a/addons/mail/views/mail_menus.xml
+++ b/addons/mail/views/mail_menus.xml
@@ -8,9 +8,9 @@
sequence="5"
/>
-
+