[IMP] mail: implement link preview with OpenGraph Protocol

OpenGraph Protocol https://ogp.me/ allow us to get some data from the link that
are shared in message.

task-2365881

closes odoo/odoo#82641

Related: odoo/enterprise#30867
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Didier (did)
2022-09-05 14:49:30 +02:00
parent ece6371229
commit 7ceb079455
52 changed files with 1556 additions and 16 deletions
+1
View File
@@ -57,6 +57,7 @@ class TestImLivechatMessage(TransactionCase):
'is_discussion': False,
'is_note': True,
'is_notification': False,
'linkPreviews': [],
'message_type': 'notification',
'messageReactionGroups': [],
'model': 'mail.channel',
+1
View File
@@ -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',
+26
View File
@@ -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()
+1
View File
@@ -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
+1
View File
@@ -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': {
+151
View File
@@ -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]
+2
View File
@@ -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,
})
+1
View File
@@ -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(),
+1
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
65 access_res_users_settings_volumes_user res.users.settings.volumes model_res_users_settings_volumes base.group_user 1 1 1 1
66 access_mail_template_reset access.mail.template.reset model_mail_template_reset mail.group_mail_template_editor 1 1 1 1
67 ir_actions_report_access_user ir.actions.report.access.user base.model_ir_actions_report base.group_user 1 0 0 0
68 access_mail_link_preview_admin mail.link.preview.admin model_mail_link_preview base.group_erp_manager 1 1 1 1
@@ -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);
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.LinkPreviewAsideView" owl="1">
<button class="o_LinkPreviewAside btn" t-attf-class="{{ className }}" t-on-click="linkPreviewAsideView.onClick" t-ref="root">
<i class="o_LinkPreviewAside_itemUnlinkIcon fa fa-times"/>
</button>
</t>
</templates>
@@ -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);
@@ -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;
}
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.LinkPreviewCardView" owl="1">
<div class="o_LinkPreviewCardView card position-relative w-100 mb-2 rounded border border-light bg-white shadow-sm overflow-hidden" t-att-class="{ 'me-2': !linkPreviewCardView.linkPreviewListViewOwner.messageViewOwner.isInChatWindow }" t-attf-class="{{ className }}" t-on-mouseenter="linkPreviewCardView.onMouseEnter" t-on-mouseleave="linkPreviewCardView.onMouseLeave" t-ref="root">
<div class="row g-0 flex-row-reverse h-100">
<div class="col min-w-0" t-att-class="{ 'd-flex align-items-center': !linkPreviewCardView.linkPreview.og_description }">
<div class="o_LinkPreviewCardView_content p-3 bg-white">
<h6 class="o_LinkPreviewCardView_title card-title mb-0 me-2" t-attf-class="{{ linkPreviewCardView.linkPreview.og_description ? 'text-truncate' : 'o_LinkPreviewCardView_hasMultineLines overflow-hidden' }}">
<a t-att-href="linkPreviewCardView.linkPreview.source_url" target="_blank" t-out="linkPreviewCardView.linkPreview.og_title"/>
</h6>
<p t-if="linkPreviewCardView.linkPreview.og_description" class="o_LinkPreviewCardView_description o_LinkPreviewCardView_hasMultineLines card-text mb-0 mt-2 text-muted small overflow-hidden" t-out="linkPreviewCardView.linkPreview.og_description"/>
</div>
</div>
<div class="col-4 d-block border-end border-light">
<a t-att-href="linkPreviewCardView.linkPreview.source_url" class="o_LinkPreviewCardView_imageLink" target="_blank">
<img t-if="linkPreviewCardView.linkPreview.og_image" class="o_LinkPreviewCardView_image w-100 h-100 o_object_fit_cover" t-att-src="linkPreviewCardView.linkPreview.og_image" t-att-alt="linkPreviewCardView.linkPreview.og_title"/>
<span t-else="" class="o_LinkPreviewCardView_imageEmpty d-flex align-items-center justify-content-center w-100 h-100 bg-100 text-300">
<i class="fa fa-camera fa-2x"/>
</span>
</a>
</div>
</div>
<div t-if="linkPreviewCardView.linkPreviewAsideView" class="o_LinkPreviewCardView_aside position-absolute top-0 end-0 small">
<LinkPreviewAsideView className="'fa fa-stack p-0 opacity-75 opacity-100-hover'" record="linkPreviewCardView.linkPreviewAsideView"/>
</div>
</div>
</t>
</templates>
@@ -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);
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.LinkPreviewDeleteConfirmView" owl="1">
<div class="o_LinkPreviewDeleteConfirmView card bg-white" t-attf-class="{{ className }}" t-ref="root">
<h4 class="m-3">Confirmation</h4>
<hr class="mt-0 mb-3"/>
<p class="o_LinkPreviewDeleteConfirmView_mainText mx-3 mb-3">Do you really want to delete this preview?</p>
<hr class="mt-0 mb-3"/>
<div class="o_LinkPreviewDeleteConfirmView_buttons mx-3 mb-3">
<button class="o_LinkPreviewDeleteConfirmView_confirmButton btn btn-primary me-2" t-on-click="linkPreviewDeleteConfirmView.onClickOk">Ok</button>
<button class="o_LinkPreviewDeleteConfirmView_cancelButton btn btn-secondary me-2" t-on-click="linkPreviewDeleteConfirmView.onClickCancel">Cancel</button>
</div>
</div>
</t>
</templates>
@@ -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);
@@ -0,0 +1,4 @@
.o_LinkPreviewImageView img {
max-height: $o-mail-link-preview-height;
max-width: $o-mail-link-preview-width;
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.LinkPreviewImageView" owl="1">
<div class="o_LinkPreviewImageView position-relative mb-2" t-att-class="{ 'me-2': !linkPreviewImageView.linkPreviewListViewOwner.messageViewOwner.isInChatWindow }" t-attf-class="{{ className }}" t-on-mouseenter="linkPreviewImageView.onMouseEnter" t-on-mouseleave="linkPreviewImageView.onMouseLeave" t-ref="root">
<a t-if="linkPreviewImageView.imageUrl" t-att-href="linkPreviewImageView.imageUrl" target="_blank">
<img class="h-auto w-auto rounded" t-att-src="linkPreviewImageView.imageUrl"/>
</a>
<div t-if="linkPreviewImageView.linkPreviewAsideView" class="o_LinkPreviewCardView_aside position-absolute top-0 end-0 small">
<LinkPreviewAsideView className="'btn btn-sm btn-dark mt-2 me-2 rounded opacity-75 opacity-100-hover'" record="linkPreviewImageView.linkPreviewAsideView"/>
</div>
</div>
</t>
</templates>
@@ -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);
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.LinkPreviewListView" owl="1">
<div class="o_LinkPreviewListView d-flex flex-column mt-2" t-att-class="{ 'me-2 pe-4': linkPreviewListView.messageViewOwner.isInChatWindowAndIsAlignedLeft, 'ms-2 ps-4': linkPreviewListView.messageViewOwner.isInChatWindowAndIsAlignedRight }" t-attf-class="{{ className }}" t-ref="root">
<div class="o_LinkPreviewListView_partialList o_LinkPreviewListView_partialListImages d-flex flex-grow-1 flex-wrap" t-att-class="{ 'justify-content-end': linkPreviewListView.messageViewOwner.isInChatWindowAndIsAlignedRight }">
<LinkPreviewImageView t-foreach="linkPreviewListView.linkPreviewAsImageViews" t-as="linkPreviewImageView" t-key="linkPreviewImageView" record="linkPreviewImageView"/>
</div>
<div class="o_LinkPreviewListView_partialList o_LinkPreviewListView_partialListCards d-flex flex-grow-1 flex-wrap" t-att-class="{ 'justify-content-end': linkPreviewListView.messageViewOwner.isInChatWindowAndIsAlignedRight }">
<LinkPreviewCardView t-foreach="linkPreviewListView.linkPreviewAsCardViews" t-as="linkPreviewCardView" t-key="linkPreviewCardView" record="linkPreviewCardView"/>
<LinkPreviewVideoView t-foreach="linkPreviewListView.linkPreviewAsVideoViews" t-as="linkPreviewVideoView" t-key="linkPreviewVideoView" record="linkPreviewVideoView"/>
</div>
</div>
</t>
</templates>
@@ -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);
@@ -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;
}
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.LinkPreviewVideoView" owl="1">
<div class="o_LinkPreview o_LinkPreviewVideoView card position-relative w-100 mb-2 rounded border border-light bg-white shadow-sm overflow-hidden" t-att-class="{ 'me-2': !linkPreviewVideoView.linkPreviewListViewOwner.messageViewOwner.isInChatWindow }" t-attf-class="{{ className }}" t-on-mouseenter="linkPreviewVideoView.onMouseEnter" t-on-mouseleave="linkPreviewVideoView.onMouseLeave" t-ref="root">
<div class="row g-0 flex-row-reverse h-100">
<div class="col min-w-0" t-att-class="{ 'd-flex align-items-center': !linkPreviewVideoView.linkPreview.og_description }">
<div class="o_LinkPreviewVideoView_content p-3 bg-white">
<h6 class="o_LinkPreviewVideoView_title card-title mb-0 me-2" t-attf-class="{{ linkPreviewVideoView.linkPreview.og_description ? 'text-truncate' : 'o_LinkPreviewVideoView_hasMultineLines overflow-hidden' }}">
<a t-att-href="linkPreviewVideoView.linkPreview.source_url" target="_blank" t-esc="linkPreviewVideoView.linkPreview.og_title"/>
</h6>
<p t-if="linkPreviewVideoView.linkPreview.og_description" class="o_LinkPreviewVideoView_description o_LinkPreviewVideoView_hasMultineLines card-text mb-0 mt-2 text-muted small overflow-hidden" t-out="linkPreviewVideoView.linkPreview.og_description"></p>
</div>
</div>
<div class="col-4 d-block border-end border-light">
<a t-att-href="linkPreviewVideoView.linkPreview.source_url" class="o_LinkPreviewVideoView_imageLink" target="_blank">
<div t-if="linkPreviewVideoView.linkPreview.og_image" class="o_linkPreviewVideo_overlay position-relative h-100 opacity-trigger-hover">
<img class="o_LinkPreviewVideoView_image img-fluid h-100 rounded-bottom o_object_fit_cover" t-att-src="linkPreviewVideoView.linkPreview.og_image" t-att-alt="linkPreviewVideoView.linkPreview.og_title"/>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="btn btn-dark btn-lg rounded opacity-75 opacity-100-hover transition-base">
<i class="fa fa-play text-white"/>
</div>
</div>
</div>
<span t-else="" class="o_LinkPreviewVideoView_imageEmpty d-flex align-items-center justify-content-center w-100 h-100 bg-100 text-300">
<i class="fa fa-camera fa-2x"/>
</span>
</a>
</div>
</div>
<div t-if="linkPreviewVideoView.linkPreviewAsideView" class="o_LinkPreviewVideoView_aside position-absolute top-0 end-0 small">
<LinkPreviewAsideView className="'fa fa-stack p-0 opacity-75 opacity-100-hover'" record="linkPreviewVideoView.linkPreviewAsideView"/>
</div>
</div>
</t>
</templates>
@@ -206,6 +206,7 @@
</div>
</div>
<AttachmentList t-if="messageView.attachmentList" record="messageView.attachmentList" className="'position-relative'"/>
<LinkPreviewListView t-if="messageView.linkPreviewListView" record="messageView.linkPreviewListView"/>
<div t-if="messageView.message.messageReactionGroups.length > 0" class="position-relative d-flex flex-wrap"
t-att-class="{ 'flex-row-reverse me-3': messageView.isInChatWindowAndIsAlignedRight, 'ms-3': !messageView.isInChatWindowAndIsAlignedRight and (messageView.message.isDiscussionOrNotification or messageView.message.message_type === 'sms')}" t-attf-class="{{ messageView.message.isDiscussionOrNotification or messageView.message.message_type === 'sms' ? 'mt-n2' : 'mt-1' }}">
<t t-foreach="messageView.message.messageReactionGroups" t-as="messageReactionGroup" t-key="messageReactionGroup.localId">
@@ -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)
);
},
/**
+22 -1
View File
@@ -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',
@@ -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(),
},
});
@@ -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',
}),
},
});
@@ -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',
}),
},
});
@@ -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,
}),
},
});
@@ -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',
}),
},
});
@@ -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',
}),
},
});
@@ -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',
}),
},
});
+4
View File
@@ -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.
@@ -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).
*/
@@ -394,6 +394,7 @@ registerModel({
fetchImStatusTimerDuration: attr({
default: 50 * 1000,
}),
hasLinkPreviewFeature: attr(),
history: one('Mailbox', {
default: {},
inverse: 'messagingAsHistory',
@@ -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
@@ -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':
@@ -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;
@@ -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,
@@ -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', {
@@ -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),
@@ -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'
);
});
});
});
+1
View File
@@ -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
+91
View File
@@ -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"""
<html>
<head>
<meta property="og:title" content="Test title">
<meta property="og:description" content="Test description">
</head>
</html>
"""
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': '<a href="https://thisdomainedoentexist.nothing">Nothing link</a>',
})
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': '<a href="https://thisdomainedoentexist.nothing">Nothing link</a>',
})
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]
}]
)
+2 -2
View File
@@ -6,7 +6,7 @@
<field name="model">mail.guest</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<tree string="Guest">
<tree string="Guests">
<field name="id"/>
<field name="name"/>
<field name="country_id"/>
@@ -17,7 +17,7 @@
</record>
<record id="mail_guest_action" model="ir.actions.act_window">
<field name="name">User Settings</field>
<field name="name">Guests</field>
<field name="res_model">mail.guest</field>
<field name="view_mode">tree,form</field>
</record>
@@ -0,0 +1,25 @@
<?xml version="1.0"?>
<odoo>
<record id="mail_link_preview_view_tree" model="ir.ui.view">
<field name="name">mail.link.preview.tree</field>
<field name="model">mail.link.preview</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<tree string="Link Previews">
<field name="id"/>
<field name="source_url"/>
<field name="og_title"/>
<field name="og_type"/>
<field name="image_mimetype"/>
</tree>
</field>
</record>
<record id="mail_link_preview_action" model="ir.actions.act_window">
<field name="name">Link Previews</field>
<field name="res_model">mail.link.preview</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>
+7 -2
View File
@@ -8,9 +8,9 @@
sequence="5"
/>
<record id="base.menu_email" model="ir.ui.menu">
<record id="base.menu_email" model="ir.ui.menu">
<field name="sequence">3</field>
</record>
</record>
<!-- Under Technical/Email -->
<menuitem name="Emails"
@@ -134,6 +134,11 @@
action="mail_message_reaction_action"
parent="mail.mail_menu_technical"
sequence="54"/>
<menuitem id="mail_link_preview_menu"
name="Link Previews"
action="mail_link_preview_action"
parent="mail.mail_menu_technical"
sequence="55"/>
<!--
This menuitem will be activated by integrations modules (like github, twitter, ...). It
@@ -6,7 +6,7 @@
<field name="model">mail.message.reaction</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<tree string="Reaction">
<tree string="Reactions">
<field name="id"/>
<field name="message_id"/>
<field name="content"/>
@@ -113,6 +113,7 @@ class TestDiscussFullPerformance(TransactionCase):
The point of having a separate getter is to allow it to be overriden.
"""
return {
'hasLinkPreviewFeature': True,
'needaction_inbox_counter': 1,
'starred_counter': 1,
'channels': [
+2 -2
View File
@@ -1094,7 +1094,7 @@ class TestMailComplexPerformance(BaseMailPerformance):
self.env.flush_all()
self.env.invalidate_all()
with self.assertQueryCount(employee=18):
with self.assertQueryCount(employee=19):
res = messages.message_format()
self.assertEqual(len(res), 2)
for message in res:
@@ -1122,7 +1122,7 @@ class TestMailComplexPerformance(BaseMailPerformance):
self.env.flush_all()
self.env.invalidate_all()
with self.assertQueryCount(employee=13):
with self.assertQueryCount(employee=14):
res = messages.message_format()
self.assertEqual(len(res), 6)