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" /> - + 3 - + +