[REF] mail: JS mail refactoring
----------------------
Summary
----------------------
The purpose of this commit is to improve the JS code of the `mail` module.
It applies the new coding guidelines and makes some changes on the design
of some modules, such as the old ChatManager module.
Here is a short summary of the changes that have been made:
1. New coding guidelines
- snake_case to camelCase
- prefix private attributes and methods with '_'
- jsdoc on most methods
- one class per module
2. Rename/Merge some classes
- 'chat manager' becomes 'mail manager' (internal) and 'mail service' (external)
- 'chat window manager' is now included in mail manager
- 'thread' widget now named 'thread widget'
3. New model abstraction for mail objects:
- modules 'mail.model.*'
- modeling:
0..1 0..1 * *
ThreadWindow <------> Thread <-------> Message
/ \
/ \
Thread With Cache Document Thread
/ | \
/ | \
Mailbox Channel Support Channel
|
|
DM
- Thread: the superclass of threads.
- ThreadWindow: the window component of a thread.
- Message: mail objects representing messages.
- Thread With Cache: threads that can be used with search view (Discuss app compatible).
- Document Thread: represents the thread part of a chatter.
- Mailbox: represents what was previously called 'static' channel, e.g. 'Inbox'.
- Channel: mail objects representing channels, including livechat.
- DM: special kind of Channel for 1:1 communication in the backend.
- Support Channel: special channel for im_support module.
This new modeling approach let us easily add features on all threads, such as the
possibility to put any thread in a small window.
----------------------
Known issues
----------------------
[Already Present in Master]
1. When the Discuss app is in the background with 'Inbox' as the selected
Thread, when clicking on a document thread preview in the messaging menu
of the systray, the rainbow man appears.
2. When a document with the chatter is in the background, when receiving an
inbox notification from this document thread, the document thread is
automatically marked as read, which removes the notification right away.
3. Sometimes, opening a DM window from the "blank" thread window does not work.
4. Reply-to feature on Inbox is not working: no message is sent in the document
thread.
5. On the first login of admin user with demo data, the inbox counter is wrong
(it displays 6, instead of 3).
Explanation after investigation:
> On page load, it fetches the correct number of Inbox messages (3),
but the server notifies of 3 needaction messages right away,
so it wrongly assumes these are new needaction messages.
> Not possible for web client to detect that these messages should not
increment the Inbox counter while keeping same API.
6. Notifications for new document thread messages only work when the user sets
'handle with Odoo' for the Notification Management in the preferences.
> due to notifications on the longpoll bus for document thread
messages that come from needaction notifications.
> requires server-side changes to send notification on the longpoll
bus to mentionned user.
[New]
7. When receiving a message on a unjoined channel, thread window flickers
('open' > 'close' > 'open')
Explanation after investigation:
> JS logic:
a) On auto-join, ask server to join the channel and get channel infos.
b) The info tells the channel is not detached, but JS code makes decision
to detach it, and tells server the channel is now detached.
c) From (a), server notifies on longpoll bus the state of channel, which is
not detached. The web client thinks that the window state of the channel
has been changed somewhere else, and the channel is now closed.
d) From (b), server notifies on longpoll bus the state of channel, which is
detached. The web client opens the thread window of this channel.
> The flicker didn't occur before refactoring because the web client was only
updating the model of channel when it receives the longpoll notification.
> Server behaviour on 1st longpoll notification is necessary for cross-tab
synchronization for channel window state.
> New design implies that model and view should be synchronized, hence the
issue now.
> Solution: remove server-side thread window synchronization and replace with
client-side synchronization.
----------------------
Hacks
----------------------
The module `im_livechat` now uses mail objects that are compatible with Message
and Window objects:
Modeling for messages:
AbstractMessage
/ \
/ \
LivechatMessage Message
- AbstractMessage: message compatible with the thread widget.
- LivechatMessage: message used by im_livechat.
- Message: message used with the mail manager.
Modeling for thread windows:
AbstractThreadWindow
/ \
/ \
LivechatWindow ThreadWindow
- AbstractThreadWindow: behaviour share between all types of thread windows.
- LivechatWindow: window used by im_livechat.
- ThreadWindow: window used with mail_manager.
The reason for these hacks are twofold:
1. Use the thread widget in the frontend and livechat external lib bundles.
2. Do not have a dependency with the mail manager in the frontend and external
lib bundles.
This commit is contained in:
@@ -11,7 +11,7 @@ var BusService = AbstractService.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.bus = bus;
|
||||
this._audio = null;
|
||||
@@ -33,19 +33,18 @@ var BusService = AbstractService.extend({
|
||||
/**
|
||||
* Send a notification, and notify once per browser's tab
|
||||
*
|
||||
* @param {web.Widget} widget the widget that called sendNotification
|
||||
* @param {string} title
|
||||
* @param {string} content
|
||||
*/
|
||||
sendNotification: function (widget, title, content) {
|
||||
sendNotification: function (title, content) {
|
||||
if (window.Notification && Notification.permission === "granted") {
|
||||
if (this.bus.is_master) {
|
||||
this._sendNativeNotification(title, content);
|
||||
}
|
||||
} else {
|
||||
widget.do_notify(title, content);
|
||||
this.do_notify(title, content);
|
||||
if (this.bus.is_master) {
|
||||
this._beep(widget);
|
||||
this._beep();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -58,14 +57,13 @@ var BusService = AbstractService.extend({
|
||||
* Lazily play the 'beep' audio on sent notification
|
||||
*
|
||||
* @private
|
||||
* @param {web.Widget} widget
|
||||
*/
|
||||
_beep: function (widget) {
|
||||
_beep: function () {
|
||||
if (typeof(Audio) !== "undefined") {
|
||||
if (!this._audio) {
|
||||
this._audio = new Audio();
|
||||
var ext = this._audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3";
|
||||
var session = widget.getSession();
|
||||
var session = this.getSession();
|
||||
this._audio.src = session.url("/mail/static/src/audio/ting" + ext);
|
||||
}
|
||||
this._audio.play();
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
odoo.define('calendar.systray', function (require) {
|
||||
odoo.define('calendar.systray.ActivityMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ActivityMenu = require('mail.systray').ActivityMenu;
|
||||
var ActivityMenu = require('mail.systray.ActivityMenu');
|
||||
var fieldUtils = require('web.field_utils');
|
||||
|
||||
ActivityMenu.include({
|
||||
@@ -22,7 +22,7 @@ ActivityMenu.include({
|
||||
_getActivityData: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
var meeting = _.find(self.activities, {type: 'meeting'});
|
||||
var meeting = _.find(self._activities, {type: 'meeting'});
|
||||
if (meeting && meeting.meetings) {
|
||||
_.each(meeting.meetings, function (res) {
|
||||
res.start = fieldUtils.parse.datetime(res.start, false, {isUTC: true});
|
||||
@@ -20,8 +20,8 @@
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-extend="mail.chat.ActivityMenuPreview">
|
||||
<t t-jquery="div.o_channel_title" t-operation="after">
|
||||
<t t-extend="mail.systray.ActivityMenu.Previews">
|
||||
<t t-jquery="div.o_preview_title" t-operation="after">
|
||||
<div t-if="activity and activity.type == 'meeting'">
|
||||
<t t-set="is_next_meeting" t-value="true"/>
|
||||
<t t-foreach="activity.meetings" t-as="meeting">
|
||||
|
||||
+10
-10
@@ -1,17 +1,17 @@
|
||||
odoo.define('calendar.systray_tests', function (require) {
|
||||
odoo.define('calendar.systray.ActivityMenuTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var systray = require('mail.systray');
|
||||
var ActivityMenu = require('mail.systray.ActivityMenu');
|
||||
var mailUtils = require('mail.testUtils');
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
var createBusService = require('mail.testUtils').createBusService;
|
||||
|
||||
|
||||
QUnit.module('calendar', {}, function () {
|
||||
|
||||
QUnit.module('ActivityMenu', {
|
||||
beforeEach: function () {
|
||||
this.services = [ChatManager, createBusService()];
|
||||
this.services = mailUtils.getMailServices();
|
||||
this.data = {
|
||||
'calendar.event': {
|
||||
records: [{
|
||||
@@ -40,7 +40,7 @@ QUnit.module('ActivityMenu', {
|
||||
QUnit.test('activity menu widget:today meetings', function (assert) {
|
||||
assert.expect(8);
|
||||
var self = this;
|
||||
var activityMenu = new systray.ActivityMenu();
|
||||
var activityMenu = new ActivityMenu();
|
||||
testUtils.addMockEnvironment(activityMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
@@ -53,19 +53,19 @@ QUnit.test('activity menu widget:today meetings', function (assert) {
|
||||
|
||||
activityMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget');
|
||||
assert.ok(activityMenu.$el.hasClass('o_mail_systray_item'), 'should be the instance of widget');
|
||||
|
||||
testUtils.intercept(activityMenu, 'do_action', function (event) {
|
||||
assert.ok(true, "should have triggered a do_action");
|
||||
assert.strictEqual(event.data.action.res_id, activityMenu.$('.o_meeting_filter').eq(0).data('res_id'), 'should open particular meeting form view')
|
||||
assert.strictEqual(event.data.action.res_id, activityMenu.$('.o_meeting_filter').eq(0).data('res_id'), 'should open particular meeting form view');
|
||||
});
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$('.o_meeting_filter').eq(0).click();
|
||||
|
||||
testUtils.intercept(activityMenu, 'do_action', function (event) {
|
||||
assert.strictEqual(event.data.action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode')
|
||||
assert.strictEqual(event.data.action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode');
|
||||
});
|
||||
activityMenu.$('.o_mail_channel_preview').click();
|
||||
activityMenu.$('.o_mail_preview').click();
|
||||
|
||||
assert.ok(activityMenu.$('.o_meeting_filter'), "should be a meeting");
|
||||
assert.strictEqual(activityMenu.$('.o_meeting_filter').length, 2, 'there should be 2 meetings');
|
||||
@@ -5,14 +5,14 @@
|
||||
<link rel="stylesheet" type="text/scss" href="/calendar/static/src/scss/calendar.scss"/>
|
||||
<script type="text/javascript" src="/calendar/static/src/js/base_calendar.js"></script>
|
||||
<script type="text/javascript" src="/calendar/static/src/js/mail_activity.js"></script>
|
||||
<script type="text/javascript" src="/calendar/static/src/js/systray.js"></script>
|
||||
<script type="text/javascript" src="/calendar/static/src/js/systray_activity_menu.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="qunit_suite" inherit_id="web.qunit_suite">
|
||||
<xpath expr="//script[last()]" position="after">
|
||||
<script type="text/javascript" src="/calendar/static/tests/calendar_tests.js"/>
|
||||
<script type="text/javascript" src="/calendar/static/tests/systray_tests.js"/>
|
||||
<script type="text/javascript" src="/calendar/static/tests/systray_activity_menu_tests.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ odoo.define('im_livechat.im_livechat', function (require) {
|
||||
"use strict";
|
||||
|
||||
var bus = require('bus.bus').bus;
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
@@ -9,7 +10,8 @@ var time = require('web.time');
|
||||
var utils = require('web.utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var ChatWindow = require('mail.ChatWindow');
|
||||
var LivechatMessage = require('im_livechat.model.LivechatMessage');
|
||||
var LivechatWindow = require('im_livechat.LivechatWindow');
|
||||
|
||||
var _t = core._t;
|
||||
var QWeb = core.qweb;
|
||||
@@ -26,127 +28,169 @@ var RATING_TO_EMOJI = {
|
||||
|
||||
// History tracking
|
||||
var page = window.location.href.replace(/^.*\/\/[^/]+/, '');
|
||||
var page_history = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
|
||||
var url_history = [];
|
||||
if (page_history) {
|
||||
url_history = JSON.parse(page_history) || [];
|
||||
var pageHistory = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
|
||||
var urlHistory = [];
|
||||
if (pageHistory) {
|
||||
urlHistory = JSON.parse(pageHistory) || [];
|
||||
}
|
||||
if (!_.contains(url_history, page)) {
|
||||
url_history.push(page);
|
||||
while (url_history.length > HISTORY_LIMIT) {
|
||||
url_history.shift();
|
||||
if (!_.contains(urlHistory, page)) {
|
||||
urlHistory.push(page);
|
||||
while (urlHistory.length > HISTORY_LIMIT) {
|
||||
urlHistory.shift();
|
||||
}
|
||||
utils.set_cookie(LIVECHAT_COOKIE_HISTORY, JSON.stringify(url_history), 60*60*24); // 1 day cookie
|
||||
utils.set_cookie(LIVECHAT_COOKIE_HISTORY, JSON.stringify(urlHistory), 60*60*24); // 1 day cookie
|
||||
}
|
||||
|
||||
var LivechatButton = Widget.extend({
|
||||
className:"openerp o_livechat_button hidden-print",
|
||||
|
||||
className:'openerp o_livechat_button hidden-print',
|
||||
events: {
|
||||
"click": "open_chat"
|
||||
'click': '_openChat'
|
||||
},
|
||||
|
||||
init: function (parent, server_url, options) {
|
||||
init: function (parent, serverURL, options) {
|
||||
this._super(parent);
|
||||
this.options = _.defaults(options || {}, {
|
||||
input_placeholder: _t('Ask something ...'),
|
||||
input_placeholder: _t("Ask something ..."),
|
||||
default_username: _t("Visitor"),
|
||||
button_text: _t("Chat with one of our collaborators"),
|
||||
default_message: _t("How may I help you?"),
|
||||
});
|
||||
this.channel = null;
|
||||
this.chat_window = null;
|
||||
this.messages = [];
|
||||
this.server_url = server_url;
|
||||
this.busBus = bus;
|
||||
this._channel = null;
|
||||
this._chatWindow = null;
|
||||
this._messages = [];
|
||||
this._serverURL = serverURL;
|
||||
this._busBus = bus;
|
||||
},
|
||||
|
||||
willStart: function () {
|
||||
var self = this;
|
||||
var cookie = utils.get_cookie('im_livechat_session');
|
||||
var ready;
|
||||
if (!cookie) {
|
||||
ready = session.rpc("/im_livechat/init", {channel_id: this.options.channel_id})
|
||||
ready = session.rpc('/im_livechat/init', {channel_id: this.options.channel_id})
|
||||
.then(function (result) {
|
||||
if (!result.available_for_me) {
|
||||
return $.Deferred().reject();
|
||||
}
|
||||
self.rule = result.rule;
|
||||
self._rule = result.rule;
|
||||
});
|
||||
} else {
|
||||
var channel = JSON.parse(cookie);
|
||||
ready = session.rpc("/mail/chat_history", {uuid: channel.uuid, limit: 100})
|
||||
ready = session.rpc('/mail/chat_history', {uuid: channel.uuid, limit: 100})
|
||||
.then(function (history) {
|
||||
self.history = history;
|
||||
self._history = history;
|
||||
});
|
||||
}
|
||||
return ready.then(this.load_qweb_template.bind(this));
|
||||
return ready.then(this._loadQWebTemplate.bind(this));
|
||||
},
|
||||
|
||||
start: function () {
|
||||
this.$el.text(this.options.button_text);
|
||||
if (this.history) {
|
||||
_.each(this.history.reverse(), this.add_message.bind(this));
|
||||
this.open_chat();
|
||||
} else if (!config.device.isMobile && this.rule.action === 'auto_popup') {
|
||||
var auto_popup_cookie = utils.get_cookie('im_livechat_auto_popup');
|
||||
if (!auto_popup_cookie || JSON.parse(auto_popup_cookie)) {
|
||||
this.auto_popup_timeout =
|
||||
setTimeout(this.open_chat.bind(this), this.rule.auto_popup_timer*1000);
|
||||
if (this._history) {
|
||||
_.each(this._history.reverse(), this._addMessage.bind(this));
|
||||
this._openChat();
|
||||
} else if (!config.device.isMobile && this._rule.action === 'auto_popup') {
|
||||
var autoPopupCookie = utils.get_cookie('im_livechat_auto_popup');
|
||||
if (!autoPopupCookie || JSON.parse(autoPopupCookie)) {
|
||||
this._autoPopupTimeout =
|
||||
setTimeout(this._openChat.bind(this), this._rule.auto_popup_timer*1000);
|
||||
}
|
||||
}
|
||||
this.busBus.on('notification', this, function (notifications) {
|
||||
var self = this;
|
||||
_.each(notifications, function (notification) {
|
||||
self._on_notification(notification);
|
||||
});
|
||||
});
|
||||
this._busBus.on('notification', this, this._onNotification);
|
||||
return this._super();
|
||||
},
|
||||
_on_notification: function (notification){
|
||||
if (this.channel && (notification[0] === this.channel.uuid)) {
|
||||
if (notification[1]._type === "history_command") { // history request
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object} [options={}]
|
||||
*/
|
||||
_addMessage: function (data, options) {
|
||||
options = _.extend({}, options, {
|
||||
serverURL: this._serverURL,
|
||||
});
|
||||
var message = new LivechatMessage(this, data, options);
|
||||
|
||||
if (options && options.prepend) {
|
||||
this._messages.unshift(message);
|
||||
} else {
|
||||
this._messages.push(message);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_askFeedback: function () {
|
||||
this._chatWindow.$('.o_thread_composer input').prop('disabled', true);
|
||||
|
||||
var feedback = new Feedback(this, this._channel);
|
||||
feedback.replace(this._chatWindow.threadWidget.$el);
|
||||
|
||||
feedback.on('send_message', this, this._sendMessage);
|
||||
feedback.on('feedback_sent', this, this._closeChat);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeChat: function () {
|
||||
this._chatWindow.destroy();
|
||||
utils.set_cookie('im_livechat_session', "", -1); // remove cookie
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Array} notification
|
||||
*/
|
||||
_handleNotification: function (notification){
|
||||
if (this._channel && (notification[0] === this._channel.uuid)) {
|
||||
if (notification[1]._type === 'history_command') { // history request
|
||||
var cookie = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
|
||||
var history = cookie ? JSON.parse(cookie) : [];
|
||||
session.rpc("/im_livechat/history", {
|
||||
pid: this.channel.operator_pid[0],
|
||||
channel_uuid: this.channel.uuid,
|
||||
session.rpc('/im_livechat/history', {
|
||||
pid: this._channel.operator_pid[0],
|
||||
channel_uuid: this._channel.uuid,
|
||||
page_history: history,
|
||||
});
|
||||
} else { // normal message
|
||||
this.add_message(notification[1]);
|
||||
this.render_messages();
|
||||
if (this.chat_window.folded || !this.chat_window.thread.is_at_bottom()) {
|
||||
this.chat_window.update_unread(this.chat_window.unread_msgs+1);
|
||||
this._addMessage(notification[1]);
|
||||
this._renderMessages();
|
||||
if (this._chatWindow.isFolded() || !this._chatWindow.threadWidget.isAtBottom()) {
|
||||
this._chatWindow.incrementUnreadCounter();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
load_qweb_template: function (){
|
||||
var xml_files = ['/mail/static/src/xml/chat_window.xml',
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_loadQWebTemplate: function () {
|
||||
var xml_files = ['/mail/static/src/xml/abstract_thread_window.xml',
|
||||
'/mail/static/src/xml/thread.xml',
|
||||
'/im_livechat/static/src/xml/im_livechat.xml'];
|
||||
var defs = _.map(xml_files, function (tmpl) {
|
||||
return session.rpc('/web/proxy/load', {path: tmpl}).then(function (xml) {
|
||||
return session.rpc('/web/proxy/load', { path: tmpl }).then(function (xml) {
|
||||
QWeb.add_template(xml);
|
||||
});
|
||||
});
|
||||
return $.when.apply($, defs);
|
||||
},
|
||||
|
||||
open_chat: _.debounce(function () {
|
||||
if (this.opening_chat) {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_openChat: _.debounce(function () {
|
||||
if (this._openingChat) {
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
var cookie = utils.get_cookie('im_livechat_session');
|
||||
var def;
|
||||
this.opening_chat = true;
|
||||
clearTimeout(this.auto_popup_timeout);
|
||||
this._openingChat = true;
|
||||
clearTimeout(this._autoPopupTimeout);
|
||||
if (cookie) {
|
||||
def = $.when(JSON.parse(cookie));
|
||||
} else {
|
||||
this.messages = []; // re-initialize messages cache
|
||||
this._messages = []; // re-initialize messages cache
|
||||
def = session.rpc('/im_livechat/get_session', {
|
||||
channel_id : this.options.channel_id,
|
||||
anonymous_name : this.options.default_username,
|
||||
@@ -156,141 +200,121 @@ var LivechatButton = Widget.extend({
|
||||
if (!channel || !channel.operator_pid) {
|
||||
alert(_t("None of our collaborators seems to be available, please try again later."));
|
||||
} else {
|
||||
self.channel = channel;
|
||||
self.open_chat_window(channel);
|
||||
self.send_welcome_message();
|
||||
self.render_messages();
|
||||
self._channel = channel;
|
||||
self._openChatWindow(channel);
|
||||
self._sendWelcomeMessage();
|
||||
self._renderMessages();
|
||||
|
||||
self.busBus.add_channel(channel.uuid);
|
||||
self.busBus.start_polling();
|
||||
self._busBus.add_channel(channel.uuid);
|
||||
self._busBus.start_polling();
|
||||
|
||||
utils.set_cookie('im_livechat_session', JSON.stringify(channel), 60*60);
|
||||
utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60*60);
|
||||
}
|
||||
}).always(function () {
|
||||
self.opening_chat = false;
|
||||
self._openingChat = false;
|
||||
});
|
||||
}, 200, true),
|
||||
|
||||
open_chat_window: function (channel) {
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} channel
|
||||
*/
|
||||
_openChatWindow: function (channel) {
|
||||
var self = this;
|
||||
var options = {
|
||||
display_stars: false,
|
||||
displayStars: false,
|
||||
placeholder: this.options.input_placeholder || "",
|
||||
};
|
||||
var is_folded = (channel.state === 'folded');
|
||||
this.chat_window = new ChatWindow(this, channel.id, channel.name, is_folded, channel.message_unread_counter, options);
|
||||
this.chat_window.appendTo($('body')).then(function () {
|
||||
self.chat_window.$el.css({right: 0, bottom: 0});
|
||||
this._chatWindow = new LivechatWindow(this, channel, options);
|
||||
this._chatWindow.appendTo($('body')).then(function () {
|
||||
self._chatWindow.$el.css({right: 0, bottom: 0});
|
||||
self.$el.hide();
|
||||
});
|
||||
this.chat_window.on("close_chat_session", this, function () {
|
||||
var input_disabled = this.chat_window.$(".o_chat_composer input").prop('disabled');
|
||||
var ask_fb = !input_disabled && _.find(this.messages, function (msg) {
|
||||
return msg.id !== '_welcome';
|
||||
this._chatWindow.on('close', this, function () {
|
||||
var input_disabled = this._chatWindow.$('.o_thread_composer input').prop('disabled');
|
||||
var ask_fb = !input_disabled && _.find(this._messages, function (message) {
|
||||
return message.getID() !== '_welcome';
|
||||
});
|
||||
if (ask_fb) {
|
||||
this.chat_window.toggle_fold(false);
|
||||
this.ask_feedback();
|
||||
this._chatWindow.toggleFold(false);
|
||||
this._askFeedback();
|
||||
} else {
|
||||
this.close_chat();
|
||||
this._closeChat();
|
||||
}
|
||||
});
|
||||
this.chat_window.on("post_message", this, function (message) {
|
||||
self.send_message(message).fail(function (error, e) {
|
||||
this._chatWindow.on('post_message', this, function (message) {
|
||||
self._sendMessage(message).fail(function (error, e) {
|
||||
e.preventDefault();
|
||||
return self.send_message(message); // try again just in case
|
||||
return self._sendMessage(message); // try again just in case
|
||||
});
|
||||
});
|
||||
this.chat_window.on("fold_channel", this, function () {
|
||||
this.channel.state = (this.channel.state === 'open') ? 'folded' : 'open';
|
||||
utils.set_cookie('im_livechat_session', JSON.stringify(this.channel), 60*60);
|
||||
});
|
||||
this.chat_window.thread.$el.on("scroll", null, _.debounce(function () {
|
||||
if (self.chat_window.thread.is_at_bottom()) {
|
||||
self.chat_window.update_unread(0);
|
||||
this._chatWindow.on('save_chat', this, this._onSaveChat);
|
||||
this._chatWindow.threadWidget.$el.on('scroll', null, _.debounce(function () {
|
||||
if (self._chatWindow.threadWidget.isAtBottom()) {
|
||||
self._chatWindow.resetUnreadCounter();
|
||||
}
|
||||
}, 100));
|
||||
},
|
||||
|
||||
close_chat: function () {
|
||||
this.chat_window.destroy();
|
||||
utils.set_cookie('im_livechat_session', "", -1); // remove cookie
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderMessages: function () {
|
||||
var shouldScroll = !this._chatWindow.isFolded() && this._chatWindow.threadWidget.isAtBottom();
|
||||
this._chatWindow.render(this._messages);
|
||||
if (shouldScroll) {
|
||||
this._chatWindow.threadWidget.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
send_message: function (message) {
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
* @return {$.Deferred}
|
||||
*/
|
||||
_sendMessage: function (message) {
|
||||
var self = this;
|
||||
return session
|
||||
.rpc("/mail/chat_post", {uuid: this.channel.uuid, message_content: message.content})
|
||||
.rpc('/mail/chat_post', {uuid: this._channel.uuid, message_content: message.content})
|
||||
.then(function () {
|
||||
self.chat_window.thread.scroll_to();
|
||||
self._chatWindow.threadWidget.scrollToBottom();
|
||||
});
|
||||
},
|
||||
|
||||
add_message: function (data, options) {
|
||||
var msg = {
|
||||
id: data.id,
|
||||
attachment_ids: data.attachment_ids,
|
||||
author_id: data.author_id,
|
||||
body: data.body,
|
||||
date: moment(time.str_to_datetime(data.date)),
|
||||
is_needaction: false,
|
||||
is_note: data.is_note,
|
||||
is_discussion: data.is_discussion,
|
||||
is_notification: data.is_notification,
|
||||
customer_email_data: []
|
||||
};
|
||||
|
||||
// Compute displayed author name or email
|
||||
msg.displayed_author = msg.author_id && msg.author_id[1] ||
|
||||
this.options.default_username;
|
||||
|
||||
// Compute the avatar_url
|
||||
msg.avatar_src = this.server_url;
|
||||
if (msg.author_id && msg.author_id[0]) {
|
||||
msg.avatar_src += "/web/image/res.partner/" + msg.author_id[0] + "/image_small";
|
||||
} else {
|
||||
msg.avatar_src += "/mail/static/src/img/smiley/avatar.jpg";
|
||||
}
|
||||
|
||||
if (options && options.prepend) {
|
||||
this.messages.unshift(msg);
|
||||
} else {
|
||||
this.messages.push(msg);
|
||||
}
|
||||
},
|
||||
|
||||
render_messages: function () {
|
||||
var should_scroll = !this.chat_window.folded && this.chat_window.thread.is_at_bottom();
|
||||
this.chat_window.render(this.messages);
|
||||
if (should_scroll) {
|
||||
this.chat_window.thread.scroll_to();
|
||||
}
|
||||
},
|
||||
|
||||
send_welcome_message: function () {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_sendWelcomeMessage: function () {
|
||||
if (this.options.default_message) {
|
||||
this.add_message({
|
||||
this._addMessage({
|
||||
id: '_welcome',
|
||||
attachment_ids: [],
|
||||
author_id: this.channel.operator_pid,
|
||||
author_id: this._channel.operator_pid,
|
||||
body: this.options.default_message,
|
||||
channel_ids: [this.channel.id],
|
||||
channel_ids: [this._channel.id],
|
||||
date: time.datetime_to_str(new Date()),
|
||||
tracking_value_ids: [],
|
||||
}, {prepend: true});
|
||||
}
|
||||
},
|
||||
|
||||
ask_feedback: function () {
|
||||
this.chat_window.$(".o_chat_composer input").prop('disabled', true);
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
var feedback = new Feedback(this, this.channel.uuid);
|
||||
feedback.replace(this.chat_window.thread.$el);
|
||||
|
||||
feedback.on("send_message", this, this.send_message);
|
||||
feedback.on("feedback_sent", this, this.close_chat);
|
||||
}
|
||||
/**
|
||||
* @private
|
||||
* @param {Array[]} notifications
|
||||
*/
|
||||
_onNotification: function (notifications) {
|
||||
var self = this;
|
||||
_.each(notifications, function (notification) {
|
||||
self._handleNotification(notification);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onSaveChat: function () {
|
||||
utils.set_cookie('im_livechat_session', JSON.stringify(this._channel), 60*60);
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -300,51 +324,33 @@ var LivechatButton = Widget.extend({
|
||||
* (only for red smiley), and sends the user feedback to the server.
|
||||
*/
|
||||
var Feedback = Widget.extend({
|
||||
template: "im_livechat.FeedBack",
|
||||
template: 'im_livechat.FeedBack',
|
||||
|
||||
events: {
|
||||
'click .o_livechat_rating_choices img': 'on_click_smiley',
|
||||
'click .o_livechat_no_feedback span': 'on_click_no_feedback',
|
||||
'click .o_rating_submit_button': 'on_click_send',
|
||||
'click .o_livechat_rating_choices img': '_onClickSmiley',
|
||||
'click .o_livechat_no_feedback span': '_onClickNoFeedback',
|
||||
'click .o_rating_submit_button': '_onClickSend',
|
||||
},
|
||||
|
||||
init: function (parent, channel_uuid) {
|
||||
init: function (parent, channel) {
|
||||
this._super(parent);
|
||||
this.channel_uuid = channel_uuid;
|
||||
this._channel = channel;
|
||||
this.server_origin = session.origin;
|
||||
this.rating = undefined;
|
||||
},
|
||||
|
||||
on_click_smiley: function (ev) {
|
||||
this.rating = parseInt($(ev.currentTarget).data('value'));
|
||||
this.$('.o_livechat_rating_choices img').removeClass('selected');
|
||||
this.$('.o_livechat_rating_choices img[data-value="'+this.rating+'"]').addClass('selected');
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
// only display textearea if bad smiley selected
|
||||
var close_chat = false;
|
||||
if (this.rating !== 10) {
|
||||
this.$('.o_livechat_rating_reason').show();
|
||||
} else {
|
||||
this.$('.o_livechat_rating_reason').hide();
|
||||
close_chat = true;
|
||||
}
|
||||
this._send_feedback({close: close_chat});
|
||||
},
|
||||
|
||||
on_click_no_feedback: function () {
|
||||
this.trigger("feedback_sent"); // will close the chat
|
||||
},
|
||||
|
||||
on_click_send: function () {
|
||||
if (_.isNumber(this.rating)) {
|
||||
this._send_feedback({ reason: this.$('textarea').val(), close: true });
|
||||
}
|
||||
},
|
||||
|
||||
_send_feedback: function (options) {
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} options
|
||||
*/
|
||||
_sendFeedback: function (options) {
|
||||
var self = this;
|
||||
var args = {
|
||||
uuid: this.channel_uuid,
|
||||
uuid: this._channel.uuid,
|
||||
rate: this.rating,
|
||||
reason : options.reason
|
||||
};
|
||||
@@ -355,11 +361,49 @@ var Feedback = Widget.extend({
|
||||
if (options.reason) {
|
||||
content += " \n" + options.reason;
|
||||
}
|
||||
self.trigger("send_message", {content: content});
|
||||
self.trigger("feedback_sent"); // will close the chat
|
||||
self.trigger('send_message', { content: content });
|
||||
self.trigger('feedback_sent'); // will close the chat
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickNoFeedback: function () {
|
||||
this.trigger('feedback_sent'); // will close the chat
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickSend: function () {
|
||||
if (_.isNumber(this.rating)) {
|
||||
this._sendFeedback({ reason: this.$('textarea').val(), close: true });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickSmiley: function (ev) {
|
||||
this.rating = parseInt($(ev.currentTarget).data('value'));
|
||||
this.$('.o_livechat_rating_choices img').removeClass('selected');
|
||||
this.$('.o_livechat_rating_choices img[data-value="'+this.rating+'"]').addClass('selected');
|
||||
|
||||
// only display textearea if bad smiley selected
|
||||
var shouldCloseChatWindow = false;
|
||||
if (this.rating !== 10) {
|
||||
this.$('.o_livechat_rating_reason').show();
|
||||
} else {
|
||||
this.$('.o_livechat_rating_reason').hide();
|
||||
shouldCloseChatWindow = true;
|
||||
}
|
||||
this._sendFeedback({ close: shouldCloseChatWindow });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
odoo.define('im_livechat.chat_discuss', function (require) {
|
||||
odoo.define('im_livechat.Discuss', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Discuss = require('mail.chat_discuss');
|
||||
var Discuss = require('mail.Discuss');
|
||||
|
||||
Discuss.include({
|
||||
_renderSidebar: function (options) {
|
||||
// Override to sort livechat channels by last message's date
|
||||
var channel_partition = _.partition(options.channels, function (channel) {
|
||||
return channel.type === 'livechat';
|
||||
// Override to sort livechat threads by last message's date
|
||||
var threadPartition = _.partition(options.threads, function (thread) {
|
||||
return thread.getType() === 'livechat';
|
||||
});
|
||||
channel_partition[0].sort(function (c1, c2) {
|
||||
return c2.last_message_date.diff(c1.last_message_date);
|
||||
threadPartition[0].sort(function (c1, c2) {
|
||||
if (!c1.hasMessages()) {
|
||||
return -1;
|
||||
} else if (!c2.hasMessages()) {
|
||||
return 1;
|
||||
}
|
||||
return c2.getLastMessage().getDate().diff(c1.getLastMessage().getDate());
|
||||
});
|
||||
options.channels = channel_partition[0].concat(channel_partition[1]);
|
||||
options.threads = threadPartition[0].concat(threadPartition[1]);
|
||||
return this._super(options);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
odoo.define('im_livechat.LivechatWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractThreadWindow = require('mail.AbstractThreadWindow');
|
||||
|
||||
/**
|
||||
* This is the widget that represent windows of livechat in the frontend.
|
||||
*
|
||||
* @see mail.AbstractThreadWindow for more information
|
||||
*/
|
||||
var LivechatWindow = AbstractThreadWindow.extend({
|
||||
/**
|
||||
* The thread of a live chat window is just an object containing some
|
||||
* server data of the livechat
|
||||
*
|
||||
* @override
|
||||
* @param {mail.Manager} parent
|
||||
* @param {Object} data
|
||||
* @param {integer} data.id
|
||||
* @param {string} data.name
|
||||
* @param {string} data.state
|
||||
* @param {integer} [data.message_unread_counter=0]
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._data = data;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
close: function () {
|
||||
this.trigger('close');
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
getTitle: function () {
|
||||
return this._data.name;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {integer}
|
||||
*/
|
||||
getUnreadCounter: function () {
|
||||
return this._data.message_unread_counter;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasThread: function () {
|
||||
return !!this._data;
|
||||
},
|
||||
/**
|
||||
* Increment the unread counter of this thread window, which is equivalent
|
||||
* to incrementing the unread counter of the related channel
|
||||
*/
|
||||
incrementUnreadCounter: function () {
|
||||
this._data.message_unread_counter++;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isFolded: function () {
|
||||
return (this._data.state === 'folded');
|
||||
},
|
||||
/**
|
||||
* Warn the parent widget (LivechatButton)
|
||||
*
|
||||
* @override
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
toggleFold: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.trigger('save_chat');
|
||||
},
|
||||
/**
|
||||
* Reset the unread counter of the livechat window
|
||||
*/
|
||||
resetUnreadCounter: function () {
|
||||
this._data.message_unread_counter = 0;
|
||||
this._renderHeader();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If there are no chat linked to this livechat window, returns null.
|
||||
*
|
||||
* @override
|
||||
* @returns {integer|null}
|
||||
*/
|
||||
_getThreadID: function () {
|
||||
if (!this.hasThread()) {
|
||||
return null;
|
||||
}
|
||||
return this._data.id;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} messageData
|
||||
*/
|
||||
_postMessage: function (messageData) {
|
||||
this.trigger('post_message', messageData);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
_updateThreadFoldState: function (folded) {
|
||||
this._data.state = folded ? 'folded' : 'open';
|
||||
this.updateVisualFoldState();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
});
|
||||
|
||||
return LivechatWindow;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
odoo.define('im_livechat.model.LivechatMessage', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractMessage = require('mail.model.AbstractMessage');
|
||||
|
||||
/**
|
||||
* This is a message that is handled by im_livechat, without making use of the
|
||||
* mail.Manager. The purpose of this is to make im_livechat compatible with
|
||||
* mail.widget.Thread.
|
||||
*
|
||||
* @see mail.model.AbstractMessage for more information.
|
||||
*/
|
||||
var LivechatMessage = AbstractMessage.extend({
|
||||
|
||||
/**
|
||||
* @param {Widget} parent
|
||||
* @param {Object} data
|
||||
* @param {Object} options
|
||||
* @param {string} options.serverURL
|
||||
*/
|
||||
init: function (parent, data, options) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._defaultUsername = options.default_username;
|
||||
this._serverURL = options.serverURL;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the relative url of the avatar to display next to the message
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getAvatarSource: function () {
|
||||
var source = this._serverURL;
|
||||
if (this.hasAuthor()) {
|
||||
source += '/web/image/res.partner/' + this.getAuthorID() + '/image_small';
|
||||
} else {
|
||||
source += '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return source;
|
||||
},
|
||||
/**
|
||||
* Get the text to display for the author of the message
|
||||
*
|
||||
* Rule of precedence for the displayed author:
|
||||
*
|
||||
* author name > default usernane
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getDisplayedAuthor: function () {
|
||||
return this._super.apply(this, arguments) || this._defaultUsername;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return LivechatMessage;
|
||||
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.o_chat_window {
|
||||
.o_thread_window {
|
||||
z-index: 1002; // to go over the navbar
|
||||
.o_thread_date_separator {
|
||||
display: none;
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_chat_window {
|
||||
.o_thread_window {
|
||||
&,* {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
.o_chat_header {
|
||||
.o_thread_window_header {
|
||||
height: 28px;
|
||||
.fa-close {
|
||||
text-decoration: none;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<template>
|
||||
|
||||
<t t-extend="mail.chat.Sidebar">
|
||||
<t t-jquery=".o_mail_chat_sidebar" t-operation="append">
|
||||
<t t-set="channel_type" t-value="'livechat'"/>
|
||||
<t t-set="disable_add_channel" t-value="true"/>
|
||||
<t t-call="mail.chat.SidebarTitle">
|
||||
<t t-set="channel_title">Livechat</t>
|
||||
<t t-set="channel_icon" t-value="fa-comments"/>
|
||||
<t t-extend="mail.discuss.Sidebar">
|
||||
<t t-jquery=".o_mail_discuss_sidebar" t-operation="append">
|
||||
<t t-set="type" t-value="'livechat'"/>
|
||||
<t t-set="disableAddThread" t-value="true"/>
|
||||
<t t-call="mail.discuss.SidebarTitle">
|
||||
<t t-set="title">Livechat</t>
|
||||
<t t-set="icon" t-value="fa-comments"/>
|
||||
</t>
|
||||
<t t-call="mail.chat.SidebarItems"/>
|
||||
<t t-call="mail.discuss.SidebarItems"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<data>
|
||||
|
||||
<!--
|
||||
Integrate Livechat Conversation in the Mail Chat Client Action
|
||||
Integrate Livechat Conversation in the Discuss
|
||||
-->
|
||||
<template id="assets_backend" name="im_livechat assets backend" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
@@ -135,14 +135,21 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/core/abstract_storage_service.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/services/local_storage_service.js"></script>
|
||||
<!-- Bus, Mail, Livechat -->
|
||||
<!-- thread windows -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/livechat_window.js"></script>
|
||||
<script type="text/javascript" src="/bus/static/src/js/bus.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/chat_window.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/document_viewer.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/im_livechat.js"></script>
|
||||
<!-- Models -->
|
||||
<!-- messages -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/messages/abstract_message.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/models/livechat_message.js"></script>
|
||||
<t t-call="web._assets_helpers"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat_bootstrap.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/chat_window.scss"></link>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/abstract_thread_window.scss"></link>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/thread.scss"></link>
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat.scss"/>
|
||||
</template>
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
odoo.define('im_support.ChatManager', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module includes the ChatManager to handle the case of the Support
|
||||
* channel, allowing the users of the current database to communicate with
|
||||
* livechat operators from another database (the Support database).
|
||||
*/
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var WebClient = require('web.WebClient');
|
||||
|
||||
var supportSession = require('im_support.SupportSession');
|
||||
var supportBus = require('im_support.SupportBus');
|
||||
|
||||
var _t = core._t;
|
||||
var CHANNEL_STATES = {
|
||||
CLOSED: 'closed',
|
||||
FOLDED: 'folded',
|
||||
OPEN: 'open',
|
||||
};
|
||||
var ODOOBOT_ID = "ODOOBOT"; // FIXME: should be exported by the ChatManager
|
||||
var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes
|
||||
var POLL_TIMEOUT_KEY = 'im_support.poll_timeout';
|
||||
var SUPPORT_CHANNEL_LIMIT = 30; // limit of messages to fetch
|
||||
var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state';
|
||||
|
||||
ChatManager.include({
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
closeChatSession: function (channelID) {
|
||||
if (channelID === this.supportChannelUUID) {
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.CLOSED);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
detachChannel: function (channelID) {
|
||||
if (channelID === this.supportChannelUUID) {
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.OPEN);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
foldChannel: function (channelID, folded) {
|
||||
if (channelID === this.supportChannelUUID) {
|
||||
var value = folded ? CHANNEL_STATES.FOLDED : CHANNEL_STATES.OPEN;
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, value);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to filter out the Support channel from the previews.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
getChannelsPreview: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function (channelsPreview) {
|
||||
return _.reject(channelsPreview, {id: self.supportChannelUUID});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
getMentionPartnerSuggestions: function (channel) {
|
||||
if (channel && channel.id === this.supportChannelUUID) {
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Initialises the Support: checks if there is a pending chat session
|
||||
* between the user and Support, and if so, re-opens it.
|
||||
* Note: we can't directly override init(), because it is already called
|
||||
* when the include is applied, so we use this hook instead (called at
|
||||
* webclient startup)
|
||||
*/
|
||||
initSupport: function () {
|
||||
var self = this;
|
||||
this.supportChannelDef = null;
|
||||
this.supportChannelUUID = null;
|
||||
this.pollTimeout = null;
|
||||
this.pollingSupport = false;
|
||||
|
||||
// listen to notifications coming from Support longpolling
|
||||
supportBus.on('notification', this, this._onSupportNotification);
|
||||
|
||||
// check if there is a pending chat session with the Support
|
||||
var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY);
|
||||
var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now());
|
||||
if (pollingDelay && pollingDelay > 0) {
|
||||
var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY);
|
||||
this.startSupportLivechat(channelState).then(function () {
|
||||
var supportChannel = self.getChannel(self.supportChannelUUID);
|
||||
if (supportChannel.available) {
|
||||
self._startPollingSupport(pollingDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
markChannelAsSeen: function (channel) {
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
this._updateChannelUnreadCounter(channel, 0);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to call a specific route for messages posted in the Support
|
||||
* channel.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
postMessage: function (data, options) {
|
||||
if (options.channelID && options.channelID === this.supportChannelUUID) {
|
||||
// ensure that the poll is active before posting the message
|
||||
if (!this.pollingSupport) {
|
||||
this._startPollingSupport();
|
||||
}
|
||||
return supportSession.rpc("/odoo_im_support/chat_post", {
|
||||
uuid: this.supportChannelUUID,
|
||||
message_content: data.content,
|
||||
});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Opens the support channel between a livechat operator from the Support
|
||||
* database and the current user (if there is an available operator).
|
||||
* Ensures to perform only once the request to create/retrieve the Support
|
||||
* channel.
|
||||
*
|
||||
* @param {string} [channelState=CHANNEL_STATES.OPEN] state of the Support
|
||||
* channel (see CHANNEL_STATES for accepted values)
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
startSupportLivechat: function (channelState) {
|
||||
var self = this;
|
||||
if (!this.supportChannelDef) {
|
||||
// retrieve or create the channel
|
||||
this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', {
|
||||
channel_uuid: session.support_token,
|
||||
db_uuid: session.db_uuid,
|
||||
user_name: session.name,
|
||||
});
|
||||
}
|
||||
return this.supportChannelDef.then(function (channel) {
|
||||
if (!channel) {
|
||||
// there is no channel (because there is no online operator, and
|
||||
// no support channel has been created yet), so create one to
|
||||
// open in a chat window
|
||||
channel = {
|
||||
available: false,
|
||||
support_channel: true,
|
||||
type: 'livechat',
|
||||
uuid: "support_unavailable",
|
||||
};
|
||||
}
|
||||
if (!channelState) {
|
||||
channelState = self.discussOpen ? CHANNEL_STATES.CLOSED : CHANNEL_STATES.OPEN;
|
||||
self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState);
|
||||
}
|
||||
if (!self.supportChannelUUID) {
|
||||
// this part is only executed the first time the RPC is resolved
|
||||
self.supportChannelUUID = channel.uuid;
|
||||
|
||||
// add the channel to the ChatManager
|
||||
self._addChannel(_.extend(channel, {
|
||||
id: channel.uuid,
|
||||
is_minimized: _.contains([CHANNEL_STATES.OPEN, CHANNEL_STATES.FOLDED], channelState),
|
||||
state: channelState,
|
||||
}));
|
||||
|
||||
// display automatic messages in the channel
|
||||
if (!channel.available) {
|
||||
self._addSupportNotAvailableMessage();
|
||||
} else {
|
||||
self._addSupportWelcomeMessage();
|
||||
}
|
||||
} else {
|
||||
// the channel has already been added to the ChatManager, so
|
||||
// simply re-open it
|
||||
channel = self.getChannel(self.supportChannelUUID);
|
||||
if (self.discussOpen) {
|
||||
self.openChannel(channel);
|
||||
} else {
|
||||
channel.is_folded = channelState === CHANNEL_STATES.FOLDED;
|
||||
self.call('chat_window_manager', 'openChat', channel);
|
||||
}
|
||||
}
|
||||
}).fail(function () {
|
||||
self.do_warn(_t("The Support server can't be reached."));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server for messages of the Support
|
||||
* channel (which are records on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
toggleStarStatus: function (msgID) {
|
||||
var message = this.getMessage(msgID);
|
||||
if (_.contains(message.channel_ids, this.supportChannelUUID)) {
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
unsubscribe: function (channel) {
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds a message in the Support channel indicating that this channel is
|
||||
* not available for now.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addSupportNotAvailableMessage: function () {
|
||||
var msg = {
|
||||
author_id: ODOOBOT_ID,
|
||||
body: _t("None of our operators are available. <a href='https://www.odoo.com/help' " +
|
||||
"target='_blank'>Submit a ticket</a> to ask your question now."),
|
||||
channel_ids: [this.supportChannelUUID],
|
||||
id: Number.MAX_SAFE_INTEGER, // last message in the channel
|
||||
};
|
||||
this._addMessage(msg, {silent: true});
|
||||
},
|
||||
/**
|
||||
* Adds a welcome message as first message of the Support channel.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addSupportWelcomeMessage: function () {
|
||||
var channel = this.getChannel(this.supportChannelUUID);
|
||||
if (channel.welcome_message) {
|
||||
var msg = {
|
||||
author_id: channel.operator,
|
||||
body: channel.welcome_message,
|
||||
channel_ids: [this.supportChannelUUID],
|
||||
id: -1, // first message of the channel
|
||||
};
|
||||
this._addMessage(msg, {silent: true});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to handle the Support channel case: fetches the messages from
|
||||
* the Support database in that case.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_fetchFromChannel: function (channel, options) {
|
||||
var self = this;
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
var domain = options && options.domain || [];
|
||||
var cache = this._getChannelCache(channel, domain);
|
||||
if (options && options.loadMore) {
|
||||
var minMessageID = cache.messages[0].id;
|
||||
domain = [['id', '<', minMessageID]].concat(domain);
|
||||
}
|
||||
|
||||
return supportSession.rpc('/odoo_im_support/fetch_messages', {
|
||||
domain: domain,
|
||||
channel_uuid: session.support_token,
|
||||
limit: SUPPORT_CHANNEL_LIMIT,
|
||||
}).then(function (msgs) {
|
||||
if (!cache.all_history_loaded) {
|
||||
cache.all_history_loaded = msgs.length < SUPPORT_CHANNEL_LIMIT;
|
||||
}
|
||||
cache.loaded = true;
|
||||
|
||||
_.each(msgs, function (msg) {
|
||||
_.extend(msg, {channel_ids: [self.supportChannelUUID]});
|
||||
self._addMessage(msg, {
|
||||
channel_id: self.supportChannelUUID,
|
||||
silent: true,
|
||||
domain: domain,
|
||||
});
|
||||
});
|
||||
var channelCache = self._getChannelCache(channel, domain);
|
||||
return channelCache.messages;
|
||||
});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_makeChannel: function (data) {
|
||||
var channel = this._super.apply(this, arguments);
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
channel.supportChannel = true;
|
||||
channel.name = _t('Support');
|
||||
channel.available = data.available;
|
||||
if (!channel.available) {
|
||||
channel.name += _t(' (offline)');
|
||||
}
|
||||
channel.welcome_message = data.welcome_message;
|
||||
channel.operator = data.operator;
|
||||
}
|
||||
return channel;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_makeMessage: function (data) {
|
||||
if (_.contains(data.channel_ids, this.supportChannelUUID) && data.author_id !== ODOOBOT_ID) {
|
||||
if (!data.author_id[0]) { // the author is the client
|
||||
data.author_id = [session.partner_id, session.name];
|
||||
} else { // the author is the operator
|
||||
data.author_id[0] = -1; // prevent from conflicting with partners of this instance
|
||||
var msg = this._super.apply(this, arguments);
|
||||
msg.author_redirect = false;
|
||||
msg.avatar_src = "/mail/static/src/img/odoo_o.png";
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Automatically stop polling for Support messages after a given delay of
|
||||
* inactivity.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling
|
||||
* timeout delay to set
|
||||
*/
|
||||
_setPollTimeout: function (pollingDelay) {
|
||||
pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY;
|
||||
clearTimeout(this.pollTimeout);
|
||||
this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay);
|
||||
// save the timeout expiration datetime into the LocalStorage so that
|
||||
// we can re-open the Support channel if necessary on F5
|
||||
var timeoutTimestamp = Date.now() + pollingDelay;
|
||||
this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp);
|
||||
},
|
||||
/**
|
||||
* Initiates a longpoll with the server hosting the Support channel.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling
|
||||
* timeout delay to set
|
||||
*/
|
||||
_startPollingSupport: function (pollingDelay) {
|
||||
this.pollingSupport = true;
|
||||
supportBus.add_channel(this.supportChannelUUID);
|
||||
supportBus.start_polling();
|
||||
this._setPollTimeout(pollingDelay);
|
||||
},
|
||||
/**
|
||||
* Stops the longpoll with the server hosting the Support channel.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_stopPollingSupport: function () {
|
||||
this.pollingSupport = false;
|
||||
supportBus.stop_polling();
|
||||
this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handles poll notifications from the Support server.
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} notifications
|
||||
*/
|
||||
_onSupportNotification: function (notifications) {
|
||||
var self = this;
|
||||
if (notifications && notifications.length) {
|
||||
this._setPollTimeout();
|
||||
}
|
||||
_.each(notifications, function (notification) {
|
||||
if (notification[1]._type === 'history_command') {
|
||||
// ignore history requests
|
||||
return;
|
||||
}
|
||||
var msg = _.extend(notification[1], {
|
||||
channel_ids: [self.supportChannelUUID],
|
||||
});
|
||||
self._manageChannelNotification(msg);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Unfortunately, we can't override init() of ChatManager because it is called
|
||||
// before the include is applied, so we override the WebClient instead to call
|
||||
// an initialization hook for Livechat Support in the ChatManager service.
|
||||
WebClient.include({
|
||||
/**
|
||||
* Overrides to ask the ChatManager service to check whether there is a
|
||||
* pending chat session with Support, and if so, to re-open it.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
show_application: function () {
|
||||
this.call('chat_manager', 'initSupport');
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
odoo.define('im_support.chat_discuss', function (require) {
|
||||
odoo.define('im_support.Discuss', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Discuss = require('mail.Discuss');
|
||||
|
||||
/**
|
||||
* This module includes Discuss to handle the case of the Support channel.
|
||||
*/
|
||||
|
||||
var Discuss = require('mail.chat_discuss');
|
||||
|
||||
Discuss.include({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -21,8 +21,8 @@ Discuss.include({
|
||||
*/
|
||||
_getThreadRenderingOptions: function () {
|
||||
var options = this._super.apply(this, arguments);
|
||||
if (this.channel.supportChannel) {
|
||||
options.display_stars = false;
|
||||
if (this._thread.getType() === 'support_channel') {
|
||||
options.displayStars = false;
|
||||
}
|
||||
return options;
|
||||
},
|
||||
@@ -34,13 +34,13 @@ Discuss.include({
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_setChannel: function (channel) {
|
||||
_setThread: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
var $buttonAddAttachment = self.basicComposer.$('.o_composer_button_add_attachment');
|
||||
if (channel.supportChannel) {
|
||||
if (!channel.available) {
|
||||
self.basicComposer.do_hide();
|
||||
var $buttonAddAttachment = self._basicComposer.$('.o_composer_button_add_attachment');
|
||||
if (self._thread.getType() === 'support_channel') {
|
||||
if (!self._thread.isAvailable()) {
|
||||
self._basicComposer.do_hide();
|
||||
}
|
||||
$buttonAddAttachment.toggleClass('o_hidden', true);
|
||||
} else {
|
||||
@@ -54,9 +54,9 @@ Discuss.include({
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_updateControlPanelButtons: function (channel) {
|
||||
_updateControlPanelButtons: function (thread) {
|
||||
this._super.apply(this, arguments);
|
||||
if (channel.supportChannel) {
|
||||
if (thread.getType() === 'support_channel') {
|
||||
this.$buttons.find('button').hide();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
odoo.define('im_support.ExtendedChatWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module includes ExtendedChatWindow to handle the case of the Support
|
||||
* channel.
|
||||
*/
|
||||
|
||||
|
||||
var ExtendedChatWindow = require('mail.ExtendedChatWindow');
|
||||
|
||||
ExtendedChatWindow.include({
|
||||
/**
|
||||
* Overrides to tweak the options in the case of the Support channel (no
|
||||
* star, and no input if no operator available).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
var channel = this.call('chat_manager', 'getChannel', this.channel_id);
|
||||
if (channel && channel.supportChannel) {
|
||||
this.options.display_stars = false;
|
||||
if (!channel.available) {
|
||||
this.options.input_less = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to remove the attachment button in the Support channel as this
|
||||
* feature does not work in that channel.
|
||||
*
|
||||
* @note: this feature could be enabled by storing the attachments on the
|
||||
* Support server.
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var def = this._super.apply(this, arguments);
|
||||
var channel = this.call('chat_manager', 'getChannel', this.channel_id);
|
||||
if (channel && channel.supportChannel) {
|
||||
this.$('.o_composer .o_composer_button_add_attachment').remove();
|
||||
}
|
||||
return def;
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
odoo.define('im_support.MailManager', function (require) {
|
||||
"use strict";
|
||||
|
||||
var MailManager = require('mail.Manager');
|
||||
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var WebClient = require('web.WebClient');
|
||||
|
||||
var SupportChannel = require('im_support.SupportChannel');
|
||||
var SupportMessage = require('im_support.SupportMessage');
|
||||
var supportSession = require('im_support.SupportSession');
|
||||
var supportBus = require('im_support.SupportBus');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes
|
||||
var POLL_TIMEOUT_KEY = 'im_support.poll_timeout';
|
||||
var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state';
|
||||
|
||||
/**
|
||||
* This module includes the MailService to handle the case of the Support
|
||||
* channel, allowing the users of the current database to communicate with
|
||||
* livechat operators from another database (the Support database).
|
||||
*/
|
||||
MailManager.include({
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialises the Support: checks if there is a pending chat session
|
||||
* between the user and Support, and if so, re-opens it.
|
||||
* Note: we can't directly override init(), because it is already called
|
||||
* when the include is applied, so we use this hook instead (called at
|
||||
* webclient startup)
|
||||
*/
|
||||
initSupport: function () {
|
||||
var self = this;
|
||||
this.supportChannelDef = null;
|
||||
this.supportChannelUUID = null;
|
||||
this.pollTimeout = null;
|
||||
this.pollingSupport = false;
|
||||
|
||||
// listen to notifications coming from Support longpolling
|
||||
supportBus.on('notification', this, this._onSupportNotification);
|
||||
|
||||
// check if there is a pending chat session with the Support
|
||||
var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY);
|
||||
var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now());
|
||||
if (pollingDelay && pollingDelay > 0) {
|
||||
var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY);
|
||||
this.startSupportLivechat(channelState).then(function () {
|
||||
var supportChannel = self.getChannel(self.supportChannelUUID);
|
||||
if (supportChannel.isAvailable()) {
|
||||
self.startPollingSupport(pollingDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to filter out the Support channel from the previews.
|
||||
*
|
||||
* @override
|
||||
* @returns {$.Promise<Object[]>} list of valid objects for mail.Preview
|
||||
* template
|
||||
*/
|
||||
getChannelPreviews: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments)
|
||||
.then(function (channelsPreview) {
|
||||
return _.reject(channelsPreview, { id: self.supportChannelUUID });
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Initiates a longpoll with the server hosting the Support channel.
|
||||
*
|
||||
* @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling
|
||||
* timeout delay to set
|
||||
*/
|
||||
startPollingSupport: function (pollingDelay) {
|
||||
if (!this.pollingSupport) {
|
||||
this.pollingSupport = true;
|
||||
supportBus.add_channel(this.supportChannelUUID);
|
||||
supportBus.start_polling();
|
||||
this._setPollTimeout(pollingDelay);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Opens the Support channel between a livechat operator from the Support
|
||||
* database and the current user (if there is an available operator).
|
||||
* Ensures to perform only once the request to create/retrieve the Support
|
||||
* channel.
|
||||
*
|
||||
* @param {string} [channelState='open'] state of the Support
|
||||
* channel (see CHANNEL_STATES for accepted values)
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
startSupportLivechat: function (channelState) {
|
||||
var self = this;
|
||||
if (!this.supportChannelDef) {
|
||||
// retrieve or create the channel
|
||||
this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', {
|
||||
channel_uuid: session.support_token,
|
||||
db_uuid: session.db_uuid,
|
||||
user_name: session.name,
|
||||
});
|
||||
}
|
||||
return this.supportChannelDef.then(function (channel) {
|
||||
if (!channel) {
|
||||
// there is no channel (because there is no online operator, and
|
||||
// no support channel has been created yet), so create one to
|
||||
// open in a chat window
|
||||
channel = {
|
||||
available: false,
|
||||
support_channel: true,
|
||||
type: 'livechat',
|
||||
uuid: "support_unavailable",
|
||||
};
|
||||
}
|
||||
if (!channelState) {
|
||||
channelState = self._isDiscussOpen() ? 'closed' : 'open';
|
||||
self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState);
|
||||
}
|
||||
if (!self.supportChannelUUID) {
|
||||
// this part is only executed the first time the RPC is resolved
|
||||
self.supportChannelUUID = channel.uuid;
|
||||
|
||||
// add the channel to the MailManager
|
||||
self._addChannel(_.extend(channel, {
|
||||
id: channel.uuid,
|
||||
is_minimized: _.contains(['open', 'folded'], channelState),
|
||||
state: channelState,
|
||||
}));
|
||||
|
||||
// display an automatic message in the channel
|
||||
var supportChannel = self.getChannel(channel.uuid);
|
||||
supportChannel.addDefaultMessage();
|
||||
} else {
|
||||
// the channel has already been added to the MailManager, so
|
||||
// simply re-open it
|
||||
if (self._isDiscussOpen()) {
|
||||
self._openThreadInDiscuss(self.supportChannelUUID);
|
||||
} else {
|
||||
channel = self.getChannel(self.supportChannelUUID);
|
||||
channel.fold(channelState === 'folded');
|
||||
}
|
||||
}
|
||||
}).fail(function () {
|
||||
self.do_warn(_t("The Support server can't be reached."));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Updates the state of the Support channel (stored in the localStorage).
|
||||
*
|
||||
* @param {string} state ('closed', 'folded' or 'open')
|
||||
*/
|
||||
updateSupportChannelState: function (state) {
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, state);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_makeChannel: function (data, options) {
|
||||
if (data.id === this.supportChannelUUID) {
|
||||
return new SupportChannel(this, data, options, this._commands);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to instantiate a SupportMessage when necessary.
|
||||
* @override
|
||||
*/
|
||||
_makeMessage: function (data) {
|
||||
if (_.contains(data.channel_ids, this.supportChannelUUID)) {
|
||||
return new SupportMessage(this, data, this._emojis);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Automatically stop polling for Support messages after a given delay of
|
||||
* inactivity.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling
|
||||
* timeout delay to set
|
||||
*/
|
||||
_setPollTimeout: function (pollingDelay) {
|
||||
pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY;
|
||||
clearTimeout(this.pollTimeout);
|
||||
this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay);
|
||||
// save the timeout expiration datetime into the LocalStorage so that
|
||||
// we can re-open the Support channel if necessary on F5
|
||||
var timeoutTimestamp = Date.now() + pollingDelay;
|
||||
this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp);
|
||||
},
|
||||
/**
|
||||
* Stops the longpoll with the server hosting the Support channel.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_stopPollingSupport: function () {
|
||||
this.pollingSupport = false;
|
||||
supportBus.stop_polling();
|
||||
this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handles poll notifications from the Support server.
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} notifications
|
||||
*/
|
||||
_onSupportNotification: function (notifications) {
|
||||
var self = this;
|
||||
if (notifications && notifications.length) {
|
||||
this._setPollTimeout();
|
||||
}
|
||||
_.each(notifications, function (notification) {
|
||||
if (notification[1]._type === 'history_command') {
|
||||
// ignore history requests
|
||||
return;
|
||||
}
|
||||
var message = _.extend(notification[1], {
|
||||
channel_ids: [self.supportChannelUUID],
|
||||
});
|
||||
self._handleChannelNotification(message);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Unfortunately, we can't override init() of MailService because it is called
|
||||
// before the include is applied, so we override the WebClient instead to call
|
||||
// an initialization hook for Livechat Support in the Mail service.
|
||||
WebClient.include({
|
||||
/**
|
||||
* Overrides to ask the Mail service to check whether there is a
|
||||
* pending chat session with Support, and if so, to re-open it.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
show_application: function () {
|
||||
this.call('mail_service', 'initSupport');
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
odoo.define('im_support.SupportChannel', function (require) {
|
||||
"use strict";
|
||||
|
||||
var supportSession = require('im_support.SupportSession');
|
||||
|
||||
var ThreadWithCache = require('mail.model.ThreadWithCache');
|
||||
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* This mail model represents support channel, which are communication channels
|
||||
* between two different databases for support-related reasons. It is like
|
||||
* livechat, but both users are in different databases, and both users
|
||||
* communicate from their respective 'backend' access.
|
||||
*
|
||||
* FIXME: it should inherit from mail.model.Channel, not from
|
||||
* mail.model.ThreadWithCache
|
||||
*/
|
||||
var SupportChannel = ThreadWithCache.extend({
|
||||
init: function (parent, data, options) {
|
||||
|
||||
data.type = 'support_channel';
|
||||
data.name = _t("Support");
|
||||
|
||||
this._available = data.available;
|
||||
this._operator = data.operator;
|
||||
this._supportChannelUUID = data.id;
|
||||
this._welcomeMessage = data.welcome_message;
|
||||
if (!this._available) {
|
||||
data.name += _t(" (offline)");
|
||||
}
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// force stuff that should probably be in Thread (or at least
|
||||
// ThreadWithCache), but that are currently in Channel
|
||||
this._detached = data.is_minimized;
|
||||
this._folded = data.state === 'folded';
|
||||
this._uuid = data.uuid;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds the default message in the Support channel (depending on its
|
||||
* availability).
|
||||
*/
|
||||
addDefaultMessage: function () {
|
||||
if (!this._available) {
|
||||
this._addSupportNotAvailableMessage();
|
||||
} else {
|
||||
this._addSupportWelcomeMessage();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to store the state of the Support channel in the localStorage.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
close: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.call('mail_service', 'updateSupportChannelState', 'closed');
|
||||
},
|
||||
/**
|
||||
* Overrides to store the state of the Support channel in the localStorage.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
detach: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.call('mail_service', 'updateSupportChannelState', 'open');
|
||||
},
|
||||
/**
|
||||
* Overrides to store the state of the Support channel in the localStorage.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
fold: function (folded) {
|
||||
this._super.apply(this, arguments);
|
||||
var value = folded ? 'folded' : 'open';
|
||||
this.call('mail_service', 'updateSupportChannelState', value);
|
||||
},
|
||||
/**
|
||||
* FIXME: this method is necessary just because the support channel is
|
||||
* considered as a channel, even though it does not inherit from
|
||||
* mail.model.Channel.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasBeenPreviewed: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* @returns {boolean} true iff the Support channel is available
|
||||
*/
|
||||
isAvailable: function () {
|
||||
return this._available;
|
||||
},
|
||||
/**
|
||||
* FIXME: this override is necessary just because the support channel is
|
||||
* considered as a channel, even though it does not inherit from
|
||||
* mail.model.Channel.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChannel: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Support channels are not considered as chat, so they are not displayed
|
||||
* in the 'chat' filter of the systray messaging menu.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChat: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* FIXME: this override is necessary just because the support channel is
|
||||
* considered as a channel, even though it does not inherit from
|
||||
* mail.model.Channel.
|
||||
*
|
||||
* @returns {integer}
|
||||
*/
|
||||
getNeedactionCounter: function () {
|
||||
return 0;
|
||||
},
|
||||
/**
|
||||
* @return {string} uuid of this channel
|
||||
*/
|
||||
getUUID: function () {
|
||||
return this._uuid;
|
||||
},
|
||||
/**
|
||||
* Posts the message on the Support server.
|
||||
*
|
||||
* @override
|
||||
* @return {$.Promise}
|
||||
*/
|
||||
postMessage: function (data) {
|
||||
// ensure that the poll is active before posting the message
|
||||
this.call('mail_service', 'startPollingSupport');
|
||||
return supportSession.rpc('/odoo_im_support/chat_post', {
|
||||
uuid: this._supportChannelUUID,
|
||||
message_content: data.content,
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds a message in the Support channel indicating that this channel is
|
||||
* not available for now.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addSupportNotAvailableMessage: function () {
|
||||
var msg = {
|
||||
author_id: this.call('mail_service', 'getOdoobotID'),
|
||||
body: _t("None of our operators are available. <a href='https://www.odoo.com/help' " +
|
||||
"target='_blank'>Submit a ticket</a> to ask your question now."),
|
||||
channel_ids: [this.getID()],
|
||||
id: Number.MAX_SAFE_INTEGER, // last message in the channel
|
||||
};
|
||||
this.call('mail_service', 'addMessage', msg, { silent: true });
|
||||
},
|
||||
/**
|
||||
* Adds the welcome message as first message of the Support channel.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addSupportWelcomeMessage: function () {
|
||||
if (this._welcomeMessage) {
|
||||
var msg = {
|
||||
author_id: this._operator,
|
||||
body: this._welcomeMessage,
|
||||
channel_ids: [this.getID()],
|
||||
id: -1, // first message of the channel
|
||||
};
|
||||
this.call('mail_service', 'addMessage', msg, { silent: true });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Fetches the messages from the Support server.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_fetchMessages: function (channel, options) {
|
||||
var self = this;
|
||||
var domain = options && options.domain || [];
|
||||
var cache = this._getCache(channel, domain);
|
||||
if (options && options.loadMore) {
|
||||
var minMessageID = cache.messages[0].id;
|
||||
domain = [['id', '<', minMessageID]].concat(domain);
|
||||
}
|
||||
|
||||
return supportSession.rpc('/odoo_im_support/fetch_messages', {
|
||||
domain: domain,
|
||||
channel_uuid: session.support_token,
|
||||
limit: self._FETCH_LIMIT,
|
||||
}).then(function (msgs) {
|
||||
if (!cache.all_history_loaded) {
|
||||
cache.all_history_loaded = msgs.length < self._FETCH_LIMIT;
|
||||
}
|
||||
cache.loaded = true;
|
||||
|
||||
_.each(msgs, function (msg) {
|
||||
_.extend(msg, {channel_ids: [self.getID()]});
|
||||
self.call('mail_service', 'addMessage', msg, {
|
||||
channel_id: self.getID(),
|
||||
silent: true,
|
||||
domain: domain,
|
||||
});
|
||||
});
|
||||
var channelCache = self._getCache(channel, domain);
|
||||
return channelCache.messages;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return SupportChannel;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
odoo.define('im_support.SupportMessage', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Message = require('mail.model.Message');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
/**
|
||||
* This is a model for messages that are in the the support channel
|
||||
*/
|
||||
var SupportMessage = Message.extend({
|
||||
/**
|
||||
* @override
|
||||
* @param {Class} parent
|
||||
* @param {Object} data
|
||||
* @param {Array} [data.author_id=[]]
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
data.author_id = data.author_id || [];
|
||||
if (data.author_id !== this.call('mail_service', 'getOdoobotID')) {
|
||||
if (!data.author_id[0]) {
|
||||
// the author is the client
|
||||
data.author_id = [session.partner_id, session.name];
|
||||
} else {
|
||||
// the author is the operator
|
||||
// prevent from conflicting with partners of this instance
|
||||
data.author_id[0] = -1;
|
||||
}
|
||||
}
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
getAvatarSource: function () {
|
||||
return '/mail/static/src/img/odoo_o.png';
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent clicks on the Support operator from redirecting.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldRedirectToAuthor: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server for messages of the Support
|
||||
* channel (which are records on the Support database).
|
||||
*
|
||||
* @override
|
||||
* @returns {$.Promise}
|
||||
*/
|
||||
toggleStarStatus: function () {
|
||||
return $.when();
|
||||
},
|
||||
});
|
||||
|
||||
return SupportMessage;
|
||||
|
||||
});
|
||||
@@ -1,15 +1,14 @@
|
||||
odoo.define('im_support.SupportSession', function (require) {
|
||||
"use strict";
|
||||
|
||||
var session = require('web.session');
|
||||
var Session = require('web.Session');
|
||||
|
||||
/**
|
||||
* This module returns an instance of Session which is linked to the Support
|
||||
* server, allowing the current instance to communicate with the Support
|
||||
* server (CORS).
|
||||
*/
|
||||
|
||||
var session = require('web.session');
|
||||
var Session = require('web.Session');
|
||||
|
||||
return new Session(null, session.support_origin, {
|
||||
modules: odoo._modules,
|
||||
use_cors: true,
|
||||
|
||||
+12
-14
@@ -1,18 +1,13 @@
|
||||
odoo.define('im_support.systray', function (require) {
|
||||
odoo.define('im_support.systray.MessagingMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module adds a fake Support channel in the messaging menu of the systray.
|
||||
*/
|
||||
|
||||
var mailSystray = require('mail.systray');
|
||||
var MessagingMenu = require('mail.systray.MessagingMenu');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
|
||||
var _t = core._t;
|
||||
var MessagingMenu = mailSystray.MessagingMenu;
|
||||
var SUPPORT_CHANNEL_ID = 'SupportChannel';
|
||||
|
||||
// Disable Support in mobile for design purposes (for now at least), and don't
|
||||
@@ -21,6 +16,9 @@ if (config.device.isMobile || !session.support_token || !session.support_origin)
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* This module adds a fake Support channel in the messaging menu of the systray.
|
||||
*/
|
||||
MessagingMenu.include({
|
||||
/**
|
||||
* @override
|
||||
@@ -29,8 +27,8 @@ MessagingMenu.include({
|
||||
this._super.apply(this, arguments);
|
||||
this.supportChannel = {
|
||||
id: SUPPORT_CHANNEL_ID,
|
||||
name: _t('Support'),
|
||||
image_src: '/mail/static/src/img/odoo_o.png',
|
||||
name: _t("Support"),
|
||||
imageSRC: '/mail/static/src/img/odoo_o.png',
|
||||
};
|
||||
},
|
||||
/**
|
||||
@@ -45,7 +43,7 @@ MessagingMenu.include({
|
||||
start: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self.$('.o_mail_navbar_dropdown_bottom').addClass('o_mail_navbar_dropdown_channels');
|
||||
self.$('.o_mail_systray_dropdown_bottom').addClass('o_mail_systray_dropdown_items');
|
||||
});
|
||||
},
|
||||
|
||||
@@ -59,10 +57,10 @@ MessagingMenu.include({
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onClickChannel: function (ev) {
|
||||
var channelID = $(ev.currentTarget).data('channel_id');
|
||||
if (channelID === SUPPORT_CHANNEL_ID) {
|
||||
this.call('chat_manager', 'startSupportLivechat');
|
||||
_onClickPreview: function (ev) {
|
||||
var id = $(ev.currentTarget).data('preview-id');
|
||||
if (id === SUPPORT_CHANNEL_ID) {
|
||||
this.call('mail_service', 'startSupportLivechat');
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
odoo.define('im_support.ThreadWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module includes ThreadWindow to handle the case of the Support channel.
|
||||
*/
|
||||
var ThreadWindow = require('mail.ThreadWindow');
|
||||
|
||||
ThreadWindow.include({
|
||||
/**
|
||||
* Overrides to tweak the options in the case of the Support channel (no
|
||||
* star).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
var channel = this.call('mail_service', 'getChannel', this._getThreadID());
|
||||
if (channel && channel.getType() === 'support_channel') {
|
||||
this.options.displayStars = false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to remove the attachment button in the Support channel as this
|
||||
* feature does not work in that channel.
|
||||
*
|
||||
* @note: this feature could be enabled by storing the attachments on the
|
||||
* Support server.
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var def = this._super.apply(this, arguments);
|
||||
var channel = this.call('mail_service', 'getChannel', this._getThreadID());
|
||||
if (channel && channel.getType() === 'support_channel') {
|
||||
this.$('.o_composer .o_composer_button_add_attachment').remove();
|
||||
if (!channel.isAvailable()) {
|
||||
this.$('.o_thread_composer').remove();
|
||||
}
|
||||
}
|
||||
return def;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overrides to prevent from displaying a composer if there is no operator
|
||||
* available.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_needsComposer: function () {
|
||||
var channel = this.call('mail_service', 'getChannel', this._getThreadID());
|
||||
if (
|
||||
channel &&
|
||||
channel.getType() === 'support_channel' &&
|
||||
!channel.isAvailable()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
.o_mail_navbar_item {
|
||||
.o_mail_navbar_dropdown {
|
||||
.o_mail_navbar_dropdown_bottom {
|
||||
.o_mail_systray_item {
|
||||
.o_mail_systray_dropdown {
|
||||
.o_mail_systray_dropdown_bottom {
|
||||
border-top: 1px solid $gray-lighter-darker;
|
||||
box-sizing: content-box;
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<template>
|
||||
|
||||
<t t-extend="mail.chat.Sidebar">
|
||||
<t t-extend="mail.discuss.Sidebar">
|
||||
<t t-jquery="hr" t-operation="before">
|
||||
<t t-foreach="channels" t-as="channel">
|
||||
<t t-if="channel.type == 'livechat'">
|
||||
<div t-attf-class="o_mail_chat_title_main o_mail_chat_channel_item #{(active_channel_id == channel.id) ? 'o_active': ''}"
|
||||
t-att-data-channel-id="channel.id">
|
||||
<span class="o_channel_name"><i class="fa fa-question-circle mr8"/>Odoo Support</span>
|
||||
<t t-set="counter" t-value="0"/>
|
||||
<t t-call="mail.chat.SidebarNeedaction"/>
|
||||
<t t-foreach="threads" t-as="thread">
|
||||
<t t-if="thread.getType() == 'support_channel'">
|
||||
<div t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID == thread.getID()) ? 'o_active': ''}"
|
||||
t-att-data-thread-id="thread.getID()">
|
||||
<span class="o_thread_name"><i class="fa fa-question-circle mr8"/>Odoo Support</span>
|
||||
<t t-set="counter" t-value="thread.getUnreadCounter()"/>
|
||||
<t t-call="mail.discuss.SidebarCounter"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-extend="mail.chat.MessagingMenu">
|
||||
<t t-jquery=".o_mail_navbar_dropdown" t-operation="append">
|
||||
<li t-if="widget.supportChannel" class="o_mail_navbar_dropdown_bottom">
|
||||
<t t-set="channel" t-value="widget.supportChannel"/>
|
||||
<t t-call="mail.chat.ChannelPreview"/>
|
||||
<t t-extend="mail.systray.MessagingMenu">
|
||||
<t t-jquery=".o_mail_systray_dropdown" t-operation="append">
|
||||
<li t-if="widget.supportChannel" class="o_mail_systray_dropdown_bottom">
|
||||
<t t-set="preview" t-value="widget.supportChannel"/>
|
||||
<t t-call="mail.Preview"/>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
<xpath expr="." position="inside">
|
||||
<link rel="stylesheet" href="/im_support/static/src/scss/systray.scss" type="text/scss"/>
|
||||
|
||||
<script type="text/javascript" src="/im_support/static/src/js/chat_manager.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/mail_manager.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/discuss.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/extended_chat_window.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/thread_window.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/support_bus.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/support_channel.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/support_message.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/support_session.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/systray.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/systray_messaging_menu.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
'static/src/xml/followers.xml',
|
||||
'static/src/xml/systray.xml',
|
||||
'static/src/xml/thread.xml',
|
||||
'static/src/xml/chat_window.xml',
|
||||
'static/src/xml/extended_chat_window.xml',
|
||||
'static/src/xml/abstract_thread_window.xml',
|
||||
'static/src/xml/thread_window.xml',
|
||||
'static/src/xml/announcement.xml',
|
||||
'static/src/xml/web_kanban_activity.xml',
|
||||
],
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
<p>Hello <t t-esc='record.name'/></p>
|
||||
<p>You have messages to moderate, please go for the proceedings.</p><br/><br/>
|
||||
<div style="text-align: center;">
|
||||
<a href="/web#action=mail.mail_channel_action_client_chat&active_id=channel_moderation" style="background-color: #1abc9c; padding: 20px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;" class="o_default_snippet_text">Moderate Messages</a>
|
||||
<a href="/web#action=mail.mail_channel_action_client_chat&active_id=mailbox_moderation" style="background-color: #1abc9c; padding: 20px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;" class="o_default_snippet_text">Moderate Messages</a>
|
||||
<br/><br/><br/>
|
||||
</div>
|
||||
<p>Thank you!</p>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
odoo.define('mail.Activity', function (require) {
|
||||
"use strict";
|
||||
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var AbstractField = require('web.AbstractField');
|
||||
var BasicModel = require('web.BasicModel');
|
||||
var core = require('web.core');
|
||||
var field_registry = require('web.field_registry');
|
||||
var time = require('web.time');
|
||||
var utils = require('mail.utils');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
@@ -71,38 +72,49 @@ BasicModel.include({
|
||||
|
||||
/**
|
||||
* Set the 'label_delay' entry in activity data according to the deadline date
|
||||
*
|
||||
* @param {Array} activities list of activity Object
|
||||
* @return {Array} : list of modified activity Object
|
||||
*/
|
||||
var setDelayLabel = function(activities){
|
||||
var setDelayLabel = function (activities){
|
||||
var today = moment().startOf('day');
|
||||
_.each(activities, function(activity){
|
||||
var to_display = '';
|
||||
_.each(activities, function (activity){
|
||||
var toDisplay = '';
|
||||
var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding
|
||||
if(diff === 0){
|
||||
to_display = _t('Today');
|
||||
}else{
|
||||
if(diff < 0){ // overdue
|
||||
if(diff === -1){
|
||||
to_display = _t('Yesterday');
|
||||
}else{
|
||||
to_display = _.str.sprintf(_t('%d days overdue'), Math.abs(diff));
|
||||
if (diff === 0){
|
||||
toDisplay = _t("Today");
|
||||
} else {
|
||||
if (diff < 0){ // overdue
|
||||
if (diff === -1){
|
||||
toDisplay = _t("Yesterday");
|
||||
} else {
|
||||
toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff));
|
||||
}
|
||||
}else{ // due
|
||||
if(diff === 1){
|
||||
to_display = _t('Tomorrow');
|
||||
}else{
|
||||
to_display = _.str.sprintf(_t('Due in %d days'), Math.abs(diff));
|
||||
} else { // due
|
||||
if (diff === 1){
|
||||
toDisplay = _t("Tomorrow");
|
||||
} else {
|
||||
toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff));
|
||||
}
|
||||
}
|
||||
}
|
||||
activity.label_delay = to_display;
|
||||
activity.label_delay = toDisplay;
|
||||
});
|
||||
return activities;
|
||||
};
|
||||
|
||||
var AbstractActivityField = AbstractField.extend({
|
||||
// private
|
||||
|
||||
//------------------------------------------------------------
|
||||
// Private
|
||||
//------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @param {string} feedback
|
||||
* @return {$.Promise}
|
||||
*/
|
||||
_markActivityDone: function (id, feedback) {
|
||||
return this._rpc({
|
||||
model: 'mail.activity',
|
||||
@@ -112,7 +124,14 @@ var AbstractActivityField = AbstractField.extend({
|
||||
context: this.record.getContext(),
|
||||
});
|
||||
},
|
||||
_scheduleActivity: function (id, previous_activity_type_id, callback) {
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @param {integer} previousActivityTypeID
|
||||
* @param {function} callback
|
||||
* @return {$.Promise}
|
||||
*/
|
||||
_scheduleActivity: function (id, previousActivityTypeID, callback) {
|
||||
var action = {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.activity',
|
||||
@@ -123,7 +142,7 @@ var AbstractActivityField = AbstractField.extend({
|
||||
context: {
|
||||
default_res_id: this.res_id,
|
||||
default_res_model: this.model,
|
||||
default_previous_activity_type_id: previous_activity_type_id,
|
||||
default_previous_activity_type_id: previousActivityTypeID,
|
||||
},
|
||||
res_id: id || false,
|
||||
};
|
||||
@@ -144,18 +163,49 @@ var Activity = AbstractActivityField.extend({
|
||||
},
|
||||
specialData: '_fetchSpecialActivity',
|
||||
|
||||
// inherited
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.activities = this.record.specialData[this.name];
|
||||
this._activities = this.record.specialData[this.name];
|
||||
},
|
||||
|
||||
//------------------------------------------------------------
|
||||
// Public
|
||||
//------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {integer} previousActivityTypeID
|
||||
* @return {$.Promise}
|
||||
*/
|
||||
scheduleActivity: function (previousActivityTypeID) {
|
||||
var callback = this._reload.bind(this, { activity: true, thread: true });
|
||||
return this._scheduleActivity(false, previousActivityTypeID, callback);
|
||||
},
|
||||
|
||||
//------------------------------------------------------------
|
||||
// Private
|
||||
//------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} fieldsToReload
|
||||
*/
|
||||
_reload: function (fieldsToReload) {
|
||||
this.trigger_up('reload_mail_fields', fieldsToReload);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_render: function () {
|
||||
_.each(this.activities, function (activity) {
|
||||
_.each(this._activities, function (activity) {
|
||||
if (activity.note) {
|
||||
activity.note = utils.parse_and_transform(activity.note, utils.add_link);
|
||||
activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink);
|
||||
}
|
||||
});
|
||||
var activities = setDelayLabel(this.activities);
|
||||
var activities = setDelayLabel(this._activities);
|
||||
if (activities.length) {
|
||||
var nbActivities = _.countBy(activities, 'state');
|
||||
this.$el.html(QWeb.render('mail.activity_items', {
|
||||
@@ -163,31 +213,33 @@ var Activity = AbstractActivityField.extend({
|
||||
nbPlannedActivities: nbActivities.planned,
|
||||
nbTodayActivities: nbActivities.today,
|
||||
nbOverdueActivities: nbActivities.overdue,
|
||||
date_format: time.getLangDateFormat(),
|
||||
datetime_format: time.getLangDatetimeFormat(),
|
||||
dateFormat: time.getLangDateFormat(),
|
||||
datetimeFormat: time.getLangDatetimeFormat(),
|
||||
}));
|
||||
} else {
|
||||
this.$el.empty();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} record
|
||||
*/
|
||||
_reset: function (record) {
|
||||
this._super.apply(this, arguments);
|
||||
this.activities = this.record.specialData[this.name];
|
||||
this._activities = this.record.specialData[this.name];
|
||||
// the mail widgets being persistent, one need to update the res_id on reset
|
||||
this.res_id = record.res_id;
|
||||
},
|
||||
|
||||
// public
|
||||
scheduleActivity: function (previous_activity_type_id) {
|
||||
var callback = this._reload.bind(this, {activity: true, thread: true});
|
||||
return this._scheduleActivity(false, previous_activity_type_id, callback);
|
||||
},
|
||||
// private
|
||||
_reload: function (fieldsToReload) {
|
||||
this.trigger_up('reload_mail_fields', fieldsToReload);
|
||||
},
|
||||
//------------------------------------------------------------
|
||||
// Handlers
|
||||
//------------------------------------------------------------
|
||||
|
||||
// handlers
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickRedirect: function (ev) {
|
||||
var id = $(ev.target).data('oe-id');
|
||||
if (id) {
|
||||
@@ -199,10 +251,15 @@ var Activity = AbstractActivityField.extend({
|
||||
});
|
||||
}
|
||||
},
|
||||
_onEditActivity: function (event, options) {
|
||||
event.preventDefault();
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
* @param {Object} options
|
||||
*/
|
||||
_onEditActivity: function (ev, options) {
|
||||
ev.preventDefault();
|
||||
var self = this;
|
||||
var activity_id = $(event.currentTarget).data('activity-id');
|
||||
var activity_id = $(ev.currentTarget).data('activity-id');
|
||||
var action = _.defaults(options || {}, {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.activity',
|
||||
@@ -222,12 +279,17 @@ var Activity = AbstractActivityField.extend({
|
||||
},
|
||||
});
|
||||
},
|
||||
_onUnlinkActivity: function (event, options) {
|
||||
event.preventDefault();
|
||||
var activity_id = $(event.currentTarget).data('activity-id');
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
* @param {Object} options
|
||||
*/
|
||||
_onUnlinkActivity: function (ev, options) {
|
||||
ev.preventDefault();
|
||||
var activityID = $(ev.currentTarget).data('activity-id');
|
||||
options = _.defaults(options || {}, {
|
||||
model: 'mail.activity',
|
||||
args: [[activity_id]],
|
||||
args: [[activityID]],
|
||||
});
|
||||
return this._rpc({
|
||||
model: options.model,
|
||||
@@ -236,47 +298,51 @@ var Activity = AbstractActivityField.extend({
|
||||
})
|
||||
.then(this._reload.bind(this, {activity: true}));
|
||||
},
|
||||
_onMarkActivityDone: function (event) {
|
||||
event.preventDefault();
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMarkActivityDone: function (ev) {
|
||||
ev.preventDefault();
|
||||
var self = this;
|
||||
var $popover_el = $(event.currentTarget);
|
||||
var activity_id = $popover_el.data('activity-id');
|
||||
var previous_activity_type_id = $popover_el.data('previous-activity-type-id');
|
||||
if (!$popover_el.data('bs.popover')) {
|
||||
$popover_el.popover({
|
||||
title : _t('Feedback'),
|
||||
var $popoverElement = $(ev.currentTarget);
|
||||
var activityID = $popoverElement.data('activity-id');
|
||||
var previousActivityTypeID = $popoverElement.data('previous-activity-type-id');
|
||||
if (!$popoverElement.data('bs.popover')) {
|
||||
$popoverElement.popover({
|
||||
title : _t("Feedback"),
|
||||
html: 'true',
|
||||
trigger:'click',
|
||||
content : function() {
|
||||
var $popover = $(QWeb.render("mail.activity_feedback_form", {'previous_activity_type_id': previous_activity_type_id}));
|
||||
content : function () {
|
||||
var $popover = $(QWeb.render('mail.activity_feedback_form', { 'previous_activity_type_id': previousActivityTypeID }));
|
||||
$popover.on('click', '.o_activity_popover_done_next', function () {
|
||||
var feedback = _.escape($popover.find('#activity_feedback').val());
|
||||
var previous_activity_type_id = $popover_el.data('previous-activity-type-id');
|
||||
self._markActivityDone(activity_id, feedback)
|
||||
.then(self.scheduleActivity.bind(self, previous_activity_type_id));
|
||||
var previousActivityTypeID = $popoverElement.data('previous-activity-type-id');
|
||||
self._markActivityDone(activityID, feedback)
|
||||
.then(self.scheduleActivity.bind(self, previousActivityTypeID));
|
||||
});
|
||||
$popover.on('click', '.o_activity_popover_done', function () {
|
||||
var feedback = _.escape($popover.find('#activity_feedback').val());
|
||||
self._markActivityDone(activity_id, feedback)
|
||||
.then(self._reload.bind(self, {activity: true, thread: true}));
|
||||
self._markActivityDone(activityID, feedback)
|
||||
.then(self._reload.bind(self, { activity: true, thread: true }));
|
||||
});
|
||||
$popover.on('click', '.o_activity_popover_discard', function () {
|
||||
$popover_el.popover('hide');
|
||||
$popoverElement.popover('hide');
|
||||
});
|
||||
return $popover;
|
||||
},
|
||||
}).on("show.bs.popover", function (e) {
|
||||
var $popover = $(this).data("bs.popover").tip();
|
||||
}).on('show.bs.popover', function (e) {
|
||||
var $popover = $(this).data('bs.popover').tip();
|
||||
$popover.addClass('o_mail_activity_feedback').attr('tabindex', 0);
|
||||
$(".o_mail_activity_feedback.popover").not(e.target).popover("hide");
|
||||
}).on("shown.bs.popover", function () {
|
||||
var $popover = $(this).data("bs.popover").tip();
|
||||
$('.o_mail_activity_feedback.popover').not(e.target).popover('hide');
|
||||
}).on('shown.bs.popover', function () {
|
||||
var $popover = $(this).data('bs.popover').tip();
|
||||
$popover.find('#activity_feedback').focus();
|
||||
$popover.off('focusout');
|
||||
$popover.focusout(function (e) {
|
||||
// outside click of popover hide the popover
|
||||
// e.relatedTarget is the element receiving the focus
|
||||
if(!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) {
|
||||
if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) {
|
||||
$popover.popover('hide');
|
||||
}
|
||||
});
|
||||
@@ -296,7 +362,9 @@ var KanbanActivity = AbstractActivityField.extend({
|
||||
'click .o_mark_as_done': '_onMarkActivityDone',
|
||||
},
|
||||
|
||||
// inherited
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (parent, name, record) {
|
||||
this._super.apply(this, arguments);
|
||||
var selection = {};
|
||||
@@ -306,59 +374,98 @@ var KanbanActivity = AbstractActivityField.extend({
|
||||
this.selection = selection;
|
||||
this._setState(record);
|
||||
},
|
||||
|
||||
//------------------------------------------------------------
|
||||
// Private
|
||||
//------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_reload: function () {
|
||||
this.trigger_up('reload', { db_id: this.record_id });
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_render: function () {
|
||||
var $span = this.$(".o_activity_btn > span");
|
||||
var $span = this.$('.o_activity_btn > span');
|
||||
$span.removeClass(function (index, classNames) {
|
||||
return classNames.split(/\s+/).filter(function (className) {
|
||||
return _.str.startsWith(className, 'o_activity_color_');
|
||||
}).join(' ');
|
||||
});
|
||||
$span.addClass('o_activity_color_' + (this.activity_state || 'default'));
|
||||
$span.addClass('o_activity_color_' + (this.activityState || 'default'));
|
||||
if (this.$el.hasClass('open')) {
|
||||
// note: this part of the rendering might be asynchronous
|
||||
this._renderDropdown();
|
||||
}
|
||||
},
|
||||
_reset: function (record) {
|
||||
this._super.apply(this, arguments);
|
||||
this._setState(record);
|
||||
},
|
||||
|
||||
// private
|
||||
_reload: function () {
|
||||
this.trigger_up('reload', {db_id: this.record_id});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderDropdown: function () {
|
||||
var self = this;
|
||||
this.$('.o_activity').html(QWeb.render("mail.KanbanActivityLoading"));
|
||||
this.$('.o_activity').html(QWeb.render('mail.KanbanActivityLoading'));
|
||||
return _readActivities(this, this.value.res_ids).then(function (activities) {
|
||||
self.$('.o_activity').html(QWeb.render("mail.KanbanActivityDropdown", {
|
||||
self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', {
|
||||
selection: self.selection,
|
||||
records: _.groupBy(setDelayLabel(activities), 'state'),
|
||||
uid: self.getSession().uid,
|
||||
}));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} record
|
||||
*/
|
||||
_reset: function (record) {
|
||||
this._super.apply(this, arguments);
|
||||
this._setState(record);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} record
|
||||
*/
|
||||
_setState: function (record) {
|
||||
this.record_id = record.id;
|
||||
this.activity_state = this.recordData.activity_state;
|
||||
this.activityState = this.recordData.activity_state;
|
||||
},
|
||||
|
||||
// handlers
|
||||
_onButtonClicked: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
//------------------------------------------------------------
|
||||
// Handlers
|
||||
//------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onButtonClicked: function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.$el.hasClass('open')) {
|
||||
this._renderDropdown();
|
||||
}
|
||||
},
|
||||
_onMarkActivityDone: function (event) {
|
||||
event.stopPropagation();
|
||||
var activity_id = $(event.currentTarget).data('activity-id');
|
||||
this._markActivityDone(activity_id).then(this._reload.bind(this));
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMarkActivityDone: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var activityID = $(ev.currentTarget).data('activity-id');
|
||||
this._markActivityDone(activityID).then(this._reload.bind(this));
|
||||
},
|
||||
_onScheduleActivity: function (event) {
|
||||
var activity_id = $(event.currentTarget).data('activity-id') || false;
|
||||
return this._scheduleActivity(activity_id, false, this._reload.bind(this));
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
* @returns {$.Promise}
|
||||
*/
|
||||
_onScheduleActivity: function (ev) {
|
||||
var activityID = $(ev.currentTarget).data('activity-id') || false;
|
||||
return this._scheduleActivity(activityID, false, this._reload.bind(this));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
odoo.define('mail.ChatWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatThread = require('mail.ChatThread');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var HEIGHT_OPEN = '400px';
|
||||
var HEIGHT_FOLDED = '34px';
|
||||
|
||||
return Widget.extend({
|
||||
template: "mail.ChatWindow",
|
||||
custom_events: {
|
||||
escape_pressed: '_onEscapePressed',
|
||||
document_viewer_closed: '_onDocumentViewerClose',
|
||||
},
|
||||
events: {
|
||||
'click .o_chat_composer': '_onComposerClick',
|
||||
"click .o_mail_thread": "_onChatWindowClicked",
|
||||
"keydown .o_chat_composer": "on_keydown",
|
||||
"keypress .o_chat_composer": "on_keypress",
|
||||
"click .o_chat_window_close": "on_click_close",
|
||||
"click .o_chat_title": "on_click_fold",
|
||||
},
|
||||
|
||||
init: function (parent, channel_id, title, is_folded, unread_msgs, options) {
|
||||
this._super(parent);
|
||||
this.title = title;
|
||||
this.channel_id = channel_id;
|
||||
this.folded = is_folded;
|
||||
this.options = _.defaults(options || {}, {
|
||||
autofocus: true,
|
||||
display_stars: true,
|
||||
display_reply_icon: false,
|
||||
display_email_icon: false,
|
||||
placeholder: _t("Say something"),
|
||||
input_less: false,
|
||||
});
|
||||
this.status = this.options.status;
|
||||
this.unread_msgs = unread_msgs || 0;
|
||||
this.is_hidden = false;
|
||||
this.isMobile = config.device.isMobile;
|
||||
},
|
||||
start: function () {
|
||||
this.$input = this.$('.o_composer_text_field');
|
||||
this.$header = this.$('.o_chat_header');
|
||||
|
||||
this.thread = new ChatThread(this, {
|
||||
channel_id: this.channel_id,
|
||||
display_needactions: false,
|
||||
display_stars: this.options.display_stars,
|
||||
});
|
||||
this.thread.on('toggle_star_status', null, this.trigger.bind(this, 'toggle_star_status'));
|
||||
this.thread.on('redirect_to_channel', null, this.trigger.bind(this, 'redirect_to_channel'));
|
||||
this.thread.on('redirect', null, this.trigger.bind(this, 'redirect'));
|
||||
|
||||
if (this.folded) {
|
||||
this.$el.css('height', HEIGHT_FOLDED);
|
||||
} else if (this.options.autofocus) {
|
||||
this.focus_input();
|
||||
}
|
||||
if (!config.device.isMobile) {
|
||||
this.$el.css('margin-right', $.position.scrollbarWidth());
|
||||
}
|
||||
var def = this.thread.replace(this.$('.o_chat_content'));
|
||||
return $.when(this._super(), def);
|
||||
},
|
||||
render: function (messages) {
|
||||
this.update_unread(this.unread_msgs);
|
||||
this.thread.render(messages, {display_load_more: false});
|
||||
},
|
||||
update_unread: function (counter) {
|
||||
this.unread_msgs = counter;
|
||||
this.render_header();
|
||||
},
|
||||
update_status: function (status) {
|
||||
this.status = status;
|
||||
this.render_header();
|
||||
},
|
||||
render_header: function () {
|
||||
this.$header.html(QWeb.render('mail.ChatWindowHeaderContent', {
|
||||
status: this.status,
|
||||
title: this.title,
|
||||
unread_counter: this.unread_msgs,
|
||||
widget: this,
|
||||
}));
|
||||
},
|
||||
fold: function () {
|
||||
this.$el.animate({
|
||||
height: this.folded ? HEIGHT_FOLDED : HEIGHT_OPEN
|
||||
}, 200);
|
||||
},
|
||||
toggle_fold: function (fold) {
|
||||
this.folded = _.isBoolean(fold) ? fold : !this.folded;
|
||||
if (!this.folded) {
|
||||
this.thread.scroll_to();
|
||||
this.focus_input();
|
||||
}
|
||||
this.fold();
|
||||
},
|
||||
focus_input: function () {
|
||||
if (config.device.touch && config.device.size_class <= config.device.SIZES.SM) {
|
||||
return;
|
||||
}
|
||||
this.$input.focus();
|
||||
},
|
||||
do_show: function () {
|
||||
this.is_hidden = false;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
do_hide: function () {
|
||||
this.is_hidden = true;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
do_toggle: function (display) {
|
||||
this.is_hidden = _.isBoolean(display) ? !display : !this.is_hidden;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
on_keypress: function (event) {
|
||||
event.stopPropagation(); // to prevent jquery's blockUI to cancel event
|
||||
},
|
||||
on_keydown: function (event) {
|
||||
event.stopPropagation(); // to prevent jquery's blockUI to cancel event
|
||||
// ENTER key (avoid requiring jquery ui for external livechat)
|
||||
if (event.which === 13) {
|
||||
var content = _.str.trim(this.$input.val());
|
||||
var message = {
|
||||
content: content,
|
||||
attachment_ids: [],
|
||||
partner_ids: [],
|
||||
};
|
||||
this.$input.val('');
|
||||
if (content) {
|
||||
this.trigger('post_message', message, {
|
||||
channelID: this.channel_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
on_click_close: function (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.trigger("close_chat_session");
|
||||
},
|
||||
on_click_fold: function () {
|
||||
if (!config.device.isMobile) {
|
||||
this.toggle_fold();
|
||||
this.trigger("fold_channel", this.channel_id, this.folded);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a chat window is clicked on, we want to give the focus to the main
|
||||
* input. An exception is made when the user is selecting something.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onChatWindowClicked: function () {
|
||||
var selectObj = window.getSelection();
|
||||
if (selectObj.anchorOffset === selectObj.focusOffset) {
|
||||
this.$input.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when the composer is clicked -> forces focus on input even if
|
||||
* jquery's blockUI is enabled.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onComposerClick: function (ev) {
|
||||
if ($(ev.target).closest('a, button').length) {
|
||||
return;
|
||||
}
|
||||
this.focus_input();
|
||||
},
|
||||
_onDocumentViewerClose: function (ev) {
|
||||
this.focus_input();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEscapePressed: function () {
|
||||
if (!this.folded) {
|
||||
this.trigger("close_chat_session");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -2,10 +2,10 @@ odoo.define('mail.Chatter', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Activity = require('mail.Activity');
|
||||
var ChatterComposer = require('mail.ChatterComposer');
|
||||
var ChatterComposer = require('mail.composer.Chatter');
|
||||
var Followers = require('mail.Followers');
|
||||
var ThreadField = require('mail.ThreadField');
|
||||
var utils = require('mail.utils');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var concurrency = require('web.concurrency');
|
||||
var config = require('web.config');
|
||||
@@ -47,13 +47,13 @@ var Chatter = Widget.extend({
|
||||
this._super.apply(this, arguments);
|
||||
this._setState(record);
|
||||
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
this._dp = new concurrency.DropPrevious();
|
||||
|
||||
// mention: get the prefetched partners and use them as mention suggestions
|
||||
// if there is a follower widget, the followers will be added to the
|
||||
// suggestions as well once fetched
|
||||
this.mentionPartnerSuggestions = this.call('chat_manager', 'getMentionPartnerSuggestions');
|
||||
this.mentionSuggestions = this.mentionPartnerSuggestions;
|
||||
this._mentionPartnerSuggestions = this.call('mail_service', 'getMentionPartnerSuggestions');
|
||||
this._mentionSuggestions = this._mentionPartnerSuggestions;
|
||||
|
||||
this.fields = {};
|
||||
if (mailFields.mail_activity) {
|
||||
@@ -73,19 +73,19 @@ var Chatter = Widget.extend({
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$topbar = this.$('.o_chatter_topbar');
|
||||
this._$topbar = this.$('.o_chatter_topbar');
|
||||
|
||||
// render and append the buttons
|
||||
this.$topbar.append(QWeb.render('mail.Chatter.Buttons', {
|
||||
new_message_btn: !!this.fields.thread,
|
||||
log_note_btn: this.hasLogButton,
|
||||
schedule_activity_btn: !!this.fields.activity,
|
||||
this._$topbar.append(QWeb.render('mail.chatter.Buttons', {
|
||||
newMessageButton: !!this.fields.thread,
|
||||
logNoteButton: this.hasLogButton,
|
||||
scheduleActivityButton: !!this.fields.activity,
|
||||
isMobile: config.device.isMobile,
|
||||
}));
|
||||
|
||||
// start and append the widgets
|
||||
var fieldDefs = _.invoke(this.fields, 'appendTo', $('<div>'));
|
||||
var def = this.dp.add($.when.apply($, fieldDefs));
|
||||
var def = this._dp.add($.when.apply($, fieldDefs));
|
||||
this._render(def).then(this._updateMentionSuggestions.bind(this));
|
||||
|
||||
return this._super.apply(this, arguments);
|
||||
@@ -132,7 +132,7 @@ var Chatter = Widget.extend({
|
||||
fieldsToReset = this.fields;
|
||||
}
|
||||
var fieldDefs = _.invoke(fieldsToReset, 'reset', record);
|
||||
var def = this.dp.add($.when.apply($, fieldDefs));
|
||||
var def = this._dp.add($.when.apply($, fieldDefs));
|
||||
this._render(def).then(function () {
|
||||
self.$el.height('auto');
|
||||
self._updateMentionSuggestions();
|
||||
@@ -148,13 +148,19 @@ var Chatter = Widget.extend({
|
||||
* @param {boolean} force
|
||||
*/
|
||||
_closeComposer: function (force) {
|
||||
if (this.composer && (this.composer.is_empty() || force)) {
|
||||
if (this._composer && (this._composer.isEmpty() || force)) {
|
||||
this.$el.removeClass('o_chatter_composer_active');
|
||||
this.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active');
|
||||
this.composer.do_hide();
|
||||
this.composer.clear_composer();
|
||||
this._composer.do_hide();
|
||||
this._composer.clearComposer();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_disableChatter: function () {
|
||||
this.$('.btn').prop('disabled', true); // disable buttons
|
||||
},
|
||||
/**
|
||||
* Discard changes on the record.
|
||||
*
|
||||
@@ -176,22 +182,16 @@ var Chatter = Widget.extend({
|
||||
* after posting it
|
||||
*
|
||||
* @private
|
||||
* @param {Object} message
|
||||
* @param {Object} messageData
|
||||
* @return {$.Deferred} resolved if no reload or proceed to discard the
|
||||
* changes on the record, rejected otherwise
|
||||
*/
|
||||
_discardOnReload: function (message) {
|
||||
if (this._reloadAfterPost(message)) {
|
||||
_discardOnReload: function (messageData) {
|
||||
if (this._reloadAfterPost(messageData)) {
|
||||
return this._discardChanges();
|
||||
}
|
||||
return $.when();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_disableChatter: function () {
|
||||
this.$('.btn').prop('disabled', true); // disable buttons
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -202,51 +202,49 @@ var Chatter = Widget.extend({
|
||||
* @private
|
||||
* @param {Object} options
|
||||
* @param {Object[]} [options.suggested_partners=[]]
|
||||
* @param {boolean} [options.is_log]
|
||||
* @param {boolean} [options.isLog]
|
||||
*/
|
||||
_openComposer: function (options) {
|
||||
var self = this;
|
||||
var old_composer = this.composer;
|
||||
var oldComposer = this._composer;
|
||||
// create the new composer
|
||||
this.composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], {
|
||||
commands_enabled: false,
|
||||
this._composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], {
|
||||
commandsEnabled: false,
|
||||
context: this.context,
|
||||
input_min_height: 50,
|
||||
input_max_height: Number.MAX_VALUE, // no max_height limit for the chatter
|
||||
input_baseline: 14,
|
||||
is_log: options && options.is_log,
|
||||
record_name: this.record_name,
|
||||
default_body: old_composer && old_composer.$input && old_composer.$input.val(),
|
||||
default_mention_selections: old_composer && old_composer.mention_get_listener_selections(),
|
||||
inputMinHeight: 50,
|
||||
isLog: options && options.isLog,
|
||||
recordName: this.recordName,
|
||||
defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(),
|
||||
defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(),
|
||||
});
|
||||
this.composer.on('input_focused', this, function () {
|
||||
this.composer.mention_set_prefetched_partners(this.mentionSuggestions || []);
|
||||
this._composer.on('input_focused', this, function () {
|
||||
this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []);
|
||||
});
|
||||
this.composer.insertAfter(this.$('.o_chatter_topbar')).then(function () {
|
||||
this._composer.insertAfter(this.$('.o_chatter_topbar')).then(function () {
|
||||
// destroy existing composer
|
||||
if (old_composer) {
|
||||
old_composer.destroy();
|
||||
if (oldComposer) {
|
||||
oldComposer.destroy();
|
||||
}
|
||||
if (!config.device.touch) {
|
||||
self.composer.focus();
|
||||
self._composer.focus();
|
||||
}
|
||||
self.composer.on('post_message', self, function (message) {
|
||||
self._discardOnReload(message).then(function () {
|
||||
self.fields.thread.postMessage(message).then(function () {
|
||||
self._composer.on('post_message', self, function (messageData) {
|
||||
self._discardOnReload(messageData).then(function () {
|
||||
self.fields.thread.postMessage(messageData).then(function () {
|
||||
self._closeComposer(true);
|
||||
if (self._reloadAfterPost(message)) {
|
||||
if (self._reloadAfterPost(messageData)) {
|
||||
self.trigger_up('reload');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
self.composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
|
||||
self.composer.on('close_composer', null, self._closeComposer.bind(self, true));
|
||||
self._composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
|
||||
self._composer.on('close_composer', null, self._closeComposer.bind(self, true));
|
||||
|
||||
self.$el.addClass('o_chatter_composer_active');
|
||||
self.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active');
|
||||
self.$('.o_chatter_button_new_message').toggleClass('o_active', !self.composer.options.is_log);
|
||||
self.$('.o_chatter_button_log_note').toggleClass('o_active', self.composer.options.is_log);
|
||||
self.$('.o_chatter_button_new_message').toggleClass('o_active', !self._composer.options.isLog);
|
||||
self.$('.o_chatter_button_log_note').toggleClass('o_active', self._composer.options.isLog);
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -254,17 +252,17 @@ var Chatter = Widget.extend({
|
||||
* Useful to warn the user of unsaved changes if the record is dirty.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} message
|
||||
* @param {Array} [message.partner_ids] list of recipients of a message
|
||||
* @param {Object} messageData
|
||||
* @param {Array} [messageData.partner_ids] list of recipients of a message
|
||||
* @return {boolean} true if record will be reloaded after posting the
|
||||
* message, false otherwise
|
||||
*/
|
||||
_reloadAfterPost: function (message) {
|
||||
_reloadAfterPost: function (messageData) {
|
||||
return this.postRefresh === 'always' ||
|
||||
(
|
||||
this.postRefresh === 'recipients' &&
|
||||
message.partner_ids &&
|
||||
message.partner_ids.length
|
||||
messageData.partner_ids &&
|
||||
messageData.partner_ids.length
|
||||
);
|
||||
},
|
||||
/**
|
||||
@@ -291,14 +289,14 @@ var Chatter = Widget.extend({
|
||||
self.fields.activity.$el.appendTo(self.$el);
|
||||
}
|
||||
if (self.fields.followers) {
|
||||
self.fields.followers.$el.appendTo(self.$topbar);
|
||||
self.fields.followers.$el.appendTo(self._$topbar);
|
||||
}
|
||||
if (self.fields.thread) {
|
||||
self.fields.thread.$el.appendTo(self.$el);
|
||||
}
|
||||
}).always(function () {
|
||||
// disable widgets in create mode, otherwise enable
|
||||
self.isCreateMode ? self._disableChatter() : self._enableChatter();
|
||||
self._isCreateMode ? self._disableChatter() : self._enableChatter();
|
||||
$spinner.remove();
|
||||
});
|
||||
},
|
||||
@@ -311,8 +309,7 @@ var Chatter = Widget.extend({
|
||||
*/
|
||||
_setState: function (record) {
|
||||
|
||||
this.wasCreateMode = !!this.isCreateMode;
|
||||
this.isCreateMode = !record.res_id;
|
||||
this._isCreateMode = !record.res_id;
|
||||
|
||||
if (!this.record || this.record.res_id !== record.res_id) {
|
||||
this.context = {
|
||||
@@ -324,7 +321,7 @@ var Chatter = Widget.extend({
|
||||
this.suggested_partners_def = undefined;
|
||||
}
|
||||
this.record = record;
|
||||
this.record_name = record.data.display_name;
|
||||
this.recordName = record.data.display_name;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -335,28 +332,28 @@ var Chatter = Widget.extend({
|
||||
}
|
||||
var self = this;
|
||||
|
||||
this.mentionSuggestions = [];
|
||||
this._mentionSuggestions = [];
|
||||
|
||||
// add the followers to the mention suggestions
|
||||
var follower_suggestions = [];
|
||||
var followerSuggestions = [];
|
||||
var followers = this.fields.followers.getFollowers();
|
||||
_.each(followers, function (follower) {
|
||||
if (follower.res_model === 'res.partner') {
|
||||
follower_suggestions.push({
|
||||
followerSuggestions.push({
|
||||
id: follower.res_id,
|
||||
name: follower.name,
|
||||
email: follower.email,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (follower_suggestions.length) {
|
||||
this.mentionSuggestions.push(follower_suggestions);
|
||||
if (followerSuggestions.length) {
|
||||
this._mentionSuggestions.push(followerSuggestions);
|
||||
}
|
||||
|
||||
// add the partners (followers filtered out) to the mention suggestions
|
||||
_.each(this.mentionPartnerSuggestions, function (partners) {
|
||||
self.mentionSuggestions.push(_.filter(partners, function (partner) {
|
||||
return !_.findWhere(follower_suggestions, { id: partner.id });
|
||||
_.each(this._mentionPartnerSuggestions, function (partners) {
|
||||
self._mentionSuggestions.push(_.filter(partners, function (partner) {
|
||||
return !_.findWhere(followerSuggestions, { id: partner.id });
|
||||
}));
|
||||
});
|
||||
},
|
||||
@@ -390,7 +387,7 @@ var Chatter = Widget.extend({
|
||||
var suggested_partners = [];
|
||||
var thread_recipients = result[self.context.default_res_id];
|
||||
_.each(thread_recipients, function (recipient) {
|
||||
var parsed_email = recipient[1] && utils.parse_email(recipient[1]);
|
||||
var parsed_email = recipient[1] && mailUtils.parseEmail(recipient[1]);
|
||||
suggested_partners.push({
|
||||
checked: true,
|
||||
partner_id: recipient[0],
|
||||
@@ -404,14 +401,14 @@ var Chatter = Widget.extend({
|
||||
});
|
||||
}
|
||||
this.suggested_partners_def.then(function (suggested_partners) {
|
||||
self._openComposer({ is_log: false, suggested_partners: suggested_partners });
|
||||
self._openComposer({ isLog: false, suggested_partners: suggested_partners });
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onOpenComposerNote: function () {
|
||||
this._openComposer({is_log: true});
|
||||
this._openComposer({ isLog: true });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
odoo.define('mail.ChatterComposer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var composer = require('mail.composer');
|
||||
var utils = require('mail.utils');
|
||||
|
||||
var core = require('web.core');
|
||||
var view_dialogs = require('web.view_dialogs');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
// Chat Composer for the Chatter
|
||||
//
|
||||
// Extends the basic Composer Widget to add 'suggested partner' layer (open
|
||||
// popup when suggested partner is selected without email, or other
|
||||
// informations), and the button to open the full composer wizard.
|
||||
var ChatterComposer = composer.BasicComposer.extend({
|
||||
template: 'mail.chatter.ChatComposer',
|
||||
|
||||
init: function (parent, model, suggested_partners, options) {
|
||||
this._super(parent, options);
|
||||
this.model = model;
|
||||
this.suggested_partners = suggested_partners;
|
||||
this.options = _.defaults(this.options, {
|
||||
display_mode: 'textarea',
|
||||
record_name: false,
|
||||
is_log: false,
|
||||
});
|
||||
if (this.options.is_log) {
|
||||
this.options.send_text = _t('Log');
|
||||
}
|
||||
this.events = _.extend(this.events, {
|
||||
'click .o_composer_button_full_composer': 'on_open_full_composer',
|
||||
});
|
||||
this.notInline = true;
|
||||
},
|
||||
|
||||
should_send: function () {
|
||||
return false;
|
||||
},
|
||||
|
||||
preprocess_message: function () {
|
||||
var self = this;
|
||||
var def = $.Deferred();
|
||||
this._super().then(function (message) {
|
||||
message = _.extend(message, {
|
||||
subtype: 'mail.mt_comment',
|
||||
message_type: 'comment',
|
||||
context: self.context,
|
||||
});
|
||||
|
||||
// Subtype
|
||||
if (self.options.is_log) {
|
||||
message.subtype = 'mail.mt_note';
|
||||
}
|
||||
|
||||
// Partner_ids
|
||||
if (!self.options.is_log) {
|
||||
var checked_suggested_partners = self.get_checked_suggested_partners();
|
||||
self.check_suggested_partners(checked_suggested_partners).done(function (partner_ids) {
|
||||
message.partner_ids = (message.partner_ids || []).concat(partner_ids);
|
||||
// update context
|
||||
message.context = _.defaults({}, message.context, {
|
||||
mail_post_autofollow: true,
|
||||
});
|
||||
def.resolve(message);
|
||||
});
|
||||
} else {
|
||||
def.resolve(message);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return def;
|
||||
},
|
||||
|
||||
clear_composer_on_send: function() {
|
||||
/* chatter don't clear message on sent but after successful sent */
|
||||
},
|
||||
|
||||
/**
|
||||
* Send the message on SHIFT+ENTER, but go to new line on ENTER
|
||||
*/
|
||||
prevent_send: function (event) {
|
||||
return !event.shiftKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the list of selected suggested partners
|
||||
* @returns {Array} list of 'recipient' selected partners (may not be created in db)
|
||||
**/
|
||||
get_checked_suggested_partners: function () {
|
||||
var self = this;
|
||||
var checked_partners = [];
|
||||
this.$('.o_composer_suggested_partners input:checked').each(function() {
|
||||
var full_name = $(this).data('fullname');
|
||||
checked_partners = checked_partners.concat(_.filter(self.suggested_partners, function(item) {
|
||||
return full_name === item.full_name;
|
||||
}));
|
||||
});
|
||||
return checked_partners;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check the additional partners (not necessary registered partners), and open a popup form view
|
||||
* for the ones who informations is missing.
|
||||
* @param {Array} checked_suggested_partners list of 'recipient' partners to complete informations or validate
|
||||
* @returns {Deferred} resolved with the list of checked suggested partners (real partner)
|
||||
**/
|
||||
check_suggested_partners: function (checked_suggested_partners) {
|
||||
var self = this;
|
||||
var check_done = $.Deferred();
|
||||
|
||||
var recipients = _.filter(checked_suggested_partners, function (recipient) { return recipient.checked; });
|
||||
var recipients_to_find = _.filter(recipients, function (recipient) { return (! recipient.partner_id); });
|
||||
var names_to_find = _.pluck(recipients_to_find, 'full_name');
|
||||
var recipients_to_check = _.filter(recipients, function (recipient) { return (recipient.partner_id && ! recipient.email_address); });
|
||||
var recipient_ids = _.pluck(_.filter(recipients, function (recipient) { return recipient.partner_id && recipient.email_address; }), 'partner_id');
|
||||
|
||||
var names_to_remove = [];
|
||||
var recipient_ids_to_remove = [];
|
||||
|
||||
// have unknown names -> call message_get_partner_info_from_emails to try to find partner_id
|
||||
var def;
|
||||
if (names_to_find.length > 0) {
|
||||
def = this._rpc({
|
||||
model: this.model,
|
||||
method: 'message_partner_info_from_emails',
|
||||
args: [[this.context.default_res_id], names_to_find],
|
||||
});
|
||||
}
|
||||
|
||||
// for unknown names + incomplete partners -> open popup - cancel = remove from recipients
|
||||
$.when(def).pipe(function (result) {
|
||||
result = result || [];
|
||||
var emails_deferred = [];
|
||||
var recipient_popups = result.concat(recipients_to_check);
|
||||
|
||||
_.each(recipient_popups, function (partner_info) {
|
||||
var deferred = $.Deferred();
|
||||
emails_deferred.push(deferred);
|
||||
|
||||
var partner_name = partner_info.full_name;
|
||||
var partner_id = partner_info.partner_id;
|
||||
var parsed_email = utils.parse_email(partner_name);
|
||||
|
||||
var dialog = new view_dialogs.FormViewDialog(self, {
|
||||
res_model: 'res.partner',
|
||||
res_id: partner_id,
|
||||
context: {
|
||||
active_model: self.model,
|
||||
active_id: self.context.default_res_id,
|
||||
force_email: true,
|
||||
ref: "compound_context",
|
||||
default_name: parsed_email[0],
|
||||
default_email: parsed_email[1],
|
||||
},
|
||||
title: _t("Please complete customer's informations"),
|
||||
disable_multiple_selection: true,
|
||||
}).open();
|
||||
dialog.on('closed', self, function () {
|
||||
deferred.resolve();
|
||||
});
|
||||
dialog.opened().then(function () {
|
||||
dialog.form_view.on('on_button_cancel', self, function () {
|
||||
names_to_remove.push(partner_name);
|
||||
if (partner_id) {
|
||||
recipient_ids_to_remove.push(partner_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
$.when.apply($, emails_deferred).then(function () {
|
||||
var new_names_to_find = _.difference(names_to_find, names_to_remove);
|
||||
var def;
|
||||
if (new_names_to_find.length > 0) {
|
||||
def = self._rpc({
|
||||
model: self.model,
|
||||
method: 'message_partner_info_from_emails',
|
||||
args: [[self.context.default_res_id], new_names_to_find, true],
|
||||
});
|
||||
}
|
||||
$.when(def).pipe(function (result) {
|
||||
result = result || [];
|
||||
var recipient_popups = result.concat(recipients_to_check);
|
||||
_.each(recipient_popups, function (partner_info) {
|
||||
if (partner_info.partner_id && _.indexOf(partner_info.partner_id, recipient_ids_to_remove) === -1) {
|
||||
recipient_ids.push(partner_info.partner_id);
|
||||
}
|
||||
});
|
||||
}).pipe(function () {
|
||||
check_done.resolve(recipient_ids);
|
||||
});
|
||||
});
|
||||
});
|
||||
return check_done;
|
||||
},
|
||||
|
||||
on_open_full_composer: function() {
|
||||
if (!this.do_check_attachment_upload()){
|
||||
return false;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var recipientDoneDef = $.Deferred();
|
||||
|
||||
// any operation on the full-composer will reload the record, so
|
||||
// warn the user that any unsaved changes on the record will be lost.
|
||||
this.trigger_up('discard_record_changes', {
|
||||
proceed: function () {
|
||||
if (self.options.is_log) {
|
||||
recipientDoneDef.resolve([]);
|
||||
} else {
|
||||
var checked_suggested_partners = self.get_checked_suggested_partners();
|
||||
self.check_suggested_partners(checked_suggested_partners)
|
||||
.then(recipientDoneDef.resolve.bind(recipientDoneDef));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
recipientDoneDef.then(function (partner_ids) {
|
||||
var context = {
|
||||
default_parent_id: self.id,
|
||||
default_body: utils.get_text2html(self.$input.val()),
|
||||
default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'),
|
||||
default_partner_ids: partner_ids,
|
||||
default_is_log: self.options.is_log,
|
||||
mail_post_autofollow: true,
|
||||
};
|
||||
|
||||
if (self.context.default_model && self.context.default_res_id) {
|
||||
context.default_model = self.context.default_model;
|
||||
context.default_res_id = self.context.default_res_id;
|
||||
}
|
||||
|
||||
var action = {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.compose.message',
|
||||
view_mode: 'form',
|
||||
view_type: 'form',
|
||||
views: [[false, 'form']],
|
||||
target: 'new',
|
||||
context: context,
|
||||
};
|
||||
self.do_action(action, {
|
||||
on_close: self.trigger.bind(self, 'need_refresh'),
|
||||
}).then(self.trigger.bind(self, 'close_composer'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ChatterComposer;
|
||||
|
||||
});
|
||||
@@ -1,922 +0,0 @@
|
||||
odoo.define('mail.composer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var DocumentViewer = require('mail.DocumentViewer');
|
||||
var utils = require('mail.utils');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var data = require('web.data');
|
||||
var dom = require('web.dom');
|
||||
var session = require('web.session');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var NON_BREAKING_SPACE = '\u00a0';
|
||||
|
||||
var MENTION_PARTNER_DELIMITER = '@';
|
||||
var MENTION_CHANNEL_DELIMITER = '#';
|
||||
var MENTION_CANNED_RESPONSE_DELIMITER = ':';
|
||||
var MENTION_COMMAND_DELIMITER = '/';
|
||||
|
||||
// The MentionManager allows the Composer to register listeners. For each
|
||||
// listener, it detects if the user is currently typing a mention (starting by a
|
||||
// given delimiter). If so, if fetches mention suggestions and renders them. On
|
||||
// suggestion clicked, it updates the selection for the corresponding listener.
|
||||
var MentionManager = Widget.extend({
|
||||
className: 'dropup o_composer_mention_dropdown',
|
||||
|
||||
events: {
|
||||
"mouseover .o_mention_proposition": "on_hover_mention_proposition",
|
||||
"click .o_mention_proposition": "on_click_mention_item",
|
||||
},
|
||||
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.composer = parent;
|
||||
this.options = _.extend({}, options, {
|
||||
min_length: 0,
|
||||
});
|
||||
|
||||
this.open = false;
|
||||
this.listeners = [];
|
||||
this.set('mention_suggestions', []);
|
||||
this.on('change:mention_suggestions', this, this._render_suggestions);
|
||||
},
|
||||
|
||||
// Events
|
||||
on_hover_mention_proposition: function (event) {
|
||||
var $elem = $(event.currentTarget);
|
||||
this.$('.o_mention_proposition').removeClass('active');
|
||||
$elem.addClass('active');
|
||||
},
|
||||
on_click_mention_item: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var text_input = this.composer.$input.val();
|
||||
var id = $(event.currentTarget).data('id');
|
||||
var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) {
|
||||
return s.id === id;
|
||||
});
|
||||
var substitution = selected_suggestion.substitution;
|
||||
if (!substitution) { // no substitution string given, so use the mention name instead
|
||||
// replace white spaces with non-breaking spaces to facilitate mentions detection in text
|
||||
selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE);
|
||||
substitution = this.active_listener.delimiter + selected_suggestion.name;
|
||||
}
|
||||
var get_mention_index = function (matches, cursor_position) {
|
||||
for (var i=0; i<matches.length; i++) {
|
||||
if (cursor_position <= matches[i].index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
// add the selected suggestion to the list
|
||||
if (this.active_listener.selection.length) {
|
||||
// get mention matches (ordered by index in the text)
|
||||
var matches = this._get_match(text_input, this.active_listener);
|
||||
var index = get_mention_index(matches, this._get_selection_positions().start);
|
||||
this.active_listener.selection.splice(index, 0, selected_suggestion);
|
||||
} else {
|
||||
this.active_listener.selection.push(selected_suggestion);
|
||||
}
|
||||
|
||||
// update input text, and reset dropdown
|
||||
var cursor_position = this._get_selection_positions().start;
|
||||
var text_left = text_input.substring(0, cursor_position-(this.mention_word.length+1));
|
||||
var text_right = text_input.substring(cursor_position, text_input.length);
|
||||
var text_input_new = text_left + substitution + ' ' + text_right;
|
||||
this.composer.$input.val(text_input_new);
|
||||
this._set_cursor_position(text_left.length+substitution.length+2);
|
||||
this.set('mention_suggestions', []);
|
||||
this.composer.focus(); // to trigger autoresize
|
||||
},
|
||||
|
||||
// Public API
|
||||
/**
|
||||
* Registers a new listener, described by an object containing the following keys
|
||||
* @param {boolean} [beginning_only] true to enable autocomplete only at first position of input
|
||||
* @param {char} [delimiter] the mention delimiter
|
||||
* @param {function} [fetch_callback] the callback to fetch mention suggestions
|
||||
* @param {boolean} [generate_links] true to wrap mentions in <a> links
|
||||
* @param {string} [model] (optional) the model used for redirection
|
||||
* @param {string} [redirect_classname] (optional) the classname of the <a> wrapping the mention
|
||||
* @param {array} [selection] (optional) initial mentions for each listener
|
||||
* @param {string} [suggestion_template] the template of suggestions' dropdown
|
||||
*/
|
||||
register: function (listener) {
|
||||
this.listeners.push(_.defaults(listener, {
|
||||
model: '',
|
||||
redirect_classname: '',
|
||||
selection: [],
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if the mention suggestions dropdown is open, false otherwise
|
||||
*/
|
||||
is_open: function () {
|
||||
return this.open;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the mentions of the given listener that haven't been erased from the composer's input
|
||||
*/
|
||||
get_listener_selection: function (delimiter) {
|
||||
var listener = _.findWhere(this.listeners, {delimiter: delimiter});
|
||||
if (listener) {
|
||||
var input_mentions = this.composer.$input.val().match(new RegExp(delimiter+'[^ ]+(?= | )', 'g'));
|
||||
return this._validate_selection(listener.selection, input_mentions);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
get_listener_selections: function () {
|
||||
var selections = {};
|
||||
_.each(this.listeners, function (listener) {
|
||||
selections[listener.delimiter] = listener.selection;
|
||||
});
|
||||
return selections;
|
||||
},
|
||||
|
||||
/**
|
||||
* Detects if the user is currently typing a mention word
|
||||
* @return the search string if it is, false otherwise
|
||||
*/
|
||||
detect_delimiter: function () {
|
||||
var self = this;
|
||||
var text_val = this.composer.$input.val();
|
||||
var cursor_position = this._get_selection_positions().start;
|
||||
var left_string = text_val.substring(0, cursor_position);
|
||||
function validate_keyword (delimiter, beginning_only) {
|
||||
var delimiter_position = left_string.lastIndexOf(delimiter) - 1;
|
||||
if (beginning_only && delimiter_position > 0) {
|
||||
return false;
|
||||
}
|
||||
var search_str = text_val.substring(delimiter_position, cursor_position);
|
||||
var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))";
|
||||
var regex_start = new RegExp(pattern, "g");
|
||||
search_str = search_str.replace(/^\s\s*|^[\n\r]/g, '');
|
||||
if (regex_start.test(search_str) && search_str.length > self.options.min_length) {
|
||||
search_str = search_str.replace(pattern, '');
|
||||
return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
this.active_listener = undefined;
|
||||
for (var i=0; i<this.listeners.length; i++) {
|
||||
var listener = this.listeners[i];
|
||||
this.mention_word = validate_keyword(listener.delimiter, listener.beginning_only);
|
||||
|
||||
if (this.mention_word !== false) {
|
||||
this.active_listener = listener;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.active_listener) {
|
||||
var mention_word = this.mention_word;
|
||||
$.when(this.active_listener.fetch_callback(mention_word)).then(function (suggestions) {
|
||||
if (mention_word === self.mention_word) {
|
||||
// update suggestions only if mention_word didn't change in the meantime
|
||||
self.set('mention_suggestions', suggestions);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.set('mention_suggestions', []); // close the dropdown
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces mentions appearing in the string 's' by html links with proper redirection
|
||||
*/
|
||||
generate_links: function (s) {
|
||||
var self = this;
|
||||
var base_href = session.url("/web");
|
||||
var mention_link = "<a href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'>%s%s</a>";
|
||||
_.each(this.listeners, function (listener) {
|
||||
if (!listener.generate_links) {
|
||||
return;
|
||||
}
|
||||
var selection = listener.selection;
|
||||
if (selection.length) {
|
||||
var matches = self._get_match(s, listener);
|
||||
var substrings = [];
|
||||
var start_index = 0;
|
||||
for (var i=0; i<matches.length; i++) {
|
||||
var match = matches[i];
|
||||
var end_index = match.index + match[0].length;
|
||||
var selection_id = self.get_selection_id(match, selection);
|
||||
// put back white spaces instead of non-breaking spaces in mention's name
|
||||
var match_name = match[0].substring(1).replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' ');
|
||||
var href = base_href + _.str.sprintf("#model=%s&id=%s", listener.model, selection_id);
|
||||
var processed_text = _.str.sprintf(mention_link, href, listener.redirect_classname, selection_id, listener.model, listener.delimiter, match_name);
|
||||
var subtext = s.substring(start_index, end_index).replace(match[0], processed_text);
|
||||
substrings.push(subtext);
|
||||
start_index = end_index;
|
||||
}
|
||||
substrings.push(s.substring(start_index, s.length));
|
||||
s = substrings.join('');
|
||||
}
|
||||
});
|
||||
return s;
|
||||
},
|
||||
|
||||
get_selection_id: function (match, selection) {
|
||||
return _.findWhere(selection, {'name': match[0].slice(1)}).id;
|
||||
},
|
||||
|
||||
reset_suggestions: function () {
|
||||
this.set('mention_suggestions', []);
|
||||
},
|
||||
reset_selections: function () {
|
||||
_.each(this.listeners, function (listener) {
|
||||
listener.selection = [];
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the matches (as RexExp.exec does) for the mention in the input text
|
||||
* @param {String} input_text: the text to search matches
|
||||
* @param {Object} listener: the listener for which we want to find a match
|
||||
* @returns {Object[]} matches in the same format as RexExp.exec()
|
||||
*/
|
||||
_get_match: function (input_text, listener) {
|
||||
// create the regex of all mention's names
|
||||
var names = _.pluck(listener.selection, 'name');
|
||||
var escaped_names = _.map(names, function (str) {
|
||||
return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
|
||||
});
|
||||
var regex_str = escaped_names.join('|');
|
||||
// extract matches
|
||||
var result = [];
|
||||
if (regex_str.length) {
|
||||
var myRegexp = new RegExp(regex_str, 'g');
|
||||
var match = myRegexp.exec(input_text);
|
||||
while (match !== null) {
|
||||
result.push(match);
|
||||
match = myRegexp.exec(input_text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} keycode
|
||||
*/
|
||||
_propositionNavigation: function (keycode) {
|
||||
var $active = this.$('.o_mention_proposition.active');
|
||||
if (keycode === $.ui.keyCode.ENTER) { // selecting proposition
|
||||
$active.click();
|
||||
} else { // navigation in propositions
|
||||
var $to;
|
||||
if (keycode === $.ui.keyCode.DOWN) {
|
||||
$to = $active.nextAll('.o_mention_proposition').first();
|
||||
} else if (keycode === $.ui.keyCode.UP) {
|
||||
$to = $active.prevAll('.o_mention_proposition').first();
|
||||
} else if (keycode === $.ui.keyCode.TAB) {
|
||||
$to = $active.nextAll('.o_mention_proposition').first();
|
||||
if (!$to.length) {
|
||||
$to = $active.prevAll('.o_mention_proposition').last();
|
||||
}
|
||||
}
|
||||
if ($to && $to.length) {
|
||||
$active.removeClass('active');
|
||||
$to.addClass('active');
|
||||
}
|
||||
}
|
||||
},
|
||||
_render_suggestions: function () {
|
||||
var suggestions = [];
|
||||
if (_.find(this.get('mention_suggestions'), _.isArray)) {
|
||||
// Array of arrays -> Flatten and insert dividers between groups
|
||||
var insert_divider = false;
|
||||
_.each(this.get('mention_suggestions'), function (suggestion_group) {
|
||||
if (suggestion_group.length > 0) {
|
||||
if (insert_divider) {
|
||||
suggestions.push({ divider: true });
|
||||
}
|
||||
suggestions = suggestions.concat(suggestion_group);
|
||||
insert_divider = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
suggestions = this.get('mention_suggestions');
|
||||
}
|
||||
if (suggestions.length) {
|
||||
this.$el.html(QWeb.render(this.active_listener.suggestion_template, {
|
||||
suggestions: suggestions,
|
||||
}));
|
||||
this.$el
|
||||
.addClass('open')
|
||||
.find('ul').css("max-width", this.composer.$input.width())
|
||||
.find('.o_mention_proposition').first().addClass('active');
|
||||
this.open = true;
|
||||
} else {
|
||||
this.$el.removeClass('open');
|
||||
this.$el.empty();
|
||||
this.open = false;
|
||||
}
|
||||
},
|
||||
_validate_selection: function (selection, input_mentions) {
|
||||
var validated_selection = [];
|
||||
_.each(input_mentions, function (mention) {
|
||||
var validated_mention = _.findWhere(selection, {name: mention.slice(1)});
|
||||
if (validated_mention) {
|
||||
validated_selection.push(validated_mention);
|
||||
}
|
||||
});
|
||||
return validated_selection;
|
||||
},
|
||||
|
||||
// Cursor position and selection utils
|
||||
_get_selection_positions: function () {
|
||||
var el = this.composer.$input.get(0);
|
||||
return el ? dom.getSelectionRange(el) : {start: 0, end: 0};
|
||||
},
|
||||
_set_cursor_position: function (pos) {
|
||||
this.composer.$input.each(function (index, elem) {
|
||||
dom.setSelectionRange(elem, {start: pos, end: pos});
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
var BasicComposer = Widget.extend({
|
||||
template: "mail.ChatComposer",
|
||||
events: {
|
||||
"keydown .o_composer_input textarea": "on_keydown",
|
||||
"keyup .o_composer_input": "on_keyup",
|
||||
"change input.o_input_file": "on_attachment_change",
|
||||
"click .o_composer_button_send": "send_message",
|
||||
"click .o_composer_button_add_attachment": "on_click_add_attachment",
|
||||
"click .o_attachment_delete": "on_attachment_delete",
|
||||
"click .o_attachment_download": "_onAttachmentDownload",
|
||||
"click .o_attachment_view": "_onAttachmentView",
|
||||
'click .o_composer_button_emoji': '_onEmojiButtonClick',
|
||||
'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
|
||||
'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
|
||||
'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
|
||||
},
|
||||
// RPCs done to fetch the mention suggestions are throttled with the following value
|
||||
MENTION_THROTTLE: 200,
|
||||
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
this.options = _.defaults(options || {}, {
|
||||
commands_enabled: true,
|
||||
context: {},
|
||||
input_baseline: 18,
|
||||
input_max_height: 150,
|
||||
input_min_height: 28,
|
||||
mention_fetch_limit: 8,
|
||||
mention_partners_restricted: false, // set to true to only suggest prefetched partners
|
||||
send_text: _t('Send'),
|
||||
default_body: '',
|
||||
default_mention_selections: {},
|
||||
isMobile: config.device.isMobile
|
||||
});
|
||||
this.context = this.options.context;
|
||||
|
||||
// Attachments
|
||||
this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
|
||||
this.fileupload_id = _.uniqueId('o_chat_fileupload');
|
||||
this.set('attachment_ids', []);
|
||||
|
||||
// Mention
|
||||
this.mention_manager = new MentionManager(this);
|
||||
this.mention_manager.register({
|
||||
delimiter: MENTION_PARTNER_DELIMITER,
|
||||
fetch_callback: this.mention_fetch_partners.bind(this),
|
||||
generate_links: true,
|
||||
model: 'res.partner',
|
||||
redirect_classname: 'o_mail_redirect',
|
||||
selection: this.options.default_mention_selections[MENTION_PARTNER_DELIMITER],
|
||||
suggestion_template: 'mail.MentionPartnerSuggestions',
|
||||
});
|
||||
this.mention_manager.register({
|
||||
delimiter: MENTION_CHANNEL_DELIMITER,
|
||||
fetch_callback: this.mention_fetch_channels.bind(this),
|
||||
generate_links: true,
|
||||
model: 'mail.channel',
|
||||
redirect_classname: 'o_channel_redirect',
|
||||
selection: this.options.default_mention_selections[MENTION_CHANNEL_DELIMITER],
|
||||
suggestion_template: 'mail.MentionChannelSuggestions',
|
||||
});
|
||||
this.mention_manager.register({
|
||||
delimiter: MENTION_CANNED_RESPONSE_DELIMITER,
|
||||
fetch_callback: this.mention_get_canned_responses.bind(this),
|
||||
selection: this.options.default_mention_selections[MENTION_CANNED_RESPONSE_DELIMITER],
|
||||
suggestion_template: 'mail.MentionCannedResponseSuggestions',
|
||||
});
|
||||
if (this.options.commands_enabled) {
|
||||
this.mention_manager.register({
|
||||
beginning_only: true,
|
||||
delimiter: MENTION_COMMAND_DELIMITER,
|
||||
fetch_callback: this.mention_get_commands.bind(this),
|
||||
selection: this.options.default_mention_selections[MENTION_COMMAND_DELIMITER],
|
||||
suggestion_template: 'mail.MentionCommandSuggestions',
|
||||
});
|
||||
}
|
||||
|
||||
this.isMini = options.isMini;
|
||||
|
||||
this.avatarURL = session.uid > 0 ? session.url('/web/image', {
|
||||
model: 'res.users',
|
||||
field: 'image_small',
|
||||
id: session.uid,
|
||||
}) : '/web/static/src/img/user_menu_avatar.png';
|
||||
},
|
||||
|
||||
start: function () {
|
||||
var self = this;
|
||||
|
||||
this.$attachment_button = this.$(".o_composer_button_add_attachment");
|
||||
this.$attachments_list = this.$('.o_composer_attachments_list');
|
||||
this.$input = this.$('.o_composer_input textarea');
|
||||
this.$input.focus(function () {
|
||||
self.trigger('input_focused');
|
||||
});
|
||||
this.$input.val(this.options.default_body);
|
||||
dom.autoresize(this.$input, {parent: this, min_height: this.options.input_min_height});
|
||||
|
||||
// Attachments
|
||||
$(window).on(this.fileupload_id, this.on_attachment_loaded);
|
||||
this.on("change:attachment_ids", this, this.render_attachments);
|
||||
|
||||
// Mention
|
||||
this.mention_manager.prependTo(this.$('.o_composer'));
|
||||
|
||||
return this._super();
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
$(window).off(this.fileupload_id);
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
preprocess_message: function () {
|
||||
// Return a deferred as this function is extended with asynchronous
|
||||
// behavior for the chatter composer
|
||||
|
||||
//Removing unwanted extra spaces from message
|
||||
var value = _.escape(this.$input.val()).trim();
|
||||
value = value.replace(/(\r|\n){2,}/g, '<br/><br/>');
|
||||
value = value.replace(/(\r|\n)/g, '<br/>');
|
||||
|
||||
// prevent html space collapsing
|
||||
value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2');
|
||||
var commands = this.options.commands_enabled ? this.mention_manager.get_listener_selection('/') : [];
|
||||
return $.when({
|
||||
content: this.mention_manager.generate_links(value),
|
||||
attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
|
||||
partner_ids: _.uniq(_.pluck(this.mention_manager.get_listener_selection('@'), 'id')),
|
||||
command: commands.length > 0 ? commands[0].name : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
send_message: function () {
|
||||
if (this.is_empty() || !this.do_check_attachment_upload()) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.canned_timeout);
|
||||
var self = this;
|
||||
this.preprocess_message().then(function (message) {
|
||||
self.trigger('post_message', message);
|
||||
self.clear_composer_on_send();
|
||||
self.$input.focus();
|
||||
});
|
||||
},
|
||||
|
||||
clear_composer: function() {
|
||||
// Empty input, selected partners and attachments
|
||||
this.$input.val('');
|
||||
this.mention_manager.reset_selections();
|
||||
this.set('attachment_ids', []);
|
||||
},
|
||||
|
||||
clear_composer_on_send: function() {
|
||||
this.clear_composer();
|
||||
},
|
||||
|
||||
getState: function () {
|
||||
return {
|
||||
attachments: this.get('attachment_ids'),
|
||||
text: this.$input.val(),
|
||||
};
|
||||
},
|
||||
|
||||
// Events
|
||||
on_click_add_attachment: function () {
|
||||
this.$('input.o_input_file').click();
|
||||
this.$input.focus();
|
||||
// set ignoreEscape to avoid escape_pressed event when file selector dialog is opened
|
||||
// when user press escape to cancel file selector dialog then escape_pressed event should not be trigerred
|
||||
this.ignoreEscape = true;
|
||||
},
|
||||
|
||||
setState: function (state) {
|
||||
this.set('attachment_ids', state.attachments);
|
||||
this.$input.val(state.text);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send the message on ENTER, but go to new line on SHIFT+ENTER
|
||||
*/
|
||||
should_send: function (event) {
|
||||
return !event.shiftKey;
|
||||
},
|
||||
|
||||
on_keydown: function (event) {
|
||||
switch(event.which) {
|
||||
// UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions
|
||||
case $.ui.keyCode.UP:
|
||||
case $.ui.keyCode.DOWN:
|
||||
case $.ui.keyCode.TAB:
|
||||
if (this.mention_manager.is_open()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
// ENTER: submit the message only if the dropdown mention proposition is not displayed
|
||||
case $.ui.keyCode.ENTER:
|
||||
if (this.mention_manager.is_open()) {
|
||||
event.preventDefault();
|
||||
} else {
|
||||
var send_message = event.ctrlKey || this.should_send(event);
|
||||
if (send_message) {
|
||||
event.preventDefault();
|
||||
this.send_message();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
on_keyup: function (event) {
|
||||
switch(event.which) {
|
||||
// ESCAPED KEYS: do nothing
|
||||
case $.ui.keyCode.END:
|
||||
case $.ui.keyCode.PAGE_UP:
|
||||
case $.ui.keyCode.PAGE_DOWN:
|
||||
break;
|
||||
// ESCAPE: close mention propositions
|
||||
case $.ui.keyCode.ESCAPE:
|
||||
if (this.mention_manager.is_open()) {
|
||||
event.stopPropagation();
|
||||
this.mention_manager.reset_suggestions();
|
||||
} else if (this.ignoreEscape) {
|
||||
this.ignoreEscape = false;
|
||||
} else {
|
||||
this.trigger_up("escape_pressed");
|
||||
}
|
||||
break;
|
||||
// ENTER, UP, DOWN, TAB: check if navigation in mention propositions
|
||||
case $.ui.keyCode.ENTER:
|
||||
case $.ui.keyCode.UP:
|
||||
case $.ui.keyCode.DOWN:
|
||||
case $.ui.keyCode.TAB:
|
||||
if (this.mention_manager.is_open()) {
|
||||
this.mention_manager._propositionNavigation(event.which);
|
||||
}
|
||||
break;
|
||||
// Otherwise, check if a mention is typed
|
||||
default:
|
||||
this.mention_manager.detect_delimiter();
|
||||
}
|
||||
},
|
||||
|
||||
// Attachments
|
||||
on_attachment_change: function(event) {
|
||||
var self = this,
|
||||
files = event.target.files,
|
||||
attachments = self.get('attachment_ids');
|
||||
|
||||
_.each(files, function(file){
|
||||
var attachment = _.findWhere(attachments, {name: file.name});
|
||||
// if the files already exits, delete the file before upload
|
||||
if(attachment){
|
||||
self.AttachmentDataSet.unlink([attachment.id]);
|
||||
attachments = _.without(attachments, attachment);
|
||||
}
|
||||
});
|
||||
|
||||
this.$('form.o_form_binary_form').submit();
|
||||
this.$attachment_button.prop('disabled', true);
|
||||
var upload_attachments = _.map(files, function(file){
|
||||
return {
|
||||
'id': 0,
|
||||
'name': file.name,
|
||||
'filename': file.name,
|
||||
'url': '',
|
||||
'upload': true,
|
||||
'mimetype': '',
|
||||
};
|
||||
});
|
||||
attachments = attachments.concat(upload_attachments);
|
||||
this.set('attachment_ids', attachments);
|
||||
},
|
||||
on_attachment_loaded: function(event) {
|
||||
var self = this,
|
||||
attachments = this.get('attachment_ids'),
|
||||
files = Array.prototype.slice.call(arguments, 1);
|
||||
|
||||
_.each(files, function(file){
|
||||
if(file.error || !file.id){
|
||||
this.do_warn(file.error);
|
||||
attachments = _.filter(attachments, function (attachment) { return !attachment.upload; });
|
||||
}else{
|
||||
var attachment = _.findWhere(attachments, {filename: file.filename, upload: true});
|
||||
if(attachment){
|
||||
attachments = _.without(attachments, attachment);
|
||||
attachments.push({
|
||||
'id': file.id,
|
||||
'name': file.name || file.filename,
|
||||
'filename': file.filename,
|
||||
'mimetype': file.mimetype,
|
||||
'url': session.url('/web/content', {'id': file.id, download: true}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}.bind(this));
|
||||
this.set('attachment_ids', attachments);
|
||||
this.$attachment_button.prop('disabled', false);
|
||||
},
|
||||
on_attachment_delete: function(event){
|
||||
event.stopPropagation();
|
||||
var self = this;
|
||||
var attachment_id = $(event.target).data("id");
|
||||
if (attachment_id) {
|
||||
var attachments = [];
|
||||
_.each(this.get('attachment_ids'), function(attachment){
|
||||
if (attachment_id !== attachment.id) {
|
||||
attachments.push(attachment);
|
||||
} else {
|
||||
self.AttachmentDataSet.unlink([attachment_id]);
|
||||
}
|
||||
});
|
||||
this.set('attachment_ids', attachments);
|
||||
}
|
||||
},
|
||||
do_check_attachment_upload: function () {
|
||||
if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) {
|
||||
this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading."));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
render_attachments: function() {
|
||||
this.$attachments_list.html(QWeb.render('mail.ChatComposer.Attachments', {
|
||||
attachments: this.get('attachment_ids'),
|
||||
}));
|
||||
},
|
||||
|
||||
// Mention
|
||||
mention_fetch_throttled: function (model, method, kwargs) {
|
||||
var self = this;
|
||||
// Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing
|
||||
var def = $.Deferred();
|
||||
clearTimeout(this.mention_fetch_timer);
|
||||
this.mention_fetch_timer = setTimeout(function () {
|
||||
return self._rpc({model: model, method: method, kwargs: kwargs})
|
||||
.then(function (results) {
|
||||
def.resolve(results);
|
||||
});
|
||||
}, this.MENTION_THROTTLE);
|
||||
return def;
|
||||
},
|
||||
mention_fetch_channels: function (search) {
|
||||
return this.mention_fetch_throttled('mail.channel', 'get_mention_suggestions', {
|
||||
limit: this.options.mention_fetch_limit,
|
||||
search: search,
|
||||
}).then(function (suggestions) {
|
||||
return _.partition(suggestions, function (suggestion) {
|
||||
return _.contains(['public', 'groups'], suggestion.public);
|
||||
});
|
||||
});
|
||||
},
|
||||
mention_fetch_partners: function (search) {
|
||||
var self = this;
|
||||
return $.when(this.mention_prefetched_partners).then(function (prefetched_partners) {
|
||||
// filter prefetched partners with the given search string
|
||||
var suggestions = [];
|
||||
var limit = self.options.mention_fetch_limit;
|
||||
var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i');
|
||||
_.each(prefetched_partners, function (partners) {
|
||||
if (limit > 0) {
|
||||
var filtered_partners = _.filter(partners, function (partner) {
|
||||
return partner.email && search_regexp.test(partner.email) ||
|
||||
partner.name && search_regexp.test(utils.unaccent(partner.name));
|
||||
});
|
||||
if (filtered_partners.length) {
|
||||
suggestions.push(filtered_partners.slice(0, limit));
|
||||
limit -= filtered_partners.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!suggestions.length && !self.options.mention_partners_restricted) {
|
||||
// no result found among prefetched partners, fetch other suggestions
|
||||
suggestions = self.mention_fetch_throttled('res.partner', 'get_mention_suggestions', {
|
||||
limit: limit,
|
||||
search: search,
|
||||
});
|
||||
}
|
||||
return suggestions;
|
||||
});
|
||||
},
|
||||
mention_get_canned_responses: function (search) {
|
||||
var self = this;
|
||||
var def = $.Deferred();
|
||||
clearTimeout(this.canned_timeout);
|
||||
this.canned_timeout = setTimeout(function () {
|
||||
var cannedResponses = self.call('chat_manager', 'getCannedResponses');
|
||||
var matches = fuzzy.filter(utils.unaccent(search), _.pluck(cannedResponses, 'source'));
|
||||
var indexes = _.pluck(matches.slice(0, self.options.mention_fetch_limit), 'index');
|
||||
def.resolve(_.map(indexes, function (i) {
|
||||
return cannedResponses[i];
|
||||
}));
|
||||
}, 500);
|
||||
return def;
|
||||
},
|
||||
mention_get_commands: function (search) {
|
||||
var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i');
|
||||
return _.filter(this.mention_commands, function (command) {
|
||||
return search_regexp.test(command.name);
|
||||
}).slice(0, this.options.mention_fetch_limit);
|
||||
},
|
||||
mention_set_prefetched_partners: function (prefetched_partners) {
|
||||
this.mention_prefetched_partners = prefetched_partners;
|
||||
},
|
||||
mention_set_enabled_commands: function (commands) {
|
||||
this.mention_commands = commands;
|
||||
},
|
||||
mention_get_listener_selections: function () {
|
||||
return this.mention_manager.get_listener_selections();
|
||||
},
|
||||
|
||||
// Others
|
||||
is_empty: function () {
|
||||
return !this.$input.val().trim() && !this.$('.o_attachments').children().length;
|
||||
},
|
||||
focus: function () {
|
||||
this.$input.focus();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hides the emojis container.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_hideEmojis: function () {
|
||||
this.$emojisContainer.remove();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onAttachmentDownload: function (event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onAttachmentView: function (event) {
|
||||
event.stopPropagation();
|
||||
var activeAttachmentID = $(event.currentTarget).data('id');
|
||||
var attachments = this.get('attachment_ids');
|
||||
if (activeAttachmentID) {
|
||||
var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID);
|
||||
attachmentViewer.appendTo($('body'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when the emoji button is clicked -> opens/hides the emoji panel.
|
||||
* Also, this method is in charge of the rendering of this panel the first
|
||||
* time it is opened.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmojiButtonClick: function () {
|
||||
if (!this.$emojisContainer) { // lazy rendering
|
||||
this.$emojisContainer = $(QWeb.render('mail.ChatComposer.emojis', {
|
||||
emojis: this.call('chat_manager', 'getEmojis'),
|
||||
}));
|
||||
}
|
||||
if (this.$emojisContainer.parent().length) {
|
||||
this._hideEmojis();
|
||||
} else {
|
||||
this.$emojisContainer.appendTo(this.$('.o_composer'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when the emoji button is blurred -> closes the emoji panel. The
|
||||
* closing is scheduled to be done at the end of the current execution
|
||||
* stack to allow stoping this closing if the button was focusout to select
|
||||
* an emoji (for example).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmojiButtonFocusout: function () {
|
||||
if (this.$emojisContainer) {
|
||||
this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when an emoji is focused -> @see _onEmojiButtonFocusout
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmojiImageFocus: function () {
|
||||
clearTimeout(this._hideEmojisTimeout);
|
||||
},
|
||||
/**
|
||||
* Called when an emoji is clicked -> adds it in the <input/>, focuses the
|
||||
* <input/> and closes the emoji panel.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onEmojiImageClick: function (ev) {
|
||||
this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " ");
|
||||
this.$input.focus();
|
||||
this._hideEmojis();
|
||||
},
|
||||
});
|
||||
|
||||
var ExtendedComposer = BasicComposer.extend({
|
||||
init: function (parent, options) {
|
||||
options = _.defaults(options || {}, {
|
||||
input_min_height: 120,
|
||||
});
|
||||
this._super(parent, options);
|
||||
this.extended = true;
|
||||
},
|
||||
|
||||
start: function () {
|
||||
this.$subject_input = this.$(".o_composer_subject input");
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
preprocess_message: function () {
|
||||
var self = this;
|
||||
return this._super().then(function (message) {
|
||||
var subject = self.$subject_input.val();
|
||||
self.$subject_input.val("");
|
||||
message.subject = subject;
|
||||
return message;
|
||||
});
|
||||
},
|
||||
clear_composer: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.$subject_input.val('');
|
||||
},
|
||||
getState: function () {
|
||||
var state = this._super.apply(this, arguments);
|
||||
state.subject = this.$subject_input.val();
|
||||
return state;
|
||||
},
|
||||
should_send: function () {
|
||||
return false;
|
||||
},
|
||||
focus: function (target) {
|
||||
if (target === 'body') {
|
||||
this.$input.focus();
|
||||
} else {
|
||||
this.$subject_input.focus();
|
||||
}
|
||||
},
|
||||
set_subject: function(subject) {
|
||||
this.$('.o_composer_subject input').val(subject);
|
||||
},
|
||||
setState: function (state) {
|
||||
this._super.apply(this, arguments);
|
||||
this.set_subject(state.subject);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
BasicComposer: BasicComposer,
|
||||
ExtendedComposer: ExtendedComposer,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -0,0 +1,640 @@
|
||||
odoo.define('mail.composer.Basic', function (require) {
|
||||
"use strict";
|
||||
|
||||
var emojis = require('mail.emojis');
|
||||
var MentionManager = require('mail.composer.MentionManager');
|
||||
var DocumentViewer = require('mail.DocumentViewer');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var data = require('web.data');
|
||||
var dom = require('web.dom');
|
||||
var session = require('web.session');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var BasicComposer = Widget.extend({
|
||||
template: "mail.Composer",
|
||||
events: {
|
||||
'change input.o_input_file': '_onAttachmentChange',
|
||||
'click .o_attachment_delete': '_onAttachmentDelete',
|
||||
'click .o_attachment_download': '_onAttachmentDownload',
|
||||
'click .o_attachment_view': '_onAttachmentView',
|
||||
'click .o_composer_button_add_attachment': '_onClickAddAttachment',
|
||||
'click .o_composer_button_emoji': '_onEmojiButtonClick',
|
||||
'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
|
||||
'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
|
||||
'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
|
||||
'keydown .o_composer_input textarea': '_onKeydown',
|
||||
'keyup .o_composer_input': '_onKeyup',
|
||||
'click .o_composer_button_send': '_sendMessage',
|
||||
},
|
||||
// RPCs done to fetch the mention suggestions are throttled with the
|
||||
// following value
|
||||
MENTION_THROTTLE: 200,
|
||||
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
this.options = _.defaults(options || {}, {
|
||||
commandsEnabled: true,
|
||||
context: {},
|
||||
inputMinHeight: 28,
|
||||
mentionFetchLimit: 8,
|
||||
// set to true to only suggest prefetched partners
|
||||
mentionPartnersRestricted: false,
|
||||
sendText: _t("Send"),
|
||||
defaultBody: '',
|
||||
defaultMentionSelections: {},
|
||||
isMobile: config.device.isMobile
|
||||
});
|
||||
this.context = this.options.context;
|
||||
|
||||
// Attachments
|
||||
this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
|
||||
this.fileuploadID = _.uniqueId('o_chat_fileupload');
|
||||
this.set('attachment_ids', []);
|
||||
|
||||
// Mention
|
||||
this._mentionManager = new MentionManager(this);
|
||||
this._mentionManager.register({
|
||||
delimiter: '@',
|
||||
fetchCallback: this._mentionFetchPartners.bind(this),
|
||||
generateLinks: true,
|
||||
model: 'res.partner',
|
||||
redirectClassname: 'o_mail_redirect',
|
||||
selection: this.options.defaultMentionSelections['@'],
|
||||
suggestionTemplate: 'mail.MentionSuggestions.Partner',
|
||||
});
|
||||
this._mentionManager.register({
|
||||
delimiter: '#',
|
||||
fetchCallback: this._mentionFetchChannels.bind(this),
|
||||
generateLinks: true,
|
||||
model: 'mail.channel',
|
||||
redirectClassname: 'o_channel_redirect',
|
||||
selection: this.options.defaultMentionSelections['#'],
|
||||
suggestionTemplate: 'mail.MentionSuggestions.Channel',
|
||||
});
|
||||
this._mentionManager.register({
|
||||
delimiter: ':',
|
||||
fetchCallback: this._mentionGetCannedResponses.bind(this),
|
||||
selection: this.options.defaultMentionSelections[':'],
|
||||
suggestionTemplate: 'mail.MentionSuggestions.CannedResponse',
|
||||
});
|
||||
if (this.options.commandsEnabled) {
|
||||
this._mentionManager.register({
|
||||
beginningOnly: true,
|
||||
delimiter: '/',
|
||||
fetchCallback: this._mentionGetCommands.bind(this),
|
||||
selection: this.options.defaultMentionSelections['/'],
|
||||
suggestionTemplate: 'mail.MentionSuggestions.Command',
|
||||
});
|
||||
}
|
||||
|
||||
this.isMini = options.isMini;
|
||||
|
||||
this.avatarURL = session.uid > 0 ? session.url('/web/image', {
|
||||
model: 'res.users',
|
||||
field: 'image_small',
|
||||
id: session.uid,
|
||||
}) : '/web/static/src/img/user_menu_avatar.png';
|
||||
},
|
||||
start: function () {
|
||||
var self = this;
|
||||
|
||||
this._$attachmentButton = this.$('.o_composer_button_add_attachment');
|
||||
this._$attachmentsList = this.$('.o_composer_attachments_list');
|
||||
this.$input = this.$('.o_composer_input textarea');
|
||||
this.$input.focus(function () {
|
||||
self.trigger('input_focused');
|
||||
});
|
||||
this.$input.val(this.options.defaultBody);
|
||||
dom.autoresize(this.$input, {
|
||||
parent: this,
|
||||
min_height: this.options.inputMinHeight
|
||||
});
|
||||
|
||||
// Attachments
|
||||
$(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this));
|
||||
this.on('change:attachment_ids', this, this._renderAttachments);
|
||||
|
||||
// Mention
|
||||
this._mentionManager.prependTo(this.$('.o_composer'));
|
||||
|
||||
return this._super();
|
||||
},
|
||||
destroy: function () {
|
||||
$(window).off(this.fileuploadID);
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Empty input, selected partners and attachments
|
||||
*/
|
||||
clearComposer: function () {
|
||||
this.$input.val('');
|
||||
this._mentionManager.resetSelections();
|
||||
this.set('attachment_ids', []);
|
||||
},
|
||||
/**
|
||||
* Set the focus on the input
|
||||
*/
|
||||
focus: function () {
|
||||
this.$input.focus();
|
||||
},
|
||||
/**
|
||||
* Get the state of the composer.
|
||||
*
|
||||
* This is useful in order to (re)store its state when switchng to another
|
||||
* thread with a composer, and coming back to the thread with this composer.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
getState: function () {
|
||||
return {
|
||||
attachments: this.get('attachment_ids'),
|
||||
text: this.$input.val(),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @returns {integer} it returns length of attachment and trim content of
|
||||
* input box to check for empty.
|
||||
*/
|
||||
isEmpty: function () {
|
||||
return !this.$input.val().trim() &&
|
||||
!this.$('.o_attachments').children().length;
|
||||
},
|
||||
/**
|
||||
* Set the state of the composer.
|
||||
*
|
||||
* This is useful in order to (re)store its state when switching to another
|
||||
* thread with a composer, and coming back to the thread with this composer.
|
||||
*
|
||||
* @param {Object} state
|
||||
* @param {Array} state.attachments
|
||||
* @param {string} state.text
|
||||
*/
|
||||
setState: function (state) {
|
||||
this.set('attachment_ids', state.attachments);
|
||||
this.$input.val(state.text);
|
||||
},
|
||||
/**
|
||||
* Set the list of command suggestions on the thread.
|
||||
*
|
||||
* When the user partially types a command, matching commands are displayed
|
||||
* to the user (@see _mentionGetCommands).
|
||||
*
|
||||
* @param {Object[]} commands
|
||||
*/
|
||||
mentionSetCommands: function (commands) {
|
||||
this._mentionCommands = commands;
|
||||
},
|
||||
/**
|
||||
* Set the list of partner mentions suggestions that has been prefetched.
|
||||
*
|
||||
* When the user partially types a mention, matching prefetched partners are
|
||||
* displayed to the user. If none of them match, then it will fetch for more
|
||||
* partner suggestions (@see _mentionFetchPartners).
|
||||
*
|
||||
* @param {$.Deferred<Object[]>} prefetchedPartners
|
||||
*/
|
||||
mentionSetPrefetchedPartners: function (prefetchedPartners) {
|
||||
this._mentionPrefetchedPartners = prefetchedPartners;
|
||||
},
|
||||
/**
|
||||
* @returns {Object} containing listener selections, grouped by delimiter
|
||||
* as key of the object.
|
||||
*/
|
||||
getMentionListenerSelections: function () {
|
||||
return this._mentionManager.getListenerSelections();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_clearComposerOnSend: function () {
|
||||
this.clearComposer();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_doCheckAttachmentUpload: function () {
|
||||
if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) {
|
||||
this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading."));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Hides the emojis container.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_hideEmojis: function () {
|
||||
this._$emojisContainer.remove();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} search
|
||||
* @returns {$.Deferred<Object[]>}
|
||||
*/
|
||||
_mentionGetCannedResponses: function (search) {
|
||||
var self = this;
|
||||
var def = $.Deferred();
|
||||
clearTimeout(this._cannedTimeout);
|
||||
this._cannedTimeout = setTimeout(function () {
|
||||
var cannedResponses = self.call('mail_service', 'getCannedResponses');
|
||||
var matches = fuzzy.filter(mailUtils.unaccent(search), _.pluck(cannedResponses, 'source'));
|
||||
var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index');
|
||||
def.resolve(_.map(indexes, function (index) {
|
||||
return cannedResponses[index];
|
||||
}));
|
||||
}, 500);
|
||||
return def;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} search
|
||||
* @returns {Array}
|
||||
*/
|
||||
_mentionGetCommands: function (search) {
|
||||
var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
|
||||
return _.filter(this._mentionCommands, function (command) {
|
||||
return searchRegexp.test(command.name);
|
||||
}).slice(0, this.options.mentionFetchLimit);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} search
|
||||
*/
|
||||
_mentionFetchChannels: function (search) {
|
||||
return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', {
|
||||
limit: this.options.mentionFetchLimit,
|
||||
search: search,
|
||||
}).then(function (suggestions) {
|
||||
return _.partition(suggestions, function (suggestion) {
|
||||
return _.contains(['public', 'groups'], suggestion.public);
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} search
|
||||
*/
|
||||
_mentionFetchPartners: function (search) {
|
||||
var self = this;
|
||||
return $.when(this._mentionPrefetchedPartners).then(function (partners) {
|
||||
// filter prefetched partners with the given search string
|
||||
var suggestions = [];
|
||||
var limit = self.options.mentionFetchLimit;
|
||||
var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
|
||||
if (limit > 0) {
|
||||
var filteredPartners = _.filter(partners, function (partner) {
|
||||
return partner.email && searchRegexp.test(partner.email) ||
|
||||
partner.name && searchRegexp.test(mailUtils.unaccent(partner.name));
|
||||
});
|
||||
if (filteredPartners.length) {
|
||||
suggestions.push(filteredPartners.slice(0, limit));
|
||||
limit -= filteredPartners.length;
|
||||
}
|
||||
}
|
||||
if (!suggestions.length && !self.options.mentionPartnersRestricted) {
|
||||
// no result found among prefetched partners, fetch other suggestions
|
||||
suggestions = self._mentionFetchThrottled(
|
||||
'res.partner',
|
||||
'get_mention_suggestions',
|
||||
{ limit: limit, search: search }
|
||||
);
|
||||
}
|
||||
return suggestions;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} model
|
||||
* @param {string} method
|
||||
* @param {Object} kwargs
|
||||
* @return {$.Deferred}
|
||||
*/
|
||||
_mentionFetchThrottled: function (model, method, kwargs) {
|
||||
var self = this;
|
||||
// Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing
|
||||
var def = $.Deferred();
|
||||
clearTimeout(this.mentionFetchTimer);
|
||||
this.mentionFetchTimer = setTimeout(function () {
|
||||
return self._rpc({model: model, method: method, kwargs: kwargs})
|
||||
.then(function (results) {
|
||||
def.resolve(results);
|
||||
});
|
||||
}, this.MENTION_THROTTLE);
|
||||
return def;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {$.Deferred}
|
||||
*/
|
||||
_preprocessMessage: function () {
|
||||
// Return a deferred as this function is extended with asynchronous
|
||||
// behavior for the chatter composer
|
||||
|
||||
//Removing unwanted extra spaces from message
|
||||
var value = _.escape(this.$input.val()).trim();
|
||||
value = value.replace(/(\r|\n){2,}/g, '<br/><br/>');
|
||||
value = value.replace(/(\r|\n)/g, '<br/>');
|
||||
|
||||
// prevent html space collapsing
|
||||
value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2');
|
||||
var commands = this.options.commandsEnabled ?
|
||||
this._mentionManager.getListenerSelection('/') :
|
||||
[];
|
||||
return $.when({
|
||||
content: this._mentionManager.generateLinks(value),
|
||||
attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
|
||||
partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')),
|
||||
command: commands.length > 0 ? commands[0].name : undefined,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderAttachments: function () {
|
||||
this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', {
|
||||
attachments: this.get('attachment_ids'),
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_sendMessage: function () {
|
||||
if (this.isEmpty() || !this._doCheckAttachmentUpload()) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this._cannedTimeout);
|
||||
var self = this;
|
||||
this._preprocessMessage().then(function (message) {
|
||||
self.trigger('post_message', message);
|
||||
self._clearComposerOnSend();
|
||||
self.$input.focus();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Send the message on ENTER, but go to new line on SHIFT+ENTER
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_shouldSend: function (ev) {
|
||||
return !ev.shiftKey;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {jQuery.Event} ev
|
||||
*/
|
||||
_onAttachmentChange: function (ev) {
|
||||
var self = this;
|
||||
var files = ev.target.files;
|
||||
var attachments = this.get('attachment_ids');
|
||||
|
||||
_.each(files, function (file){
|
||||
var attachment = _.findWhere(attachments, {name: file.name});
|
||||
// if the files already exits, delete the file before upload
|
||||
if (attachment){
|
||||
self._attachmentDataSet.unlink([attachment.id]);
|
||||
attachments = _.without(attachments, attachment);
|
||||
}
|
||||
});
|
||||
|
||||
this.$('form.o_form_binary_form').submit();
|
||||
this._$attachmentButton.prop('disabled', true);
|
||||
var uploadAttachments = _.map(files, function (file){
|
||||
return {
|
||||
id: 0,
|
||||
name: file.name,
|
||||
filename: file.name,
|
||||
url: '',
|
||||
upload: true,
|
||||
mimetype: '',
|
||||
};
|
||||
});
|
||||
attachments = attachments.concat(uploadAttachments);
|
||||
this.set('attachment_ids', attachments);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAttachmentDelete: function (ev){
|
||||
ev.stopPropagation();
|
||||
var self = this;
|
||||
var attachmentID = $(ev.target).data('id');
|
||||
if (attachmentID) {
|
||||
var attachments = [];
|
||||
_.each(this.get('attachment_ids'), function (attachment){
|
||||
if (attachmentID !== attachment.id) {
|
||||
attachments.push(attachment);
|
||||
} else {
|
||||
self._attachmentDataSet.unlink([attachmentID]);
|
||||
}
|
||||
});
|
||||
this.set('attachment_ids', attachments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAttachmentDownload: function (ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onAttachmentLoaded: function () {
|
||||
var attachments = this.get('attachment_ids');
|
||||
var files = Array.prototype.slice.call(arguments, 1);
|
||||
|
||||
_.each(files, function (file) {
|
||||
if (file.error || !file.id){
|
||||
this.do_warn(file.error);
|
||||
attachments = _.filter(attachments, function (attachment) {
|
||||
return !attachment.upload;
|
||||
});
|
||||
} else {
|
||||
var attachment = _.findWhere(attachments, { filename: file.filename, upload: true });
|
||||
if (attachment) {
|
||||
attachments = _.without(attachments, attachment);
|
||||
attachments.push({
|
||||
id: file.id,
|
||||
name: file.name || file.filename,
|
||||
filename: file.filename,
|
||||
mimetype: file.mimetype,
|
||||
url: session.url('/web/content', { id: file.id, download: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}.bind(this));
|
||||
this.set('attachment_ids', attachments);
|
||||
this._$attachmentButton.prop('disabled', false);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAttachmentView: function (ev) {
|
||||
var activeAttachmentID = $(ev.currentTarget).data('id');
|
||||
var attachments = this.get('attachment_ids');
|
||||
if (activeAttachmentID) {
|
||||
var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID);
|
||||
attachmentViewer.appendTo($('body'));
|
||||
}
|
||||
},
|
||||
_onClickAddAttachment: function () {
|
||||
this.$('input.o_input_file').click();
|
||||
this.$input.focus();
|
||||
},
|
||||
/**
|
||||
* Called when the emoji button is clicked -> opens/hides the emoji panel.
|
||||
* Also, this method is in charge of the rendering of this panel the first
|
||||
* time it is opened.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmojiButtonClick: function () {
|
||||
if (!this._$emojisContainer) { // lazy rendering
|
||||
this._$emojisContainer = $(QWeb.render('mail.Composer.emojis', {
|
||||
emojis: emojis,
|
||||
}));
|
||||
}
|
||||
if (this._$emojisContainer.parent().length) {
|
||||
this._hideEmojis();
|
||||
} else {
|
||||
this._$emojisContainer.appendTo(this.$('.o_composer'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when the emoji button is blurred -> closes the emoji panel. The
|
||||
* closing is scheduled to be done at the end of the current execution
|
||||
* stack to allow stoping this closing if the button was focusout to select
|
||||
* an emoji (for example).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmojiButtonFocusout: function () {
|
||||
if (this._$emojisContainer) {
|
||||
this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when an emoji is clicked -> adds it in the <input/>, focuses the
|
||||
* <input/> and closes the emoji panel.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onEmojiImageClick: function (ev) {
|
||||
this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " ");
|
||||
this.$input.focus();
|
||||
this._hideEmojis();
|
||||
},
|
||||
/**
|
||||
* Called when an emoji is focused -> @see _onEmojiButtonFocusout
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEmojiImageFocus: function () {
|
||||
clearTimeout(this._hideEmojisTimeout);
|
||||
},
|
||||
/**
|
||||
* _onKeydown event is triggered when is key is pressed
|
||||
* - on UP and DOWN arrow is pressed then event prevents it's default
|
||||
* behaviour if mention manager is open else it break it.
|
||||
* - on ENTER key is pressed and mention manager is open then event
|
||||
* prevents it's default behaviour else check if ControlKey is
|
||||
* pressed or not if yes then it send a message
|
||||
*
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown: function (ev) {
|
||||
switch (ev.which) {
|
||||
// UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions
|
||||
case $.ui.keyCode.UP:
|
||||
case $.ui.keyCode.DOWN:
|
||||
case $.ui.keyCode.TAB:
|
||||
if (this._mentionManager.isOpen()) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
break;
|
||||
// ENTER: submit the message only if the dropdown mention proposition is not displayed
|
||||
case $.ui.keyCode.ENTER:
|
||||
if (this._mentionManager.isOpen()) {
|
||||
ev.preventDefault();
|
||||
} else {
|
||||
var sendMessage = ev.ctrlKey || this._shouldSend(ev);
|
||||
if (sendMessage) {
|
||||
ev.preventDefault();
|
||||
this._sendMessage();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* _onKeyup event is triggered when key is released.
|
||||
* on ESCAP key it close the mention suggestion dropdown menu.
|
||||
* on ENTER key it selects that mention and add to a content editable div.
|
||||
* on UP or DOWN key it highlights previous or next mention respectively.
|
||||
*
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeyup: function (ev) {
|
||||
switch (ev.which) {
|
||||
// ESCAPED KEYS: do nothing
|
||||
case $.ui.keyCode.END:
|
||||
case $.ui.keyCode.PAGE_UP:
|
||||
case $.ui.keyCode.PAGE_DOWN:
|
||||
break;
|
||||
// ESCAPE: close mention propositions
|
||||
case $.ui.keyCode.ESCAPE:
|
||||
if (this._mentionManager.isOpen()) {
|
||||
ev.stopPropagation();
|
||||
this._mentionManager.resetSuggestions();
|
||||
} else {
|
||||
this.trigger_up('escape_pressed');
|
||||
}
|
||||
break;
|
||||
// ENTER, UP, DOWN, TAB: check if navigation in mention propositions
|
||||
case $.ui.keyCode.ENTER:
|
||||
case $.ui.keyCode.UP:
|
||||
case $.ui.keyCode.DOWN:
|
||||
case $.ui.keyCode.TAB:
|
||||
if (this._mentionManager.isOpen()) {
|
||||
this._mentionManager.propositionNavigation(ev.which);
|
||||
}
|
||||
break;
|
||||
// Otherwise, check if a mention is typed
|
||||
default:
|
||||
this._mentionManager.detectDelimiter();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return BasicComposer;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
odoo.define('mail.composer.Chatter', function (require) {
|
||||
"use strict";
|
||||
|
||||
var BasicComposer = require('mail.composer.Basic');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var core = require('web.core');
|
||||
var viewDialogs = require('web.view_dialogs');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* Chat Composer for the Chatter
|
||||
*
|
||||
* Extends the basic Composer Widget to add 'suggested partner' layer (open
|
||||
* popup when suggested partner is selected without email, or other
|
||||
* informations), and the button to open the full composer wizard.
|
||||
*/
|
||||
var ChatterComposer = BasicComposer.extend({
|
||||
template: 'mail.chatter.Composer',
|
||||
events: _.extend({}, BasicComposer.prototype.events, {
|
||||
'click .o_composer_button_full_composer': '_onOpenFullComposer',
|
||||
}),
|
||||
init: function (parent, model, suggestedPartners, options) {
|
||||
this._super(parent, options);
|
||||
this._model = model;
|
||||
this.suggestedPartners = suggestedPartners;
|
||||
this.options = _.defaults(this.options, {
|
||||
display_mode: 'textarea',
|
||||
recordName: false,
|
||||
isLog: false,
|
||||
});
|
||||
if (this.options.isLog) {
|
||||
this.options.sendText = _t("Log");
|
||||
}
|
||||
this.notInline = true;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Chatter don't clear message on sent but after successful sent
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
clearComposerOnSend: function () {},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check the additional partners (not necessary registered partners), and
|
||||
* open a popup form view for the ones who informations is missing.
|
||||
*
|
||||
* @private
|
||||
* @param {Array} checkedSuggestedPartners list of 'recipient' partners to
|
||||
* complete informations or validate
|
||||
* @returns {Deferred} resolved with the list of checked suggested partners
|
||||
* (real partner)
|
||||
**/
|
||||
_checkSuggestedPartners: function (checkedSuggestedPartners) {
|
||||
var self = this;
|
||||
var checkDone = $.Deferred();
|
||||
|
||||
var recipients = _.filter(checkedSuggestedPartners, function (recipient) {
|
||||
return recipient.checked;
|
||||
});
|
||||
var recipientsToFind = _.filter(recipients, function (recipient) {
|
||||
return (! recipient.partner_id);
|
||||
});
|
||||
var namesToFind = _.pluck(recipientsToFind, 'full_name');
|
||||
var recipientsToCheck = _.filter(recipients, function (recipient) {
|
||||
return (recipient.partner_id && ! recipient.email_address);
|
||||
});
|
||||
var recipientIDs = _.pluck(_.filter(recipients, function (recipient) {
|
||||
return recipient.partner_id && recipient.email_address;
|
||||
}), 'partner_id');
|
||||
|
||||
var namesToRemove = [];
|
||||
var recipientIDsToRemove = [];
|
||||
|
||||
// have unknown names
|
||||
// -> call message_get_partner_info_from_emails to try to find
|
||||
// partner_id
|
||||
var def;
|
||||
if (namesToFind.length > 0) {
|
||||
def = this._rpc({
|
||||
model: this._model,
|
||||
method: 'message_partner_info_from_emails',
|
||||
args: [[this.context.default_res_id], namesToFind],
|
||||
});
|
||||
}
|
||||
|
||||
// for unknown names + incomplete partners
|
||||
// -> open popup - cancel = remove from recipients
|
||||
$.when(def).pipe(function (result) {
|
||||
result = result || [];
|
||||
var emailDefs = [];
|
||||
var recipientPopups = result.concat(recipientsToCheck);
|
||||
|
||||
_.each(recipientPopups, function (partnerInfo) {
|
||||
var deferred = $.Deferred();
|
||||
emailDefs.push(deferred);
|
||||
|
||||
var partnerName = partnerInfo.full_name;
|
||||
var partnerID = partnerInfo.partner_id;
|
||||
var parsedEmail = mailUtils.parseEmail(partnerName);
|
||||
|
||||
var dialog = new viewDialogs.FormViewDialog(self, {
|
||||
res_model: 'res.partner',
|
||||
res_id: partnerID,
|
||||
context: {
|
||||
active_model: self._model,
|
||||
active_id: self.context.default_res_id,
|
||||
force_email: true,
|
||||
ref: 'compound_context',
|
||||
default_name: parsedEmail[0],
|
||||
default_email: parsedEmail[1],
|
||||
},
|
||||
title: _t("Please complete customer's informations"),
|
||||
disable_multiple_selection: true,
|
||||
}).open();
|
||||
dialog.on('closed', self, function () {
|
||||
deferred.resolve();
|
||||
});
|
||||
dialog.opened().then(function () {
|
||||
dialog.form_view.on('on_button_cancel', self, function () {
|
||||
namesToRemove.push(partnerName);
|
||||
if (partnerID) {
|
||||
recipientIDsToRemove.push(partnerID);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
$.when.apply($, emailDefs).then(function () {
|
||||
var newNamesToFind = _.difference(namesToFind, namesToRemove);
|
||||
var def;
|
||||
if (newNamesToFind.length > 0) {
|
||||
def = self._rpc({
|
||||
model: self._model,
|
||||
method: 'message_partner_info_from_emails',
|
||||
args: [[self.context.default_res_id], newNamesToFind, true],
|
||||
});
|
||||
}
|
||||
$.when(def).pipe(function (result) {
|
||||
result = result || [];
|
||||
var recipientPopups = result.concat(recipientsToCheck);
|
||||
_.each(recipientPopups, function (partnerInfo) {
|
||||
if (
|
||||
partnerInfo.partner_id &&
|
||||
_.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1
|
||||
) {
|
||||
recipientIDs.push(partnerInfo.partner_id);
|
||||
}
|
||||
});
|
||||
}).pipe(function () {
|
||||
checkDone.resolve(recipientIDs);
|
||||
});
|
||||
});
|
||||
});
|
||||
return checkDone;
|
||||
},
|
||||
/**
|
||||
* Get the list of selected suggested partners
|
||||
*
|
||||
* @private
|
||||
* @returns {Array} list of 'recipient' selected partners (may not be
|
||||
* created in db)
|
||||
**/
|
||||
_getCheckedSuggestedPartners: function () {
|
||||
var self = this;
|
||||
var checkedPartners = [];
|
||||
this.$('.o_composer_suggested_partners input:checked').each(function () {
|
||||
var fullName = $(this).data('fullname');
|
||||
checkedPartners = checkedPartners.concat(
|
||||
_.filter(self.suggestedPartners, function (item) {
|
||||
return fullName === item.full_name;
|
||||
})
|
||||
);
|
||||
});
|
||||
return checkedPartners;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @returns {$.Deferred}
|
||||
*/
|
||||
_preprocessMessage: function () {
|
||||
var self = this;
|
||||
var def = $.Deferred();
|
||||
this._super().then(function (message) {
|
||||
message = _.extend(message, {
|
||||
subtype: 'mail.mt_comment',
|
||||
message_type: 'comment',
|
||||
context: self.context,
|
||||
});
|
||||
|
||||
// Subtype
|
||||
if (self.options.isLog) {
|
||||
message.subtype = 'mail.mt_note';
|
||||
}
|
||||
|
||||
// Partner_ids
|
||||
if (!self.options.isLog) {
|
||||
var checkedSuggestedPartners = self._getCheckedSuggestedPartners();
|
||||
self._checkSuggestedPartners(checkedSuggestedPartners).done(function (partnerIDs) {
|
||||
message.partner_ids = (message.partner_ids || []).concat(partnerIDs);
|
||||
// update context
|
||||
message.context = _.defaults({}, message.context, {
|
||||
mail_post_autofollow: true,
|
||||
});
|
||||
def.resolve(message);
|
||||
});
|
||||
} else {
|
||||
def.resolve(message);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return def;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_shouldSend: function () {
|
||||
return false;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onOpenFullComposer: function () {
|
||||
if (!this._doCheckAttachmentUpload()){
|
||||
return false;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var recipientDoneDef = $.Deferred();
|
||||
|
||||
// any operation on the full-composer will reload the record, so
|
||||
// warn the user that any unsaved changes on the record will be lost.
|
||||
this.trigger_up('discard_record_changes', {
|
||||
proceed: function () {
|
||||
if (self.options.isLog) {
|
||||
recipientDoneDef.resolve([]);
|
||||
} else {
|
||||
var checkedSuggestedPartners = self._getCheckedSuggestedPartners();
|
||||
self._checkSuggestedPartners(checkedSuggestedPartners)
|
||||
.then(recipientDoneDef.resolve.bind(recipientDoneDef));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
recipientDoneDef.then(function (partnerIDs) {
|
||||
var context = {
|
||||
default_parent_id: self.id,
|
||||
default_body: mailUtils.getTextToHTML(self.$input.val()),
|
||||
default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'),
|
||||
default_partner_ids: partnerIDs,
|
||||
default_is_log: self.options.isLog,
|
||||
mail_post_autofollow: true,
|
||||
};
|
||||
|
||||
if (self.context.default_model && self.context.default_res_id) {
|
||||
context.default_model = self.context.default_model;
|
||||
context.default_res_id = self.context.default_res_id;
|
||||
}
|
||||
|
||||
var action = {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.compose.message',
|
||||
view_mode: 'form',
|
||||
view_type: 'form',
|
||||
views: [[false, 'form']],
|
||||
target: 'new',
|
||||
context: context,
|
||||
};
|
||||
self.do_action(action, {
|
||||
on_close: self.trigger.bind(self, 'need_refresh'),
|
||||
}).then(self.trigger.bind(self, 'close_composer'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ChatterComposer;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
odoo.define('mail.composer.Extended', function (require) {
|
||||
"use strict";
|
||||
|
||||
var BasicComposer = require('mail.composer.Basic');
|
||||
|
||||
var ExtendedComposer = BasicComposer.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
options = _.defaults(options || {}, {
|
||||
inputMinHeight: 120,
|
||||
});
|
||||
this._super(parent, options);
|
||||
this.extended = true;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._$subjectInput = this.$('.o_composer_subject input');
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
clearComposer: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._$subjectInput.val('');
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {string} target
|
||||
*/
|
||||
focus: function (target) {
|
||||
if (target === 'body') {
|
||||
this.$input.focus();
|
||||
} else {
|
||||
this._$subjectInput.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getState: function () {
|
||||
var state = this._super.apply(this, arguments);
|
||||
state.subject = this._$subjectInput.val();
|
||||
return state;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} state
|
||||
* @param {string} state.subject
|
||||
*/
|
||||
setState: function (state) {
|
||||
this._super.apply(this, arguments);
|
||||
this.setSubject(state.subject);
|
||||
},
|
||||
/**
|
||||
* @param {string} subject
|
||||
*/
|
||||
setSubject: function (subject) {
|
||||
this.$('.o_composer_subject input').val(subject);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @returns {$.Deferred}
|
||||
*/
|
||||
_preprocessMessage: function () {
|
||||
var self = this;
|
||||
return this._super().then(function (message) {
|
||||
var subject = self._$subjectInput.val();
|
||||
self._$subjectInput.val("");
|
||||
message.subject = subject;
|
||||
return message;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_shouldSend: function () {
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
return ExtendedComposer;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
odoo.define('mail.composer.MentionManager', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var dom = require('web.dom');
|
||||
var session = require('web.session');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var NON_BREAKING_SPACE = '\u00a0';
|
||||
|
||||
// The MentionManager allows the Composer to register listeners. For each
|
||||
// listener, it detects if the user is currently typing a mention (starting by a
|
||||
// given delimiter). If so, if fetches mention suggestions and renders them. On
|
||||
// suggestion clicked, it updates the selection for the corresponding listener.
|
||||
var MentionManager = Widget.extend({
|
||||
className: 'dropup o_composer_mention_dropdown',
|
||||
events: {
|
||||
'click .o_mention_proposition': '_onClickMentionItem',
|
||||
'mouseover .o_mention_proposition': '_onHoverMentionProposition',
|
||||
},
|
||||
init: function (parent) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._composer = parent;
|
||||
|
||||
this._open = false;
|
||||
this._listeners = [];
|
||||
this.set('mention_suggestions', []);
|
||||
this.on('change:mention_suggestions', this, this._renderSuggestions);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if the mention suggestions dropdown is open, false otherwise
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpen: function () {
|
||||
return this._open;
|
||||
},
|
||||
/**
|
||||
* Returns the mentions of the given listener that haven't been erased from
|
||||
* the composer's input
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
getListenerSelection: function (delimiter) {
|
||||
var listener = _.findWhere(this._listeners, { delimiter: delimiter });
|
||||
if (listener) {
|
||||
var inputMentions = this._composer
|
||||
.$input
|
||||
.val()
|
||||
.match(new RegExp(delimiter+'[^ ]+(?= | )', 'g'));
|
||||
return this._validateSelection(listener.selection, inputMentions);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
/**
|
||||
* @returns {Object} containing listener selections, grouped by delimiter
|
||||
* as key of the object.
|
||||
*/
|
||||
getListenerSelections: function () {
|
||||
var selections = {};
|
||||
_.each(this._listeners, function (listener) {
|
||||
selections[listener.delimiter] = listener.selection;
|
||||
});
|
||||
return selections;
|
||||
},
|
||||
/**
|
||||
* Detects if the user is currently typing a mention word
|
||||
*/
|
||||
detectDelimiter: function () {
|
||||
var self = this;
|
||||
var textVal = this._composer.$input.val();
|
||||
var cursorPosition = this._getSelectionPositions().start;
|
||||
var leftString = textVal.substring(0, cursorPosition);
|
||||
|
||||
function validateKeyword(delimiter, beginningOnly) {
|
||||
// use position before delimiter because there should be whitespaces
|
||||
// or line feed/carriage return before the delimiter
|
||||
var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1;
|
||||
if (beginningOnly && beforeDelimiterPosition > 0) {
|
||||
return false;
|
||||
}
|
||||
var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition);
|
||||
// regex string start with delimiter or whitespace then delimiter
|
||||
var pattern = "^"+delimiter+"|^\\s"+delimiter;
|
||||
var regexStart = new RegExp(pattern, 'g');
|
||||
// trim any left whitespaces or the left line feed/ carriage return
|
||||
// at the beginning of the string
|
||||
searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, '');
|
||||
if (regexStart.test(searchStr) && searchStr.length) {
|
||||
searchStr = searchStr.replace(pattern, '');
|
||||
return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ?
|
||||
searchStr.replace(delimiter, '') :
|
||||
false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
this._activeListener = undefined;
|
||||
for (var i = 0; i < this._listeners.length; i++) {
|
||||
var listener = this._listeners[i];
|
||||
this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly);
|
||||
|
||||
if (this._mentionWord !== false) {
|
||||
this._activeListener = listener;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._activeListener) {
|
||||
var mentionWord = this._mentionWord;
|
||||
$.when(this._activeListener.fetchCallback(mentionWord))
|
||||
.then(function (suggestions) {
|
||||
if (mentionWord === self._mentionWord) {
|
||||
// update suggestions only if mentionWord didn't change
|
||||
// in the meantime
|
||||
self.set('mention_suggestions', suggestions);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.set('mention_suggestions', []); // close the dropdown
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Replaces mentions appearing in the string 's' by html links with proper
|
||||
* redirection
|
||||
*
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
generateLinks: function (s) {
|
||||
var self = this;
|
||||
var baseHREF = session.url('/web');
|
||||
var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'";
|
||||
var mentionLink = "<a " + linkParams + " >" +
|
||||
"%s%s" +
|
||||
"</a>";
|
||||
_.each(this._listeners, function (listener) {
|
||||
if (!listener.generateLinks) {
|
||||
return;
|
||||
}
|
||||
var selection = listener.selection;
|
||||
if (selection.length) {
|
||||
var matches = self._getMatch(s, listener);
|
||||
var substrings = [];
|
||||
var startIndex = 0;
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
var match = matches[i];
|
||||
var endIndex = match.index + match[0].length;
|
||||
var selectionID = self._getSelectionID(match, selection);
|
||||
// put back white spaces instead of non-breaking spaces in
|
||||
// mention's name
|
||||
var matchName = match[0].substring(1)
|
||||
.replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' ');
|
||||
var href = baseHREF +
|
||||
_.str.sprintf("#model=%s&id=%s", listener.model, selectionID);
|
||||
var processedText = _.str.sprintf(mentionLink,
|
||||
href,
|
||||
listener.redirectClassname,
|
||||
selectionID,
|
||||
listener.model,
|
||||
listener.delimiter,
|
||||
matchName);
|
||||
var subtext = s.substring(startIndex, endIndex)
|
||||
.replace(match[0], processedText);
|
||||
substrings.push(subtext);
|
||||
startIndex = endIndex;
|
||||
}
|
||||
substrings.push(s.substring(startIndex, s.length));
|
||||
s = substrings.join('');
|
||||
}
|
||||
});
|
||||
return s;
|
||||
},
|
||||
/**
|
||||
* @param {integer} keycode
|
||||
*/
|
||||
propositionNavigation: function (keycode) {
|
||||
var $active = this.$('.o_mention_proposition.active');
|
||||
if (keycode === $.ui.keyCode.ENTER) {
|
||||
// selecting proposition
|
||||
$active.click();
|
||||
} else {
|
||||
// navigation in propositions
|
||||
var $to;
|
||||
if (keycode === $.ui.keyCode.DOWN) {
|
||||
$to = $active.nextAll('.o_mention_proposition').first();
|
||||
} else if (keycode === $.ui.keyCode.UP) {
|
||||
$to = $active.prevAll('.o_mention_proposition').first();
|
||||
} else if (keycode === $.ui.keyCode.TAB) {
|
||||
$to = $active.nextAll('.o_mention_proposition').first();
|
||||
if (!$to.length) {
|
||||
$to = $active.prevAll('.o_mention_proposition').last();
|
||||
}
|
||||
}
|
||||
if ($to && $to.length) {
|
||||
$active.removeClass('active');
|
||||
$to.addClass('active');
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Registers a new listener, described by an object containing the following
|
||||
* keys
|
||||
*
|
||||
* @param {Object} listener
|
||||
* @param {boolean} [listener.beginningOnly] true to enable autocomplete
|
||||
* only at first position of input
|
||||
* @param {char} [listener.delimiter] the mention delimiter
|
||||
* @param {function} [listener.fetchCallback] the callback to fetch mention
|
||||
* suggestions
|
||||
* @param {boolean} [listener.generateLinks] true to wrap mentions in <a>
|
||||
* links
|
||||
* @param {string} [listener.model=''] (optional) the model used for
|
||||
* redirection
|
||||
* @param {string} [listener.redirectClassname=''] (optional) the classname
|
||||
* of the <a> wrapping the mention
|
||||
* @param {Array} [listener.selection=[]] (optional) initial mentions for
|
||||
* each listener
|
||||
* @param {string} [listener.suggestionTemplate] the template of
|
||||
* suggestions' dropdown
|
||||
*/
|
||||
register: function (listener) {
|
||||
this._listeners.push(_.defaults(listener, {
|
||||
model: '',
|
||||
redirectClassname: '',
|
||||
selection: [],
|
||||
}));
|
||||
},
|
||||
resetSuggestions: function () {
|
||||
this.set('mention_suggestions', []);
|
||||
},
|
||||
resetSelections: function () {
|
||||
_.each(this._listeners, function (listener) {
|
||||
listener.selection = [];
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the matches (as RexExp.exec does) for the mention in the input
|
||||
* text
|
||||
*
|
||||
* @private
|
||||
* @param {string} inputText: the text to search matches
|
||||
* @param {Object} listener: the listener for which we want to find a match
|
||||
* @returns {Object[]} matches in the same format as RexExp.exec()
|
||||
*/
|
||||
_getMatch: function (inputText, listener) {
|
||||
// create the regex of all mention's names
|
||||
var names = _.pluck(listener.selection, 'name');
|
||||
var escapedNames = _.map(names, function (str) {
|
||||
return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
|
||||
});
|
||||
var regexStr = escapedNames.join('|');
|
||||
// extract matches
|
||||
var result = [];
|
||||
if (regexStr.length) {
|
||||
var myRegexp = new RegExp(regexStr, 'g');
|
||||
var match = myRegexp.exec(inputText);
|
||||
while (match !== null) {
|
||||
result.push(match);
|
||||
match = myRegexp.exec(inputText);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string[]} match
|
||||
* @param {Object} selection
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getSelectionID: function (match, selection) {
|
||||
return _.findWhere(selection, { name: match[0].slice(1) }).id;
|
||||
},
|
||||
/**
|
||||
* Get cursor position and selection
|
||||
*
|
||||
* @private
|
||||
* @returns a current cursor position
|
||||
*/
|
||||
_getSelectionPositions: function () {
|
||||
var InputElement = this._composer.$input.get(0);
|
||||
return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 };
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderSuggestions: function () {
|
||||
var suggestions = [];
|
||||
if (_.find(this.get('mention_suggestions'), _.isArray)) {
|
||||
// Array of arrays -> Flatten and insert dividers between groups
|
||||
var insertDivider = false;
|
||||
_.each(this.get('mention_suggestions'), function (suggestionGroup) {
|
||||
if (suggestionGroup.length > 0) {
|
||||
if (insertDivider) {
|
||||
suggestions.push({ divider: true });
|
||||
}
|
||||
suggestions = suggestions.concat(suggestionGroup);
|
||||
insertDivider = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
suggestions = this.get('mention_suggestions');
|
||||
}
|
||||
if (suggestions.length) {
|
||||
this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, {
|
||||
suggestions: suggestions,
|
||||
}));
|
||||
this.$el
|
||||
.addClass('open')
|
||||
.find('ul')
|
||||
.css('max-width', this._composer.$input.width())
|
||||
.find('.o_mention_proposition')
|
||||
.first()
|
||||
.addClass('active');
|
||||
this._open = true;
|
||||
} else {
|
||||
this.$el.removeClass('open');
|
||||
this.$el.empty();
|
||||
this._open = false;
|
||||
}
|
||||
},
|
||||
/*
|
||||
* Set cursor position
|
||||
*
|
||||
* @private
|
||||
* @param {integer} pos
|
||||
*/
|
||||
_setCursorPosition: function (pos) {
|
||||
this._composer.$input.each(function (index, elem) {
|
||||
dom.setSelectionRange(elem, { start: pos, end: pos });
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} selection
|
||||
* @param {Array} inputMentions
|
||||
* @returns {Array}
|
||||
*/
|
||||
_validateSelection: function (selection, inputMentions) {
|
||||
var validatedSelection = [];
|
||||
_.each(inputMentions, function (mention) {
|
||||
var validatedMention = _.findWhere(selection, { name: mention.slice(1) });
|
||||
if (validatedMention) {
|
||||
validatedSelection.push(validatedMention);
|
||||
}
|
||||
});
|
||||
return validatedSelection;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev - to get a current selected mention from list
|
||||
* and eventlistener.
|
||||
*/
|
||||
_onClickMentionItem: function (ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
var textInput = this._composer.$input.val();
|
||||
var id = $(ev.currentTarget).data('id');
|
||||
var suggestions = _.flatten(this.get('mention_suggestions'));
|
||||
var selectedSuggestion = _.find(suggestions, function (s) {
|
||||
return s.id === id;
|
||||
});
|
||||
var substitution = selectedSuggestion.substitution;
|
||||
if (!substitution) {
|
||||
// no substitution string given, so use the mention name instead
|
||||
// replace white spaces with non-breaking spaces to facilitate
|
||||
// mentions detection in text
|
||||
selectedSuggestion.name = selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE);
|
||||
substitution = this._activeListener.delimiter + selectedSuggestion.name;
|
||||
}
|
||||
var getMentionIndex = function (matches, cursorPosition) {
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
if (cursorPosition <= matches[i].index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
// add the selected suggestion to the list
|
||||
if (this._activeListener.selection.length) {
|
||||
// get mention matches (ordered by index in the text)
|
||||
var matches = this._getMatch(textInput, this._activeListener);
|
||||
var index = getMentionIndex(matches, this._getSelectionPositions().start);
|
||||
this._activeListener.selection.splice(index, 0, selectedSuggestion);
|
||||
} else {
|
||||
this._activeListener.selection.push(selectedSuggestion);
|
||||
}
|
||||
|
||||
// update input text, and reset dropdown
|
||||
var cursorPosition = this._getSelectionPositions().start;
|
||||
var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1));
|
||||
var textRight = textInput.substring(cursorPosition, textInput.length);
|
||||
var textInputNew = textLeft + substitution + ' ' + textRight;
|
||||
this._composer.$input.val(textInputNew);
|
||||
this._setCursorPosition(textLeft.length+substitution.length+2);
|
||||
this.set('mention_suggestions', []);
|
||||
this._composer.focus(); // to trigger autoresize
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onHoverMentionProposition: function (ev) {
|
||||
var $elem = $(ev.currentTarget);
|
||||
this.$('.o_mention_proposition').removeClass('active');
|
||||
$elem.addClass('active');
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return MentionManager;
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
odoo.define('mail.chat_discuss_mobile', function (require) {
|
||||
odoo.define('mail.DiscussMobile', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Discuss = require('mail.chat_discuss');
|
||||
var Discuss = require('mail.Discuss');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
@@ -12,12 +12,15 @@ if (!config.device.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides Discuss module in mobile
|
||||
*/
|
||||
Discuss.include({
|
||||
template: 'mail.discuss_mobile',
|
||||
events: _.extend(Discuss.prototype.events, {
|
||||
'click .o_mail_mobile_tab': '_onMobileTabClicked',
|
||||
'click .o_channel_inbox_item': '_onMobileInboxButtonClicked',
|
||||
'click .o_mail_channel_preview': '_onMobileChannelClicked',
|
||||
'click .o_mailbox_inbox_item': '_onMobileInboxButtonClicked',
|
||||
'click .o_mail_preview': '_onMobileThreadClicked',
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -25,13 +28,13 @@ Discuss.include({
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.currentState = this.defaultChannelID;
|
||||
this._currentState = this._defaultThreadID;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$mainContent = this.$('.o_mail_chat_content');
|
||||
this._$mainContent = this.$('.o_mail_discuss_content');
|
||||
return this._super.apply(this, arguments)
|
||||
.then(this._updateControlPanel.bind(this));
|
||||
},
|
||||
@@ -39,8 +42,8 @@ Discuss.include({
|
||||
* @override
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
if (this.channel && this._isInInboxTab()) {
|
||||
this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]});
|
||||
if (this._thread && this._isInInboxTab()) {
|
||||
this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -48,7 +51,7 @@ Discuss.include({
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
if (this._isInInboxTab()) {
|
||||
this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop();
|
||||
this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -61,7 +64,7 @@ Discuss.include({
|
||||
* @returns {Boolean} true iff we currently are in the Inbox tab
|
||||
*/
|
||||
_isInInboxTab: function () {
|
||||
return _.contains(['channel_inbox', 'channel_starred'], this.currentState);
|
||||
return _.contains(['mailbox_inbox', 'mailbox_starred'], this._currentState);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -71,18 +74,18 @@ Discuss.include({
|
||||
var self = this;
|
||||
this._super.apply(this, arguments);
|
||||
_.each(['dm', 'public', 'private'], function (type) {
|
||||
var selector = '.o_mail_chat_button_' + type;
|
||||
self.$buttons.on('click', selector, self._onAddChannel.bind(self));
|
||||
var selector = '.o_mail_discuss_button_' + type;
|
||||
self.$buttons.on('click', selector, self._onAddThread.bind(self));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to only store the channel state if we are in the Inbox tab, as
|
||||
* this is the only tab in which we actually have a displayed channel
|
||||
* Overrides to only store the thread state if we are in the Inbox tab, as
|
||||
* this is the only tab in which we actually have a displayed thread
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_restoreChannelState: function () {
|
||||
_restoreThreadState: function () {
|
||||
if (this._isInInboxTab()) {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
@@ -100,29 +103,31 @@ Discuss.include({
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} channel
|
||||
*/
|
||||
_setChannel: function (channel) {
|
||||
if (channel.type !== 'static') {
|
||||
return this.call('chat_manager', 'detachChannel', channel.id);
|
||||
_setThread: function (threadID) {
|
||||
var thread = this.call('mail_service', 'getThread', threadID);
|
||||
if (thread.getType() !== 'mailbox') {
|
||||
this.call('mail_service', 'openThreadWindow', threadID);
|
||||
return $.when();
|
||||
} else {
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to only store the channel state if we are in the Inbox tab, as
|
||||
* this is the only tab in which we actually have a displayed channel
|
||||
* Overrides to only store the thread state if we are in the Inbox tab, as
|
||||
* this is the only tab in which we actually have a displayed thread
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_storeChannelState: function () {
|
||||
if (this.channel && this._isInInboxTab()) {
|
||||
_storeThreadState: function () {
|
||||
if (this._thread && this._isInInboxTab()) {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to toggle the visibility of the tabs when a message is unselected
|
||||
* Overrides to toggle the visibility of the tabs when a message is
|
||||
* unselected
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
@@ -135,74 +140,86 @@ Discuss.include({
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_updateChannels: function () {
|
||||
return this._updateContent(this.currentState);
|
||||
_updateThreads: function () {
|
||||
return this._updateContent(this._currentState);
|
||||
},
|
||||
/**
|
||||
* Redraws the content of the client action according to its current state.
|
||||
*
|
||||
* @private
|
||||
* @param {string} type the channel's type to display (e.g. 'channel_inbox',
|
||||
* 'channel_starred', 'dm'...).
|
||||
* @param {string} type the thread's type to display (e.g. 'mailbox_inbox',
|
||||
* 'mailbox_starred', 'dm'...).
|
||||
*/
|
||||
_updateContent: function (type) {
|
||||
var self = this;
|
||||
var inInbox = type === 'channel_inbox' || type === 'channel_starred';
|
||||
if (!inInbox && this._isInInboxTab()) {
|
||||
var inMailbox = type === 'mailbox_inbox' || type === 'mailbox_starred';
|
||||
if (!inMailbox && this._isInInboxTab()) {
|
||||
// we're leaving the inbox, so store the thread scrolltop
|
||||
this._storeChannelState();
|
||||
this._storeThreadState();
|
||||
}
|
||||
var previouslyInInbox = this._isInInboxTab();
|
||||
this.currentState = type;
|
||||
this._currentState = type;
|
||||
|
||||
// fetch content to display
|
||||
var def;
|
||||
if (inInbox) {
|
||||
if (inMailbox) {
|
||||
def = this._fetchAndRenderThread();
|
||||
} else {
|
||||
var allChannels = this.call('chat_manager', 'getChannels');
|
||||
var channels = _.where(allChannels, {type: type});
|
||||
def = this.call('chat_manager', 'getChannelsPreview', channels);
|
||||
var allChannels = this.call('mail_service', 'getChannels');
|
||||
var channels = _.filter(allChannels, function (channel) {
|
||||
return channel.getType() === type;
|
||||
});
|
||||
def = this.call('mail_service', 'getChannelPreviews', channels);
|
||||
}
|
||||
return $.when(def).then(function (channelsPreview) {
|
||||
return $.when(def).then(function (previews) {
|
||||
// update content
|
||||
if (inInbox) {
|
||||
if (inMailbox) {
|
||||
if (!previouslyInInbox) {
|
||||
self.$('.o_mail_chat_tab_pane').remove();
|
||||
self.$mainContent.append(self.thread.$el);
|
||||
self.$mainContent.append(self.extendedComposer.$el);
|
||||
self.$('.o_mail_discuss_tab_pane').remove();
|
||||
self._$mainContent.append(self._threadWidget.$el);
|
||||
self._$mainContent.append(self._extendedComposer.$el);
|
||||
}
|
||||
self._restoreChannelState();
|
||||
self._restoreThreadState();
|
||||
} else {
|
||||
self.thread.$el.detach();
|
||||
self.extendedComposer.$el.detach();
|
||||
var $content = $(QWeb.render("mail.chat.MobileTabPane", {
|
||||
channels: channelsPreview,
|
||||
self._threadWidget.$el.detach();
|
||||
self._extendedComposer.$el.detach();
|
||||
var $content = $(QWeb.render('mail.discuss.MobileTabPane', {
|
||||
previews: previews,
|
||||
type: type,
|
||||
}));
|
||||
self._prepareAddChannelInput($content.find('.o_mail_add_channel input'), type);
|
||||
self.$mainContent.html($content);
|
||||
self._prepareAddThreadInput($content.find('.o_mail_add_thread input'), type);
|
||||
self._$mainContent.html($content);
|
||||
}
|
||||
|
||||
// update control panel
|
||||
self.$buttons.find('button').addClass('o_hidden');
|
||||
self.$buttons.find('.o_mail_chat_button_' + type).removeClass('o_hidden');
|
||||
self.$buttons.find('.o_mail_chat_button_mark_read').toggleClass('o_hidden', type !== 'channel_inbox');
|
||||
self.$buttons.find('.o_mail_chat_button_unstar_all').toggleClass('o_hidden', type !== 'channel_starred');
|
||||
self.$buttons.find('button')
|
||||
.addClass('o_hidden');
|
||||
self.$buttons.find('.o_mail_discuss_button_' + type)
|
||||
.removeClass('o_hidden');
|
||||
self.$buttons.find('.o_mail_discuss_button_mark_all_read')
|
||||
.toggleClass('o_hidden', type !== 'mailbox_inbox');
|
||||
self.$buttons.find('.o_mail_discuss_button_unstar_all')
|
||||
.toggleClass('o_hidden', type !== 'mailbox_starred');
|
||||
|
||||
// update Inbox page buttons
|
||||
if (inInbox) {
|
||||
self.$('.o_mail_chat_mobile_inbox_buttons').removeClass('o_hidden');
|
||||
self.$('.o_channel_inbox_item').removeClass('btn-primary').addClass('btn-default');
|
||||
self.$('.o_channel_inbox_item[data-type=' + type + ']').removeClass('btn-default').addClass('btn-primary');
|
||||
// update Mailbox page buttons
|
||||
if (inMailbox) {
|
||||
self.$('.o_mail_discuss_mobile_mailboxes_buttons')
|
||||
.removeClass('o_hidden');
|
||||
self.$('.o_mailbox_inbox_item')
|
||||
.removeClass('btn-primary')
|
||||
.addClass('btn-default');
|
||||
self.$('.o_mailbox_inbox_item[data-type=' + type + ']')
|
||||
.removeClass('btn-default')
|
||||
.addClass('btn-primary');
|
||||
} else {
|
||||
self.$('.o_mail_chat_mobile_inbox_buttons').addClass('o_hidden');
|
||||
self.$('.o_mail_discuss_mobile_mailboxes_buttons')
|
||||
.addClass('o_hidden');
|
||||
}
|
||||
|
||||
// update bottom buttons
|
||||
self.$('.o_mail_mobile_tab').removeClass('active');
|
||||
// channel_inbox and channel_starred share the same tab
|
||||
type = type === 'channel_starred' ? 'channel_inbox' : type;
|
||||
// mailbox_inbox and mailbox_starred share the same tab
|
||||
type = type === 'mailbox_starred' ? 'mailbox_inbox' : type;
|
||||
self.$('.o_mail_mobile_tab[data-type=' + type + ']').addClass('active');
|
||||
});
|
||||
},
|
||||
@@ -212,45 +229,46 @@ Discuss.include({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onAddChannel: function () {
|
||||
this.$('.o_mail_add_channel').show().find('input').focus();
|
||||
_onAddThread: function () {
|
||||
this.$('.o_mail_add_thread').show().find('input').focus();
|
||||
},
|
||||
/**
|
||||
* Switches to the clicked channel in the Inbox page (Inbox or Starred).
|
||||
* Switches to the clicked thread in the Inbox page (Inbox or Starred).
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent}
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMobileInboxButtonClicked: function (event) {
|
||||
var channel = this.call('chat_manager', 'getChannel', $(event.currentTarget).data('type'));
|
||||
this._setChannel(channel);
|
||||
this._updateContent(this.channel.id);
|
||||
_onMobileInboxButtonClicked: function (ev) {
|
||||
var mailboxID = $(ev.currentTarget).data('type');
|
||||
this._setThread(mailboxID);
|
||||
this._updateContent(this._thread.getID());
|
||||
},
|
||||
/**
|
||||
* Switches to another tab.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent}
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMobileTabClicked: function (event) {
|
||||
var type = $(event.currentTarget).data('type');
|
||||
if (type === 'channel_inbox') {
|
||||
var channel = this.call('chat_manager', 'getChannel', 'channel_inbox');
|
||||
this._setChannel(channel);
|
||||
_onMobileTabClicked: function (ev) {
|
||||
var type = $(ev.currentTarget).data('type');
|
||||
if (type === 'mailbox') {
|
||||
var inbox = this.call('mail_service', 'getMailbox', 'inbox');
|
||||
this._setThread(inbox);
|
||||
}
|
||||
this._updateContent(type);
|
||||
},
|
||||
/**
|
||||
* Opens a channel in a chat windown (full screen in mobile).
|
||||
* Opens a thread in a chat window (full screen in mobile).
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent}
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMobileChannelClicked: function (event) {
|
||||
var channelID = $(event.currentTarget).data("channel_id");
|
||||
this.call('chat_manager', 'detachChannel', channelID);
|
||||
_onMobileThreadClicked: function (ev) {
|
||||
var threadID = $(ev.currentTarget).data('preview-id');
|
||||
this.call('mail_service', 'openThreadWindow', threadID);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
odoo.define('mail.emojis', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module exports the list of all available emojis on the client side.
|
||||
* An emoji object has the following properties:
|
||||
*
|
||||
* - {string[]} sources: the character representations of the emoji
|
||||
* - {string} unicode: the unicode representation of the emoji
|
||||
* - {string} description: the description of the emoji
|
||||
*/
|
||||
|
||||
/**
|
||||
* This data represent all the available emojis that are supported on the web
|
||||
* client:
|
||||
*
|
||||
* - key: this is the source representation of an emoji, i.e. its "character"
|
||||
* representation. This is a string that can be easily typed by the
|
||||
* user and then translated to its unicode representation (see value)
|
||||
* - value: this is the unicode representation of an emoji, i.e. its "true"
|
||||
* representation in the system.
|
||||
*/
|
||||
var data = {
|
||||
":)": "😊",
|
||||
":-)": "😊", // alternative (alt.)
|
||||
"=)": "😊", // alt.
|
||||
":]": "😊", // alt.
|
||||
":D": "😃",
|
||||
":-D": "😃", // alt.
|
||||
"=D": "😃", // alt.
|
||||
"xD": "😆",
|
||||
"XD": "😆", // alt.
|
||||
"x'D": "😂",
|
||||
";)": "😉",
|
||||
";-)": "😉", // alt.
|
||||
"B)": "😎",
|
||||
"8)": "😎", // alt.
|
||||
"B-)": "😎", // alt.
|
||||
"8-)": "😎", // alt.
|
||||
";p": "😜",
|
||||
";P": "😜", // alt.
|
||||
":p": "😋",
|
||||
":P": "😋", // alt.
|
||||
":-p": "😋", // alt.
|
||||
":-P": "😋", // alt.
|
||||
"=P": "😋", // alt.
|
||||
"xp": "😝",
|
||||
"xP": "😝", // alt.
|
||||
"o_o": "😳",
|
||||
":|": "😐",
|
||||
":-|": "😐", // alt.
|
||||
":/": "😕", // alt.
|
||||
":-/": "😕", // alt.
|
||||
":(": "😞",
|
||||
":@": "😱",
|
||||
":O": "😲",
|
||||
":-O": "😲", // alt.
|
||||
":o": "😲", // alt.
|
||||
":-o": "😲", // alt.
|
||||
":'o": "😨",
|
||||
"3:(": "😠",
|
||||
">:(": "😠", // alt.
|
||||
"3:": "😠", // alt.
|
||||
"3:)": "😈",
|
||||
">:)": "😈", // alt.
|
||||
":*": "😘",
|
||||
":-*": "😘", // alt.
|
||||
"o:)": "😇",
|
||||
":'(": "😢",
|
||||
":'-(": "😭",
|
||||
":\"(": "😭", // alt.
|
||||
"<3": "❤️",
|
||||
":heart": "❤️", // alt.
|
||||
":heart_eyes": "😍",
|
||||
":turban": "👳",
|
||||
":+1": "👍",
|
||||
":-1": "👎",
|
||||
":ok": "👌",
|
||||
":poop": "💩",
|
||||
":no_see": "🙈",
|
||||
":no_hear": "🙉",
|
||||
":no_speak": "🙊",
|
||||
":bug": "🐞",
|
||||
":kitten": "😺",
|
||||
":bear": "🐻",
|
||||
":snail": "🐌",
|
||||
":boar": "🐗",
|
||||
":clover": "🍀",
|
||||
":sunflower": "🌹",
|
||||
":fire": "🔥",
|
||||
":sun": "☀️",
|
||||
":partly_sunny:": "⛅️",
|
||||
":rainbow": "🌈",
|
||||
":cloud": "☁️",
|
||||
":zap": "⚡️",
|
||||
":star": "⭐️",
|
||||
":cookie": "🍪",
|
||||
":pizza": "🍕",
|
||||
":hamburger": "🍔",
|
||||
":fries": "🍟",
|
||||
":cake": "🎂",
|
||||
":cake_part": "🍰",
|
||||
":coffee": "☕️",
|
||||
":banana": "🍌",
|
||||
":sushi": "🍣",
|
||||
":rice_ball": "🍙",
|
||||
":beer": "🍺",
|
||||
":wine": "🍷",
|
||||
":cocktail": "🍸",
|
||||
":tropical": "🍹",
|
||||
":beers": "🍻",
|
||||
":ghost": "👻",
|
||||
":skull": "💀",
|
||||
":et": "👽",
|
||||
":alien": "👽", // alt.
|
||||
":party": "🎉",
|
||||
":trophy": "🏆",
|
||||
":key": "🔑",
|
||||
":pin": "📌",
|
||||
":postal_horn": "📯",
|
||||
":music": "🎵",
|
||||
":trumpet": "🎺",
|
||||
":guitar": "🎸",
|
||||
":soccer": "⚽️",
|
||||
":football": "🏈",
|
||||
":8ball": "🎱",
|
||||
":clapper": "🎬",
|
||||
":microphone": "🎤"
|
||||
};
|
||||
|
||||
// list of emojis in a dictionary, indexed by emoji unicode
|
||||
var emojiDict = {};
|
||||
_.each(data, function (unicode, source) {
|
||||
if (!emojiDict[unicode]) {
|
||||
emojiDict[unicode] = {
|
||||
sources: [source],
|
||||
unicode: unicode,
|
||||
description: source,
|
||||
};
|
||||
} else {
|
||||
emojiDict[unicode].sources.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
var emojis = _.values(emojiDict);
|
||||
|
||||
return emojis;
|
||||
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
odoo.define('mail.ExtendedChatWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatWindow = require('mail.ChatWindow');
|
||||
var composer = require('mail.composer');
|
||||
|
||||
return ChatWindow.extend({
|
||||
template: "mail.ExtendedChatWindow",
|
||||
events: _.extend({}, ChatWindow.prototype.events, {
|
||||
"click .o_chat_window_expand": "on_click_expand",
|
||||
}),
|
||||
start: function () {
|
||||
var self = this;
|
||||
var def;
|
||||
if (self.options.thread_less) {
|
||||
this.$el.addClass('o_thread_less');
|
||||
this.$('.o_chat_search_input input')
|
||||
.autocomplete({
|
||||
source: function (request, response) {
|
||||
self.call('chat_manager', 'searchPartner', request.term, 10).done(response);
|
||||
},
|
||||
select: function (event, ui) {
|
||||
self.trigger('open_dm_session', ui.item.id);
|
||||
},
|
||||
})
|
||||
.focus();
|
||||
} else if (!self.options.input_less) {
|
||||
var basicComposer = new composer.BasicComposer(self, {mention_partners_restricted: true, isMini: true});
|
||||
basicComposer.on('post_message', self, function (message) {
|
||||
self.call('chat_manager', 'postMessage', message, {
|
||||
channelID: self.channel_id,
|
||||
});
|
||||
});
|
||||
basicComposer.once('input_focused', self, function () {
|
||||
var channel = self.call('chat_manager', 'getChannel', self.channel_id);
|
||||
var commands = self.call('chat_manager', 'getCommands', channel);
|
||||
var partners = self.call('chat_manager', 'getMentionPartnerSuggestions', channel);
|
||||
basicComposer.mention_set_enabled_commands(commands);
|
||||
basicComposer.mention_set_prefetched_partners(partners);
|
||||
});
|
||||
def = basicComposer.replace(self.$('.o_chat_composer'));
|
||||
}
|
||||
return $.when(this._super(), def);
|
||||
},
|
||||
// Override on_keydown to only prevent jquery's blockUI to cancel event, but without sending
|
||||
// the message on ENTER keydown as this is handled by the BasicComposer
|
||||
on_keydown: function (event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
on_reverse_breadcrumb: function () {
|
||||
this.call('chat_manager', 'getChatBus').trigger('discuss_open', false);
|
||||
},
|
||||
on_click_expand: _.debounce(function (event) {
|
||||
event.preventDefault();
|
||||
this.do_action('mail.mail_channel_action_client_chat', {
|
||||
clear_breadcrumbs: false,
|
||||
active_id: this.channel_id,
|
||||
on_reverse_breadcrumb: this.on_reverse_breadcrumb
|
||||
});
|
||||
}, 1000, true),
|
||||
});
|
||||
|
||||
});
|
||||
@@ -31,7 +31,7 @@ var Followers = AbstractField.extend({
|
||||
supportedFieldTypes: ['one2many'],
|
||||
|
||||
// inherited
|
||||
init: function(parent, name, record, options) {
|
||||
init: function (parent, name, record, options) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.image = this.attrs.image || 'image_small';
|
||||
@@ -40,7 +40,7 @@ var Followers = AbstractField.extend({
|
||||
this.followers = [];
|
||||
this.subtypes = [];
|
||||
this.data_subtype = {};
|
||||
this.is_follower = undefined;
|
||||
this._isFollower = undefined;
|
||||
var session = this.getSession();
|
||||
this.partnerID = session.partner_id;
|
||||
|
||||
@@ -84,7 +84,7 @@ var Followers = AbstractField.extend({
|
||||
|
||||
// private
|
||||
_displayButtons: function () {
|
||||
if (this.is_follower) {
|
||||
if (this._isFollower) {
|
||||
this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following');
|
||||
this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false);
|
||||
this.$('.o_followers_actions .dropdown-toggle').addClass('o_followers_following');
|
||||
@@ -100,7 +100,7 @@ var Followers = AbstractField.extend({
|
||||
this.$('.o_followers_title_box > button').prop('disabled', true);
|
||||
this.$('.o_followers_count')
|
||||
.html(this.value.res_ids.length)
|
||||
.parent().attr("title", this._formatFollowers(this.value.res_ids.length));
|
||||
.parent().attr('title', this._formatFollowers(this.value.res_ids.length));
|
||||
},
|
||||
_displayFollowers: function () {
|
||||
var self = this;
|
||||
@@ -118,7 +118,7 @@ var Followers = AbstractField.extend({
|
||||
|
||||
// On mouse-enter it will show the edit_subtype pencil.
|
||||
if (record.is_editable) {
|
||||
$follower_li.on('mouseenter mouseleave', function(e) {
|
||||
$follower_li.on('mouseenter mouseleave', function (e) {
|
||||
$(e.currentTarget).find('.o_edit_subtype').toggleClass('hide', e.type === 'mouseleave');
|
||||
});
|
||||
}
|
||||
@@ -129,7 +129,7 @@ var Followers = AbstractField.extend({
|
||||
this.$('.o_followers_title_box > button').prop('disabled', !$followers_list.children().length);
|
||||
this.$('.o_followers_count')
|
||||
.html(this.value.res_ids.length)
|
||||
.parent().attr("title", this._formatFollowers(this.value.res_ids.length));
|
||||
.parent().attr('title', this._formatFollowers(this.value.res_ids.length));
|
||||
},
|
||||
_displaySubtypes:function (data, dialog, display_warning) {
|
||||
var old_parent_model;
|
||||
@@ -167,7 +167,7 @@ var Followers = AbstractField.extend({
|
||||
view_mode: 'form',
|
||||
view_type: 'form',
|
||||
views: [[false, 'form']],
|
||||
name: _t('Invite Follower'),
|
||||
name: _t("Invite Follower"),
|
||||
target: 'new',
|
||||
context: {
|
||||
'default_res_model': this.model,
|
||||
@@ -182,11 +182,11 @@ var Followers = AbstractField.extend({
|
||||
_formatFollowers: function (count){
|
||||
var str = '';
|
||||
if (count <= 0) {
|
||||
str = _t('No follower');
|
||||
str = _t("No follower");
|
||||
} else if (count === 1){
|
||||
str = _t('One follower');
|
||||
str = _t("One follower");
|
||||
} else {
|
||||
str = ''+count+' '+_t('followers');
|
||||
str = ''+count+' '+_t("followers");
|
||||
}
|
||||
return str;
|
||||
},
|
||||
@@ -210,7 +210,7 @@ var Followers = AbstractField.extend({
|
||||
return _.contains(self.value.res_ids, follower.id);
|
||||
});
|
||||
var user_follower = _.filter(self.followers, function (rec) { return rec.is_uid; });
|
||||
self.is_follower = user_follower.length >= 1;
|
||||
self._isFollower = user_follower.length >= 1;
|
||||
});
|
||||
},
|
||||
_reload: function () {
|
||||
@@ -258,17 +258,17 @@ var Followers = AbstractField.extend({
|
||||
});
|
||||
return def;
|
||||
},
|
||||
_updateSubscription: function (event, follower_id, is_channel) {
|
||||
_updateSubscription: function (event, followerID, isChannel) {
|
||||
var ids = {};
|
||||
var subtypes;
|
||||
|
||||
if (follower_id !== undefined) {
|
||||
if (followerID !== undefined) {
|
||||
// Subtypes edited from the modal
|
||||
subtypes = this.dialog.$('input[type="checkbox"]');
|
||||
if (is_channel) {
|
||||
ids.channel_ids = [follower_id];
|
||||
if (isChannel) {
|
||||
ids.channel_ids = [followerID];
|
||||
} else {
|
||||
ids.partner_ids = [follower_id];
|
||||
ids.partner_ids = [followerID];
|
||||
}
|
||||
} else {
|
||||
subtypes = this.$('.o_followers_actions input[type="checkbox"]');
|
||||
@@ -286,7 +286,7 @@ var Followers = AbstractField.extend({
|
||||
// If no more subtype followed, unsubscribe the follower
|
||||
if (!checklist.length) {
|
||||
this._unfollow(ids).fail(function () {
|
||||
$(event.target).prop("checked", true);
|
||||
$(event.target).prop('checked', true);
|
||||
});
|
||||
} else {
|
||||
var kwargs = _.extend({}, ids);
|
||||
@@ -302,21 +302,33 @@ var Followers = AbstractField.extend({
|
||||
}
|
||||
},
|
||||
|
||||
// handlers
|
||||
_onAddFollower: function (event) {
|
||||
event.preventDefault();
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAddFollower: function (ev) {
|
||||
ev.preventDefault();
|
||||
this._inviteFollower(false);
|
||||
},
|
||||
_onAddChannel: function (event) {
|
||||
event.preventDefault();
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAddChannel: function (ev) {
|
||||
ev.preventDefault();
|
||||
this._inviteFollower(true);
|
||||
},
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onEditSubtype: function (event) {
|
||||
_onEditSubtype: function (ev) {
|
||||
var self = this;
|
||||
var $currentTarget = $(event.currentTarget);
|
||||
var $currentTarget = $(ev.currentTarget);
|
||||
var follower_id = $currentTarget.data('follower-id'); // id of model mail_follower
|
||||
this._rpc({
|
||||
route: '/mail/read_subscription_data',
|
||||
@@ -327,13 +339,13 @@ var Followers = AbstractField.extend({
|
||||
var is_channel = $currentTarget.data('oe-model') === 'mail.channel';
|
||||
self.dialog = new Dialog(this, {
|
||||
size: 'medium',
|
||||
title: _t('Edit Subscription of ') + $currentTarget.siblings('a').text(),
|
||||
title: _t("Edit Subscription of ") + $currentTarget.siblings('a').text(),
|
||||
buttons: [
|
||||
{
|
||||
text: _t("Apply"),
|
||||
classes: 'btn-primary',
|
||||
click: function () {
|
||||
self._updateSubscription(event, res_id, is_channel);
|
||||
self._updateSubscription(ev, res_id, is_channel);
|
||||
},
|
||||
close: true
|
||||
},
|
||||
@@ -349,16 +361,23 @@ var Followers = AbstractField.extend({
|
||||
self.dialog.open();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onFollowButtonClicked: function () {
|
||||
if (!this.is_follower) {
|
||||
if (!this._isFollower) {
|
||||
this._follow();
|
||||
} else {
|
||||
this._unfollow({partner_ids: [this.partnerID]});
|
||||
}
|
||||
},
|
||||
_onRedirect: function (event) {
|
||||
event.preventDefault();
|
||||
var $target = $(event.target);
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onRedirect: function (ev) {
|
||||
ev.preventDefault();
|
||||
var $target = $(ev.target);
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
view_type: 'form',
|
||||
@@ -368,18 +387,26 @@ var Followers = AbstractField.extend({
|
||||
res_id: $target.data('oe-id'),
|
||||
});
|
||||
},
|
||||
_onRemoveFollower: function (event) {
|
||||
var res_model = $(event.target).parent().find('a').data('oe-model');
|
||||
var res_id = $(event.target).parent().find('a').data('oe-id');
|
||||
if (res_model === 'res.partner') {
|
||||
return this._unfollow({partner_ids: [res_id]});
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onRemoveFollower: function (ev) {
|
||||
var resModel = $(ev.target).parent().find('a').data('oe-model');
|
||||
var resID = $(ev.target).parent().find('a').data('oe-id');
|
||||
if (resModel === 'res.partner') {
|
||||
return this._unfollow({partner_ids: [resID]});
|
||||
} else {
|
||||
return this._unfollow({channel_ids: [res_id]});
|
||||
return this._unfollow({channel_ids: [resID]});
|
||||
}
|
||||
},
|
||||
_onSubtypeClicked: function (event) {
|
||||
event.stopPropagation();
|
||||
this._updateSubscription(event);
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onSubtypeClicked: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._updateSubscription(ev);
|
||||
var $list = this.$('.o_subtypes_list');
|
||||
if (!$list.hasClass('open')) {
|
||||
$list.addClass('open');
|
||||
|
||||
@@ -19,12 +19,12 @@ FormController.include({
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} event
|
||||
* @param {OdooEvent} ev
|
||||
* @param {string} event.data.id datapointID
|
||||
* @param {integer[]} event.data.msgIDs list of message ids
|
||||
* @param {integer[]} event.data.messageIDs list of message IDs
|
||||
*/
|
||||
_onNewMessage: function (event) {
|
||||
this.model.updateMessageIDs(event.data.id, event.data.msgIDs);
|
||||
_onNewMessage: function (ev) {
|
||||
this.model.updateMessageIDs(ev.data.id, ev.data.messageIDs);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
odoo.define('mail.model.Channel', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ThreadWithCache = require('mail.model.ThreadWithCache');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var time = require('web.time');
|
||||
|
||||
/**
|
||||
* This class represent channels in JS. In this context, the word channel
|
||||
* has the same meaning of channel on the server, meaning that direct messages
|
||||
* (DM) and livechats are also channels.
|
||||
*
|
||||
* Any piece of code in JS that make use of channels must ideally interact with
|
||||
* such objects, instead of direct data from the server.
|
||||
*/
|
||||
var Channel = ThreadWithCache.extend({
|
||||
/**
|
||||
* @override
|
||||
* @param {string} [data.anonymous_name]
|
||||
* @param {string} data.channel_type
|
||||
* @param {boolean} data.group_based_subscription
|
||||
* @param {boolean} [data.is_minimized=false]
|
||||
* @param {boolean} data.is_moderator whether the current user is
|
||||
* moderator of this channel.
|
||||
* @param {string} [data.last_message_date] date in server-format
|
||||
* @param {boolean} [data.mass_mailing]
|
||||
* @param {integer} [data.message_unread_counter]
|
||||
* @param {string} [data.public] either 'public' or 'private'
|
||||
* @param {string} data.state
|
||||
* @param {string} [data.uuid]
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.autoswitch=true]
|
||||
* @param {Object[]} commands
|
||||
*/
|
||||
init: function (parent, data, options, commands) {
|
||||
var self = this;
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// If set, autoswitch channel on joining this channel in discuss
|
||||
// the default behaviour is to autoswitch on join.
|
||||
// exception: receiving channel or chat session notifications
|
||||
//
|
||||
this._autoswitch = 'autoswitch' in options ? options.autoswitch : true;
|
||||
this._chat = undefined; // FIXME: could be dropped when livechat and DM are moved out of this class
|
||||
this._commands = undefined;
|
||||
this._detached = data.is_minimized;
|
||||
this._directPartnerID = undefined;
|
||||
this._folded = data.state === 'folded';
|
||||
// if set: hide 'Leave channel' button
|
||||
this._groupBasedSubscription = data.group_based_subscription;
|
||||
this._isModerator = data.is_moderator;
|
||||
this._lastMessageDate = undefined;
|
||||
this._massMailing = data.mass_mailing;
|
||||
// Deferred that is resolved on fetched members of this channel.
|
||||
this._membersDef = undefined;
|
||||
this._moderation = data.is_moderation;
|
||||
// number of messages that are 'needaction', which is equivalent to the
|
||||
// number of messages in this channel that are in inbox.
|
||||
this._needactionCounter = data.message_needaction_counter || 0;
|
||||
this._serverType = data.channel_type;
|
||||
this._status = undefined;
|
||||
this._throttleFetchSeen = _.throttle(this._fetchSeen.bind(this), 3000);
|
||||
// unique identifier for this channel, which is required for some rpc
|
||||
this._uuid = data.uuid;
|
||||
|
||||
// * list of commands available for this channel
|
||||
this._commands = _.filter(commands, function (command) {
|
||||
return !command.channel_types ||
|
||||
_.contains(command.channel_types, self._serverType);
|
||||
});
|
||||
if (this._type === 'channel') {
|
||||
this._type = data.public !== 'private' ? 'public' : 'private';
|
||||
}
|
||||
if ('anonymous_name' in data) {
|
||||
this._name = data.anonymous_name;
|
||||
}
|
||||
if (data.last_message_date) {
|
||||
this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
|
||||
}
|
||||
this._chat = !this.getType().match(/^(public|private)$/);
|
||||
if (data.message_unread_counter) {
|
||||
this._unreadCounter = data.message_unread_counter;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close the channel
|
||||
*
|
||||
* This operation is executed server-side and the chat window will be
|
||||
* folded in all potential tabs from all browsers.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
close: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_fold',
|
||||
kwargs: { uuid: this.getUUID(), state: 'closed' },
|
||||
}, { shadow: true });
|
||||
},
|
||||
/**
|
||||
* Decrement the needaction counter of the channel
|
||||
* Floor value at 0.
|
||||
*
|
||||
* @param {integer} [num=1] the amount to decrement at most
|
||||
*/
|
||||
decrementNeedactionCounter: function (num) {
|
||||
num = _.isNumber(num) ? num : 1;
|
||||
this._needactionCounter = Math.max(this._needactionCounter - 1, 0);
|
||||
},
|
||||
/**
|
||||
* Open the chat window for this channel
|
||||
* (in all potential tabs from all browsers)
|
||||
*
|
||||
* @pverride
|
||||
*/
|
||||
detach: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_minimize',
|
||||
args: [this.getUUID(), true],
|
||||
}, {
|
||||
shadow: true,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Force fetch members of the channel
|
||||
*
|
||||
* @returns {$.Promise<Object[]>} resolved with list of channel listeners
|
||||
*/
|
||||
forceFetchMembers: function () {
|
||||
this._membersDef = undefined;
|
||||
return this.getMentionPartnerSuggestions();
|
||||
},
|
||||
/**
|
||||
* Folds/Minimize the channel
|
||||
* (in all potential tabs from all browsers)
|
||||
*
|
||||
* @override
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
fold: function (folded) {
|
||||
this._super.apply(this, arguments);
|
||||
var args = {
|
||||
uuid: this.getUUID(),
|
||||
};
|
||||
if (_.isBoolean(folded)) {
|
||||
args.state = folded ? 'folded' : 'open';
|
||||
}
|
||||
this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_fold',
|
||||
kwargs: args,
|
||||
}, {shadow: true});
|
||||
},
|
||||
/**
|
||||
* Show the list of available commands on this channel (e.g. '/help')
|
||||
*
|
||||
* @override
|
||||
* @returns {Array} list of commands
|
||||
*/
|
||||
getCommands: function () {
|
||||
return this._commands;
|
||||
},
|
||||
/**
|
||||
* @returns {mail.model.Message|undefined} last message of channel, if any
|
||||
*/
|
||||
getLastMessage: function () {
|
||||
return _.last(this._cache['[]'].messages);
|
||||
},
|
||||
/**
|
||||
* Get listeners of a channel
|
||||
*
|
||||
* @returns {$.Promise<Object[]>} resolved with list of channel listeners
|
||||
*/
|
||||
getMentionPartnerSuggestions: function () {
|
||||
if (!this._membersDef) {
|
||||
this._membersDef = this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_fetch_listeners',
|
||||
args: [this.getUUID()],
|
||||
}, {
|
||||
shadow: true
|
||||
})
|
||||
.then(function (members) {
|
||||
return members;
|
||||
});
|
||||
}
|
||||
return this._membersDef;
|
||||
},
|
||||
/**
|
||||
* @returns {integer}
|
||||
*/
|
||||
getNeedactionCounter: function () {
|
||||
return this._needactionCounter;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getPreview: function () {
|
||||
var result = this._super.apply(this, arguments);
|
||||
if (!this.isChat()) {
|
||||
result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small';
|
||||
}
|
||||
var lastMessage = this.getLastMessage();
|
||||
return _.extend(result, {
|
||||
author: lastMessage ? lastMessage.getDisplayedAuthor() : '',
|
||||
body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '',
|
||||
date: lastMessage ? lastMessage.getDate() : moment(),
|
||||
isAuthor: this.hasMessages() && this.getLastMessage().isAuthor(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Returns the title to display in thread window's headers.
|
||||
* For channels, the title is prefixed with "#".
|
||||
*
|
||||
* @override
|
||||
* @returns {string|Object} the name of the thread by default (see getName)
|
||||
*/
|
||||
getTitle: function () {
|
||||
return "#" + this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Get the UUID of the channel.
|
||||
* This is a string that uniquely links this channel to a server channel.
|
||||
*
|
||||
* @returns {string} uuid of this channel
|
||||
*/
|
||||
getUUID: function () {
|
||||
return this._uuid;
|
||||
},
|
||||
/**
|
||||
* Increment the needaction counter of this channel by 1 unit
|
||||
*/
|
||||
incrementNeedactionCounter: function () {
|
||||
this._needactionCounter++;
|
||||
},
|
||||
/**
|
||||
* States whether the channel should be auto-selected on creation
|
||||
*
|
||||
* Note that this is not of the responsibility of the channel
|
||||
* (see mail.model.Thread for more information)
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAutoswitch: function () {
|
||||
return this._autoswitch;
|
||||
},
|
||||
/**
|
||||
* All instances of this class are chanels
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChannel: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* States whether this channel is a chat or not.
|
||||
* These types of channels are chat:
|
||||
* - direct messages (DM)
|
||||
* - livechat
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChat: function () {
|
||||
return this._chat;
|
||||
},
|
||||
/**
|
||||
* States whether the channel auto-subscribes some users in a group
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isGroupBasedSubscription: function () {
|
||||
return this._groupBasedSubscription;
|
||||
},
|
||||
/**
|
||||
* States whether the channel is moderated or not.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isModerated: function () {
|
||||
return this._moderation;
|
||||
},
|
||||
/**
|
||||
* States whether the current user is moderator of this channel.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isModerator: function () {
|
||||
return this._isModerator;
|
||||
},
|
||||
/**
|
||||
* Marks this channel as read.
|
||||
* The last seen message will be the last message.
|
||||
* Resolved with the last seen message, only for non-mailbox channels
|
||||
*
|
||||
* @override
|
||||
* @returns {$.Promise<integer|undefined>} resolved with last message ID
|
||||
* seen in the channel, and when the channel has been marked as seen on
|
||||
* the server.
|
||||
*/
|
||||
markAsRead: function () {
|
||||
if (this._unreadCounter > 0) {
|
||||
this.resetUnreadCounter();
|
||||
return this._throttleFetchSeen();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Prepare and send a message to the server on this channel
|
||||
*
|
||||
* @override
|
||||
* @param {Object} data data related to the new message
|
||||
* @returns {$.Promise<Object>} resolved when the message has been sent to
|
||||
* the server, with the object message that has been sent to the server.
|
||||
*/
|
||||
postMessage: function (data) {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function (messageData) {
|
||||
_.extend(messageData, {
|
||||
message_type: 'comment',
|
||||
subtype: 'mail.mt_comment',
|
||||
command: data.command,
|
||||
});
|
||||
return self._rpc({
|
||||
model: 'mail.channel',
|
||||
method: data.command ? 'execute_command' : 'message_post',
|
||||
args: [self._id],
|
||||
kwargs: messageData,
|
||||
}).then(function () {
|
||||
return messageData;
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Unsubscribes from channel
|
||||
*
|
||||
* @returns {$.Promise} resolve when unsubscribed
|
||||
*/
|
||||
unsubscribe: function () {
|
||||
if (_.contains(['public', 'private'], this.getType())) {
|
||||
// unfollow channel
|
||||
return this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'action_unfollow',
|
||||
args: [[this._id]],
|
||||
});
|
||||
} else {
|
||||
// unpin livechat
|
||||
return this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_pin',
|
||||
args: [this.getUUID(), false],
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the internal state of the channel, and reflects the changes in
|
||||
* the UI.
|
||||
*
|
||||
* Called by {mail.Manager.Notification} on receiving a channel window
|
||||
* notification. A notification is received after a client-side action
|
||||
* that changes the state of the channel window visually, so that cross tab
|
||||
* channels are updated similarly.
|
||||
*
|
||||
* TODO: This method should be dropped if the state of windows are
|
||||
* completely handled client-side, which should be the case in the near
|
||||
* future.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.detached
|
||||
* @param {boolean} params.folded
|
||||
*/
|
||||
updateWindowState: function (params) {
|
||||
this._folded = 'folded' in params ? params.folded : this._folded;
|
||||
this._detached = 'detached' in params ? params.detached : this._detached;
|
||||
this._warnUpdatedWindowState();
|
||||
},
|
||||
/**
|
||||
* Reset the needaction counter to 0.
|
||||
*/
|
||||
resetNeedactionCounter: function () {
|
||||
this._needactionCounter = 0;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {$.Promise<integer>} resolved with ID of last seen message
|
||||
*/
|
||||
_fetchSeen: function () {
|
||||
var self = this;
|
||||
return this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_seen',
|
||||
args: [[this._id]],
|
||||
}, {
|
||||
shadow: true
|
||||
}).then(function (lastSeenMessageID) {
|
||||
self._lastSeenMessageID = lastSeenMessageID;
|
||||
return lastSeenMessageID;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get the domain to fetch all the messages in the current channel
|
||||
*
|
||||
* Note that with moderation, even though some messages are not really
|
||||
* linked to a channel ('channel_ids'), we should nonetheless display them.
|
||||
* These messages are fetched from their associated document
|
||||
* ('mail.channel' with provided channel ID), and messages that are pending
|
||||
* moderation.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Array}
|
||||
*/
|
||||
_getThreadDomain: function () {
|
||||
return ['|', '&', '&',
|
||||
['model', '=', 'mail.channel'],
|
||||
['res_id', 'in', [this._id]],
|
||||
['need_moderation', '=', true],
|
||||
['channel_ids', 'in', [this._id]]];
|
||||
},
|
||||
});
|
||||
|
||||
return Channel;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
odoo.define('mail.model.DM', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Channel = require('mail.model.Channel');
|
||||
|
||||
/**
|
||||
*
|
||||
* Any piece of code in JS that make use of DMs must ideally interact with
|
||||
* such objects, instead of direct data from the server.
|
||||
*/
|
||||
var DM = Channel.extend({
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} data
|
||||
* @param {Object} data.direct_partner
|
||||
* @param {integer} data.direct_partner[0].id
|
||||
* @param {string} data.direct_partner[0].im_status
|
||||
* @param {string} data.direct_partner[0].name
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._type = 'dm';
|
||||
this._name = data.direct_partner[0].name;
|
||||
this._directPartnerID = data.direct_partner[0].id;
|
||||
this._status = data.direct_partner[0].im_status;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the direct partner ID linked to the DM, i.e. the partner ID of the
|
||||
* user at the other end of the DM conversation. All DM do have a direct
|
||||
* partner iD.
|
||||
*
|
||||
* @returns {integer}
|
||||
*/
|
||||
getDirectPartnerID: function () {
|
||||
return this._directPartnerID;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getPreview: function () {
|
||||
var result = this._super.apply(this, arguments);
|
||||
result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small';
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getStatus: function () {
|
||||
return this._status;
|
||||
},
|
||||
/**
|
||||
* DM are chat
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChat: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* @param {string} newStatus
|
||||
*/
|
||||
setStatus: function (newStatus) {
|
||||
this._status = newStatus;
|
||||
},
|
||||
/**
|
||||
* Unpin this DM
|
||||
*
|
||||
* @override
|
||||
* @return {$.Promise} resolve when unpinned
|
||||
*/
|
||||
unsubscribe: function () {
|
||||
return this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_pin',
|
||||
args: [this.getUUID(), false],
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
});
|
||||
|
||||
return DM;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
odoo.define('mail.model.DocumentThread', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Thread = require('mail.model.Thread');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
/**
|
||||
* These threads represent threads that are directly attached to documents.
|
||||
* It is sometimes called the "chatter", but technically this is just the
|
||||
* part of the chatter containing the thread, as the chatter also contains
|
||||
* the activity and listener fields.
|
||||
*
|
||||
* Note that there are still some hacks in the handling of document threads,
|
||||
* due to document threads are instantiated from fetched messages, either from
|
||||
* mail.ThreadField or by fetching messages in Inbox.
|
||||
*
|
||||
* Also, because messages are not sent on the longpoll bus for this kind of
|
||||
* threads, it stores the list of message IDs that it contains, which should
|
||||
* be up-to-date whenever someone wants to get the messages in the document
|
||||
* thread.
|
||||
*/
|
||||
var DocumentThread = Thread.extend({
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {string} [data.messagesIDs] the list of message ids linked
|
||||
* to the document (if not given, they will be fetched before fetching the
|
||||
* messages)
|
||||
* @param {string} data.model
|
||||
* @param {integer} data.resID
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
data.id = data.resModel + '_' + data.resID;
|
||||
data.type = 'document_thread';
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// ID of the related document
|
||||
this._documentID = data.resID;
|
||||
// model of the related document
|
||||
this._documentModel = data.resModel;
|
||||
// used to handle history of messages
|
||||
this._messageIDs = data.messageIDs || [];
|
||||
// list of loaded messages
|
||||
this._messages = [];
|
||||
|
||||
// if the messageIDs haven't been given, fetch them before fetching the
|
||||
// messages
|
||||
this._mustFetchMessageIDs = !_.isArray(data.messageIDs);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add this message to this document thread.
|
||||
*
|
||||
* @override
|
||||
* @param {mail.model.Message} message
|
||||
*/
|
||||
addMessage: function (message) {
|
||||
// update internal list of messages
|
||||
this._messages.push(message);
|
||||
this._messages = _.sortBy(this._messages, function (msg) {
|
||||
return msg.getID();
|
||||
});
|
||||
// update message ids associated to this document thread
|
||||
if (!_.contains(this._messageIDs, message.getID())) {
|
||||
this._messageIDs.push(message.getID());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the model name of the document that is linked to this document thread
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
getDocumentModel: function () {
|
||||
return this._documentModel;
|
||||
},
|
||||
/**
|
||||
* Get the ID of the document that is linked to this document thread
|
||||
*
|
||||
* @returns {integer}
|
||||
*/
|
||||
getDocumentID: function () {
|
||||
return this._documentID;
|
||||
},
|
||||
/**
|
||||
* Get he list of message IDs that this document threads contain.
|
||||
*
|
||||
* @returns {integer[]}
|
||||
*/
|
||||
getMessageIDs: function () {
|
||||
return this._messageIDs;
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Get messages of the document thread
|
||||
*
|
||||
* @override
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.forceFetch] if true, fetch anyway, as user
|
||||
* clicked on 'load more'.
|
||||
* @returns {$.Promise<mail.model.Message[]>}
|
||||
*/
|
||||
getMessages: function (options) {
|
||||
var self = this;
|
||||
return this._fetchMessages(options).then(function () {
|
||||
self.call('mail_service', 'markMessagesAsRead', self._messageIDs);
|
||||
return self._messages;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* States whether the thread is linked to a document
|
||||
* Document thread's are always linked to a document.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLinkedToDocument: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Overrides this method so that all the messages of this document thread
|
||||
* are marked as read on the server.
|
||||
*
|
||||
* @override
|
||||
* @returns {$.Promise} resolved when messages have been marked as read on
|
||||
* the server.
|
||||
*/
|
||||
markAsRead: function () {
|
||||
this._super.apply(this, arguments);
|
||||
return this.call('mail_service', 'markMessagesAsRead', this._messageIDs);
|
||||
},
|
||||
/**
|
||||
* Post message for document thread
|
||||
*
|
||||
* @override
|
||||
* @param {Object} data data related to the new message
|
||||
* @return {$.Promise<Object>} resolved when the message has been sent to
|
||||
* the server, with the object message that has been sent to the server.
|
||||
*/
|
||||
postMessage: function (data) {
|
||||
var self = this;
|
||||
var resModel = this.getDocumentModel();
|
||||
var resID = this.getDocumentID();
|
||||
return this._super.apply(this, arguments)
|
||||
.then(function (messageData) {
|
||||
_.extend(messageData, {
|
||||
context: data.context,
|
||||
message_type: data.message_type,
|
||||
subtype: data.subtype,
|
||||
subtype_id: data.subtype_id,
|
||||
});
|
||||
return self._rpc({
|
||||
model: resModel,
|
||||
method: 'message_post',
|
||||
args: [resID],
|
||||
kwargs: messageData,
|
||||
})
|
||||
.then(function (messageID) {
|
||||
return self._rpc({
|
||||
model: 'mail.message',
|
||||
method: 'message_format',
|
||||
args: [[messageID]],
|
||||
})
|
||||
.then(function (messages) {
|
||||
messages[0].model = resModel;
|
||||
messages[0].res_id = resID;
|
||||
self.call('mail_service', 'addMessage', messages[0]);
|
||||
return messages[0];
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Set list of message IDs of this document thread
|
||||
*
|
||||
* Useful in order to handle message history of the document thread,
|
||||
* in particular to fetch messages when necessary and/or display 'load more'.
|
||||
*
|
||||
* @param {integer[]} messageIDs
|
||||
*/
|
||||
setMessageIDs: function (messageIDs) {
|
||||
this._mustFetchMessageIDs = false;
|
||||
this._messageIDs = messageIDs;
|
||||
},
|
||||
/**
|
||||
* Set the name of this document thread
|
||||
*
|
||||
* This is useful if the name of the document related to the document thread
|
||||
* has changed
|
||||
*
|
||||
* @param {string} newName
|
||||
*/
|
||||
setName: function (newName) {
|
||||
this._name = newName;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get most up to date messageIDs
|
||||
*
|
||||
* @private
|
||||
* @returns {$.Promise} resolved when message IDs have been fetched and set
|
||||
* in the model
|
||||
*/
|
||||
_fetchMessageIDs: function () {
|
||||
var self = this;
|
||||
var resID = this.getDocumentID();
|
||||
if (!resID || !this._mustFetchMessageIDs) {
|
||||
return $.when();
|
||||
}
|
||||
return this._rpc({
|
||||
model: this.getDocumentModel(),
|
||||
method: 'read',
|
||||
args: [[resID], ['message_ids']],
|
||||
}).then(function (result) {
|
||||
self.setMessageIDs(result[0].message_ids);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.forceFetch]
|
||||
* @returns {$.Promise} resolved when messages have been fetched + document
|
||||
* thread has updated messages
|
||||
*/
|
||||
_fetchMessages: function (options) {
|
||||
var self = this;
|
||||
return this._fetchMessageIDs().then(function () {
|
||||
var messageIDs = self._messageIDs;
|
||||
var loadedMessages = _.filter(self._messages, function (message) {
|
||||
return _.contains(messageIDs, message.getID());
|
||||
});
|
||||
var loadedMessageIDs = _.map(loadedMessages, function (message) {
|
||||
return message.getID();
|
||||
});
|
||||
|
||||
options = options || {};
|
||||
var shouldFetch = _.difference(
|
||||
messageIDs.slice(0, self._FETCH_LIMIT),
|
||||
loadedMessageIDs
|
||||
).length > 0;
|
||||
if (options.forceFetch || shouldFetch) {
|
||||
var idsToLoad = _.difference(messageIDs, loadedMessageIDs)
|
||||
.slice(0, self._FETCH_LIMIT);
|
||||
return self._rpc({
|
||||
model: 'mail.message',
|
||||
method: 'message_format',
|
||||
args: [idsToLoad],
|
||||
context: session.user_context,
|
||||
})
|
||||
.then(function (messagesData) {
|
||||
_.each(messagesData, function (messageData) {
|
||||
self.call('mail_service', 'addMessage', messageData, { silent: true });
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return $.when();
|
||||
}
|
||||
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return DocumentThread;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
odoo.define('mail.model.MailFailure', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Class = require('web.Class');
|
||||
var core = require('web.core');
|
||||
var Mixins = require('web.mixins');
|
||||
var ServicesMixin = require('web.ServicesMixin');
|
||||
var time = require('web.time');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
|
||||
/**
|
||||
* @param {mail.Manager} parent
|
||||
* @param {Object} data
|
||||
* @param {string} data.last_message_date
|
||||
* @param {integer} data.message_id
|
||||
* @param {string} data.model
|
||||
* @param {string} data.model_name
|
||||
* @param {string} data.module_icon
|
||||
* @param {Array} data.notifications
|
||||
* @param {string} data.record_name
|
||||
* @param {integer} data.res_id
|
||||
* @param {string} data.uuid
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
Mixins.EventDispatcherMixin.init.call(this, arguments);
|
||||
this.setParent(parent);
|
||||
|
||||
this._documentID = data.res_id;
|
||||
this._documentModel = data.model;
|
||||
this._lastMessageDate = moment(); // by default: current datetime
|
||||
this._messageID = data.message_id;
|
||||
this._modelName = data.model_name;
|
||||
this._moduleIcon = data.module_icon;
|
||||
this._notifications = data.notifications;
|
||||
this._recordName = data.record_name;
|
||||
this._uuid = data.uuid;
|
||||
|
||||
if (data.last_message_date) {
|
||||
this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the document model that this mail failure is linked to.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
getDocumentModel: function () {
|
||||
return this._documentModel;
|
||||
},
|
||||
/**
|
||||
* Get the document ID that this mail failure is linked to.
|
||||
*
|
||||
* @returns {integer}
|
||||
*/
|
||||
getDocumentID: function () {
|
||||
return this._documentID;
|
||||
},
|
||||
/**
|
||||
* Get the ID of the message that this mail failure is related to.
|
||||
*
|
||||
* @returns {integer}
|
||||
*/
|
||||
getMessageID: function () {
|
||||
return this._messageID;
|
||||
},
|
||||
/**
|
||||
* Get a valid object for the 'mail.preview' template
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
getPreview: function () {
|
||||
var preview = {
|
||||
body: _t("An error occured when sending an email"),
|
||||
date: this._lastMessageDate,
|
||||
documentID: this._documentID,
|
||||
documentModel: this._documentModel,
|
||||
id: 'mail_failure',
|
||||
imageSRC: this._moduleIcon,
|
||||
title: this._recordName,
|
||||
};
|
||||
return preview;
|
||||
},
|
||||
/**
|
||||
* Tell whether the mail failure comes from a message in a document thread.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLinkedToDocumentThread: function () {
|
||||
return !!(this._documentModel !== 'mail.channel' && this._documentID);
|
||||
},
|
||||
});
|
||||
|
||||
return MailFailure;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
odoo.define('mail.Model', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Class = require('web.Class');
|
||||
var Mixins = require('web.mixins');
|
||||
var ServicesMixin = require('web.ServicesMixin');
|
||||
|
||||
var MailModel = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
|
||||
/**
|
||||
* @param {mail.Manager} parent
|
||||
*/
|
||||
init: function (parent) {
|
||||
Mixins.EventDispatcherMixin.init.call(this, arguments);
|
||||
this.setParent(parent);
|
||||
},
|
||||
});
|
||||
|
||||
return MailModel;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
odoo.define('mail.model.Mailbox', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ThreadWithCache = require('mail.model.ThreadWithCache');
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* Mailboxes, called "static channels" in earlier version of Odoo, are threads
|
||||
* that are not represented on the server. We can see them as "threads that are
|
||||
* not conversations". For instance, the well-known "Inbox" folder contains a
|
||||
* list of messages, but the inbox does not represent a conversation: Inbox is
|
||||
* modeled as a mailbox.
|
||||
*/
|
||||
var Mailbox = ThreadWithCache.extend({
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} data
|
||||
* @param {string} data.id the ID of the mailbox, without the 'mailbox_'
|
||||
* prefix.
|
||||
* @param {integer} [data.mailboxCounter=0] the initial mailbox counter of
|
||||
* this mailbox.
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
// ID or mailboxes are always prefixed with 'mailbox_'
|
||||
data.id = 'mailbox_' + data.id;
|
||||
data.type = 'mailbox';
|
||||
this._super.apply(this, arguments);
|
||||
this._mailboxCounter = data.mailboxCounter || 0;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decrement the counter of this mailbox. It floors to 0.
|
||||
*
|
||||
* @param {integer} [num=1] how many, at most, the counter is decremented
|
||||
*/
|
||||
decrementMailboxCounter: function (num) {
|
||||
num = _.isNumber(num) ? num : 1;
|
||||
this._mailboxCounter = Math.max(this._mailboxCounter - num, 0);
|
||||
},
|
||||
/**
|
||||
* Get the local messages of the mailbox (by local messages, we mean
|
||||
* messages that have already been fetched from the server).
|
||||
*
|
||||
* It is possible to filter on local messages that are specific to a
|
||||
* document with the `options` parameter.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.documentModel] model of the document that the
|
||||
* local messages of inbox must be linked to.
|
||||
* @param {integer} [options.documentID] ID of the document that the local
|
||||
* messages of inbox must be linked to.
|
||||
*/
|
||||
getLocalMessages: function (options) {
|
||||
var localMessages = this._cache['[]'].messages;
|
||||
if (!options) {
|
||||
return localMessages;
|
||||
}
|
||||
if (options.documentModel && options.documentID) {
|
||||
return _.filter(localMessages, function (localMessage) {
|
||||
return localMessage.getDocumentModel() === options.documentModel &&
|
||||
localMessage.getDocumentID() === options.documentID;
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the mailbox counter of this mailbox.
|
||||
*
|
||||
* @returns {integer}
|
||||
*/
|
||||
getMailboxCounter: function () {
|
||||
return this._mailboxCounter;
|
||||
},
|
||||
/**
|
||||
* Get the preview of the messages from the inbox mailbox.
|
||||
* If there are multiple messages associated to a similar document or
|
||||
* channel, only keep the lastest message of this document/channel in the
|
||||
* previews.
|
||||
*
|
||||
* @returns {$.Promise<Object[]>}
|
||||
*/
|
||||
getMessagePreviews: function () {
|
||||
var self = this;
|
||||
|
||||
return this.getMessages().then(function (messages) {
|
||||
// pick only last message of chatter
|
||||
// items = list of objects
|
||||
// { unreadCounter: integer, message: mail.model.Message }
|
||||
var items = [];
|
||||
_.each(messages, function (message) {
|
||||
var unreadCounter = 1;
|
||||
var similarItem = _.find(items, function (item) {
|
||||
return self._areMessagesFromSameDocumentThread(item.message, message) ||
|
||||
self._areMessagesFromSameChannel(item.message, message);
|
||||
});
|
||||
if (similarItem) {
|
||||
unreadCounter = similarItem.unreadCounter + 1;
|
||||
var index = _.findIndex(items, similarItem);
|
||||
items[index] = {
|
||||
unreadCounter: unreadCounter,
|
||||
message: message,
|
||||
};
|
||||
} else {
|
||||
items.push({
|
||||
unreadCounter: unreadCounter,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
return _.map(items, function (item) {
|
||||
return _.extend(item.message.getPreview(), {
|
||||
unreadCounter: item.unreadCounter,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Increment the counter of this mailbox
|
||||
*
|
||||
* @param {integer} [num=1] how many, the counter is incremented
|
||||
*/
|
||||
incrementMailboxCounter: function (num) {
|
||||
num = _.isNumber(num) ? num : 1;
|
||||
this._mailboxCounter = Math.max(this._mailboxCounter + num, 0);
|
||||
},
|
||||
/**
|
||||
* Marks all messages from the mailbox as read. At the moment, this method
|
||||
* makes only sense for 'Inbox'.
|
||||
*
|
||||
* @param {Array} domain
|
||||
* @return {$.Promise} resolved when all messages have been marked as read
|
||||
* on the server
|
||||
*/
|
||||
markAllMessagesAsRead: function (domain) {
|
||||
if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) {
|
||||
return this._rpc({
|
||||
model: 'mail.message',
|
||||
method: 'mark_all_as_read',
|
||||
kwargs: {
|
||||
channel_ids: [],
|
||||
domain: domain,
|
||||
},
|
||||
});
|
||||
}
|
||||
return $.when();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tell whether the two messages originate from the same channel.
|
||||
* If any one of them does not originate from a channel, returns false.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Message} message1
|
||||
* @param {mail.model.Message} message2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_areMessagesFromSameChannel: function (message1, message2) {
|
||||
return message1.originatesFromChannel() &&
|
||||
message2.originatesFromChannel() &&
|
||||
message1.getOriginChannelID() === message2.getOriginChannelID();
|
||||
},
|
||||
/**
|
||||
* Tell whether the two messages are linked to the same document thread.
|
||||
* If any one of them is not linked to a document, returns false.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Message} message1
|
||||
* @param {mail.model.Message} message2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_areMessagesFromSameDocumentThread: function (message1, message2) {
|
||||
return message1.isLinkedToDocumentThread() &&
|
||||
message2.isLinkedToDocumentThread() &&
|
||||
message1.getDocumentModel() === message2.getDocumentModel() &&
|
||||
message1.getDocumentID() === message2.getDocumentID();
|
||||
},
|
||||
/**
|
||||
* Get the domain to fetch all the messages in the current mailbox
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Array}
|
||||
* @throws on missing domain for the provided mailbox (mailboxes should
|
||||
* always have a domain on messages to fetch)
|
||||
*/
|
||||
_getThreadDomain: function () {
|
||||
if (this._id === 'mailbox_inbox') {
|
||||
return [['needaction', '=', true]];
|
||||
} else if (this._id === 'mailbox_starred') {
|
||||
return [['starred', '=', true]];
|
||||
} else if (this._id === 'mailbox_moderation') {
|
||||
return [['need_moderation', '=', true]];
|
||||
} else {
|
||||
throw _t(_.str("Missing domain for mailbox with ID '%s'", this._id));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return Mailbox;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
odoo.define('mail.model.AbstractMessage', function (require) {
|
||||
"use strict";
|
||||
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var Class = require('web.Class');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var time = require('web.time');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* This is an abstract class for modeling messages in JS.
|
||||
* The purpose of this interface is to make im_livechat compatible with
|
||||
* mail.widget.Thread, as this widget was designed to work with messages that
|
||||
* are instances of mail.model.Messages.
|
||||
*
|
||||
* Ideally, im_livechat should also handle mail.model.Message, but this is not
|
||||
* feasible for the moment, as mail.model.Message requires mail.Manager to work,
|
||||
* and this module should not leak outside of the backend, hence the use of
|
||||
* mail.model.AbstractMessage as a work-around.
|
||||
*/
|
||||
var AbstractMessage = Class.extend({
|
||||
|
||||
/**
|
||||
* @param {Widget} parent
|
||||
* @param {Object} data
|
||||
* @param {Array} [data.attachment_ids=[]]
|
||||
* @param {Array} [data.author_id]
|
||||
* @param {string} [data.body = ""]
|
||||
* @param {string} [data.date] the server-format date time of the message.
|
||||
* If not provided, use current date time for this message.
|
||||
* @param {integer} data.id
|
||||
* @param {boolean} [data.is_discussion = false]
|
||||
* @param {boolean} [data.is_notification = false]
|
||||
* @param {string} [data.message_type = undefined]
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
this._attachmentIDs = data.attachment_ids || [];
|
||||
this._body = data.body || "";
|
||||
// by default: current datetime
|
||||
this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment();
|
||||
this._id = data.id;
|
||||
this._isDiscussion = data.is_discussion;
|
||||
this._isNotification = data.is_notification;
|
||||
// if set, the message should display the author of the message
|
||||
this._displayAuthor = false;
|
||||
this._serverAuthorID = data.author_id;
|
||||
this._type = data.message_type || undefined;
|
||||
|
||||
this._processAttachmentURL();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set whether the message should display the author or not
|
||||
*
|
||||
* @param {boolean} bool if set, display the author of this message
|
||||
*/
|
||||
displayAuthor: function (bool) {
|
||||
this._displayAuthor = bool;
|
||||
},
|
||||
/**
|
||||
* Get the list of files attached to this message.
|
||||
* Note that attachments are stored with server-format
|
||||
*
|
||||
* @return {Object[]}
|
||||
*/
|
||||
getAttachments: function () {
|
||||
return this._attachmentIDs;
|
||||
},
|
||||
/**
|
||||
* Get the server ID (number) of the author of this message
|
||||
* If there are no author, return -1;
|
||||
*
|
||||
* @return {integer}
|
||||
*/
|
||||
getAuthorID: function () {
|
||||
if (!this.hasAuthor()) {
|
||||
return -1;
|
||||
}
|
||||
return this._serverAuthorID[0];
|
||||
},
|
||||
/**
|
||||
* Get the relative url of the avatar to display next to the message
|
||||
*
|
||||
* @abstract
|
||||
* @return {string}
|
||||
*/
|
||||
getAvatarSource: function () {},
|
||||
/**
|
||||
* Get the body content of this message
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getBody: function () {
|
||||
return this._body;
|
||||
},
|
||||
/**
|
||||
* @return {moment}
|
||||
*/
|
||||
getDate: function () {
|
||||
return this._date;
|
||||
},
|
||||
/**
|
||||
* Get the date day of this message
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getDateDay: function () {
|
||||
var date = this.getDate().format('YYYY-MM-DD');
|
||||
if (date === moment().format('YYYY-MM-DD')) {
|
||||
return _t("Today");
|
||||
} else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
|
||||
return _t("Yesterday");
|
||||
}
|
||||
return this.getDate().format('LL');
|
||||
},
|
||||
/**
|
||||
* Get the name of the author, if there is an author of this message
|
||||
* If there are no author of this message, returns 'null'
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getDisplayedAuthor: function () {
|
||||
return this.hasAuthor() ? this._getAuthorName() : null;
|
||||
},
|
||||
/**
|
||||
* Get the server ID (number) of this message
|
||||
*
|
||||
* @override
|
||||
* @return {integer}
|
||||
*/
|
||||
getID: function () {
|
||||
return this._id;
|
||||
},
|
||||
/**
|
||||
* Get the list of images attached to this message.
|
||||
* Note that attachments are stored with server-format
|
||||
*
|
||||
* @return {Object[]}
|
||||
*/
|
||||
getImageAttachments: function () {
|
||||
return _.filter(this.getAttachments(), function (attachment) {
|
||||
return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get the list of non-images attached to this message.
|
||||
* Note that attachments are stored with server-format
|
||||
*
|
||||
* @return {Object[]}
|
||||
*/
|
||||
getNonImageAttachments: function () {
|
||||
return _.difference(this.getAttachments(), this.getImageAttachments());
|
||||
},
|
||||
/**
|
||||
* Get the time elapsed between sent message and now
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getTimeElapsed: function () {
|
||||
return mailUtils.timeFromNow(this.getDate());
|
||||
},
|
||||
/**
|
||||
* Get the type of message (e.g. 'comment', 'email', 'notification', ...)
|
||||
* By default, messages are of type 'undefined'
|
||||
*
|
||||
* @override
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
getType: function () {
|
||||
return this._type;
|
||||
},
|
||||
/**
|
||||
* State whether this message contains some attachments.
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAttachments: function () {
|
||||
return this.getAttachments().length > 0;
|
||||
},
|
||||
/**
|
||||
* State whether this message has an author
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAuthor: function () {
|
||||
return !!(this._serverAuthorID && this._serverAuthorID[0]);
|
||||
},
|
||||
/**
|
||||
* State whether this message contains some customer email data
|
||||
* By default, messages do not have any customer email data
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasCustomerEmailData: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message has an email of its sender.
|
||||
* By default, messages do not have any email of its sender.
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
hasEmailFrom: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this image contains images attachments
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasImageAttachments: function () {
|
||||
return _.some(this.getAttachments(), function (attachment) {
|
||||
return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
|
||||
});
|
||||
},
|
||||
/**
|
||||
* State whether this image contains non-images attachments
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasNonImageAttachments: function () {
|
||||
return _.some(this.getAttachments(), function (attachment) {
|
||||
return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image');
|
||||
});
|
||||
},
|
||||
/**
|
||||
* State whether this message originates from a channel.
|
||||
* By default, messages do not originate from a channel.
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
originatesFromChannel: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message has a subject
|
||||
* By default, messages do not have any subject.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasSubject: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* By default, messages do not have any subtype description
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasSubtypeDescription: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message contains some tracking values
|
||||
* By default, messages do not have any tracking values.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasTrackingValues: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message is a discussion
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isDiscussion: function () {
|
||||
return this._isDiscussion;
|
||||
},
|
||||
/**
|
||||
* State whether this message is linked to a document
|
||||
* By default, messages are not linked to a document.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isLinkedToDocument: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message is needaction
|
||||
* By default, messages are not needaction.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isNeedaction: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message is a note (i.e. a message from "Log note")
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isNote: function () {
|
||||
return this._isNote;
|
||||
},
|
||||
/**
|
||||
* State whether this message is a notification
|
||||
*
|
||||
* User notifications are defined as either
|
||||
* - notes
|
||||
* - pushed to user Inbox or email through classic notification process
|
||||
* - not linked to any document, meaning model and res_id are void
|
||||
*
|
||||
* This is useful in order to display white background for user
|
||||
* notifications in chatter
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNotification: function () {
|
||||
return this._isNotification;
|
||||
},
|
||||
/**
|
||||
* State whether this message is starred
|
||||
* By default, messages are not starred.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isStarred: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message is a system notification
|
||||
* By default, messages are not system notifications
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
isSystemNotification: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* States whether the current message needs moderation in general.
|
||||
* By default, messages do not require any moderation.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
needsModeration: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* State whether this message should display the author
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
shouldDisplayAuthor: function () {
|
||||
return this._displayAuthor;
|
||||
},
|
||||
/**
|
||||
* State whether this message should redirect to the author
|
||||
* when clicking on the author of this message.
|
||||
*
|
||||
* Do not redirect on author clicked of self-posted messages.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
shouldRedirectToAuthor: function () {
|
||||
return !this._isAuthor();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the name of the author of this message.
|
||||
* If there are no author of this messages, returns '' (empty string).
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_getAuthorName: function () {
|
||||
if (!this.hasAuthor()) {
|
||||
return "";
|
||||
}
|
||||
return this._serverAuthorID[1];
|
||||
},
|
||||
/**
|
||||
* State whether the current user is the author of this message
|
||||
*
|
||||
* @private
|
||||
* @return {boolean}
|
||||
*/
|
||||
_isAuthor: function () {
|
||||
return this.hasAuthor() && (this.getAuthorID() === session.partner_id);
|
||||
},
|
||||
/**
|
||||
* Compute url of attachments of this message
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_processAttachmentURL: function () {
|
||||
_.each(this.getAttachments(), function (attachment) {
|
||||
attachment.url = '/web/content/' + attachment.id + '?download=true';
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return AbstractMessage;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,780 @@
|
||||
odoo.define('mail.model.Message', function (require) {
|
||||
"use strict";
|
||||
|
||||
var emojis = require('mail.emojis');
|
||||
var AbstractMessage = require('mail.model.AbstractMessage');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var core = require('web.core');
|
||||
var Mixins = require('web.mixins');
|
||||
var ServicesMixin = require('web.ServicesMixin');
|
||||
var session = require('web.session');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* This is the main class for the modeling messages in JS.
|
||||
* Such messages are stored in the mail manager, and any piece of JS code whose
|
||||
* logic relies on threads must ideally interact with such objects.
|
||||
*/
|
||||
var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
|
||||
/**
|
||||
* @override
|
||||
* @param {web.Manager} parent
|
||||
* @param {Object} data
|
||||
* @param {string} [data.body = ""]
|
||||
* @param {(string|integer)[]} [data.channel_ids]
|
||||
* @param {Object[]} [data.customer_email_data]
|
||||
* @param {string} [data.customer_email_status]
|
||||
* @param {string} [data.email_from]
|
||||
* @param {string} [data.info]
|
||||
* @param {string} [data.model]
|
||||
* @param {string} [data.moderation_status='accepted']
|
||||
* @param {string} [data.module_icon]
|
||||
* @param {Array} [data.needaction_partner_ids = []]
|
||||
* @param {string} [data.record_name]
|
||||
* @param {integer} [data.res_id]
|
||||
* @param {Array} [data.starred_partner_ids = []]
|
||||
* @param {string} [data.subject]
|
||||
* @param {string} [data.subtype_description]
|
||||
* @param {Object[]} [data.tracking_value_ids]
|
||||
* @param {Object[]} emojis
|
||||
*/
|
||||
init: function (parent, data, emojis) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
Mixins.EventDispatcherMixin.init.call(this, arguments);
|
||||
this.setParent(parent);
|
||||
|
||||
this._customerEmailData = data.customer_email_data || [];
|
||||
this._customerEmailStatus = data.customer_email_status;
|
||||
this._documentModel = data.model;
|
||||
this._documentName = data.record_name;
|
||||
this._documentID = data.res_id;
|
||||
this._emailFrom = data.email_from;
|
||||
this._info = data.info;
|
||||
this._moduleIcon = data.module_icon;
|
||||
this._needactionPartnerIDs = data.needaction_partner_ids || [];
|
||||
this._starredPartnerIDs = data.starred_partner_ids || [];
|
||||
this._subject = data.subject;
|
||||
this._subtypeDescription = data.subtype_description;
|
||||
this._threadIDs = data.channel_ids || [];
|
||||
this._trackingValueIDs = data.tracking_value_ids;
|
||||
|
||||
this._moderationStatus = data.moderation_status || 'accepted';
|
||||
|
||||
this._processBody(emojis);
|
||||
this._processMailboxes();
|
||||
this._processModeration();
|
||||
this._processDocumentThread();
|
||||
this._processTrackingValues();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} data
|
||||
*/
|
||||
addCustomerEmailData: function (data) {
|
||||
this._customerEmailData.push(data);
|
||||
},
|
||||
/**
|
||||
* Get the name of the author of this message
|
||||
* If there are no author, return "".
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getAuthorName: function () {
|
||||
return this._getAuthorName();
|
||||
},
|
||||
/**
|
||||
* Get the relative url of the avatar to display next to the message
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getAvatarSource: function () {
|
||||
if (this._isOdoobotAuthor()) {
|
||||
return '/mail/static/src/img/odoo_o.png';
|
||||
} else if (this.hasAuthor()) {
|
||||
return '/web/image/res.partner/' + this.getAuthorID() + '/image_small';
|
||||
} else if (this.getType() === 'email') {
|
||||
return '/mail/static/src/img/email_icon.png';
|
||||
}
|
||||
return '/mail/static/src/img/smiley/avatar.jpg';
|
||||
},
|
||||
/**
|
||||
* Get the customer email data of this email, if any.
|
||||
* If this message has no such data, returns 'undefined'
|
||||
*
|
||||
* @return {Object[]|undefined}
|
||||
*/
|
||||
getCustomerEmailData: function () {
|
||||
if (!this.hasCustomerEmailData()) {
|
||||
return undefined;
|
||||
}
|
||||
return this._customerEmailData;
|
||||
},
|
||||
/**
|
||||
* Get the customer email status of this email, if any.
|
||||
* If this message has no such data, returns 'undefined'
|
||||
*
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
getCustomerEmailStatus: function () {
|
||||
if (!this.hasCustomerEmailData()) {
|
||||
return undefined;
|
||||
}
|
||||
return this._customerEmailStatus;
|
||||
},
|
||||
/**
|
||||
* Get the text to display for the author of the message
|
||||
*
|
||||
* Rule of precedence for the displayed author:
|
||||
*
|
||||
* author name > sender email > "anonymous"
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getDisplayedAuthor: function () {
|
||||
var res = this._super.apply(this, arguments);
|
||||
if (res) {
|
||||
return res;
|
||||
} else {
|
||||
return this.hasEmailFrom() ? this.getEmailFrom() :
|
||||
_t("Anonymous");
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the ID of the document that this message is linked.
|
||||
*
|
||||
* @override
|
||||
* @return {integer}
|
||||
*/
|
||||
getDocumentID: function () {
|
||||
return this._documentID;
|
||||
},
|
||||
/**
|
||||
* Get the model of the document that this message is linked.
|
||||
* If this message is not linked to a document, returns "".
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getDocumentModel: function () {
|
||||
return this._documentModel;
|
||||
},
|
||||
/**
|
||||
* Get the name of the document that this message is linked.
|
||||
* If this message is not linked to a document, returns "".
|
||||
*
|
||||
* It uses the name of an existing document thread, in order to have the
|
||||
* updated document name. If there is no document thread yet, use the
|
||||
* currently aware document name.
|
||||
*
|
||||
* As a result, this method should ideally be called internally, in order
|
||||
* to avoid having different names for messages linked to a same modified
|
||||
* document.
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getDocumentName: function () {
|
||||
if (!this.isLinkedToDocumentThread()) {
|
||||
return "";
|
||||
}
|
||||
var model = this.getDocumentModel();
|
||||
var id = this.getDocumentID();
|
||||
var documentThread = this.call('mail_service', 'getDocumentThread', model, id);
|
||||
if (documentThread) {
|
||||
this._documentName = documentThread.getName();
|
||||
}
|
||||
return this._documentName;
|
||||
},
|
||||
/**
|
||||
* Get the email of the sender of this message.
|
||||
* If this email has no sender email, returns "".
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getEmailFrom: function () {
|
||||
if (!this.hasEmailFrom()) {
|
||||
return "";
|
||||
}
|
||||
return this._emailFrom;
|
||||
},
|
||||
/**
|
||||
* Get the ID of the channel that this message originates from.
|
||||
* If this message does not originate from a channel, returns `-1`.
|
||||
*
|
||||
* @override
|
||||
* @return {integer}
|
||||
*/
|
||||
getOriginChannelID: function () {
|
||||
if (!this.originatesFromChannel()) {
|
||||
return -1;
|
||||
}
|
||||
return this._documentID;
|
||||
},
|
||||
/**
|
||||
* Get the name of the channel that this message originates from.
|
||||
* If this message does not originate from a channel, returns "".
|
||||
*
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
getOriginChannelName: function () {
|
||||
if (!this.originatesFromChannel()) {
|
||||
return "";
|
||||
}
|
||||
var originChannelID = this.getOriginChannelID();
|
||||
var channel = originChannelID && this.call(
|
||||
'mail_service',
|
||||
'getChannel',
|
||||
originChannelID);
|
||||
if (!channel) {
|
||||
return "";
|
||||
}
|
||||
return channel.getName();
|
||||
},
|
||||
/**
|
||||
* Returns the information required to render the preview of this channel.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
getPreview: function () {
|
||||
var id, title;
|
||||
if (this.isLinkedToDocumentThread()) {
|
||||
id = this.getDocumentModel() + '_' + this.getDocumentID();
|
||||
title = this.getDocumentName();
|
||||
} else {
|
||||
id = 'mailbox_inbox';
|
||||
title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor();
|
||||
}
|
||||
return {
|
||||
author: this.getDisplayedAuthor(),
|
||||
body: mailUtils.parseAndTransform(this.getBody(), mailUtils.inline),
|
||||
date: this.getDate(),
|
||||
documentModel: this.getDocumentModel(),
|
||||
documentID: this.getDocumentID(),
|
||||
id: id,
|
||||
imageSRC: this._getModuleIcon() || this.getAvatarSource(),
|
||||
status: this.status,
|
||||
title: title,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get the subject of this message
|
||||
* If this message has no subject, returns "".
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getSubject: function () {
|
||||
if (!this.hasSubject()) {
|
||||
return "";
|
||||
}
|
||||
return this._subject;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getSubtypeDescription: function () {
|
||||
return this._subtypeDescription;
|
||||
},
|
||||
/**
|
||||
* Get the list of thread IDs that this message is linked to
|
||||
* If this message is not linked to a thread, returns 'undefined'
|
||||
*
|
||||
* @return {string[]|undefined} list of thread IDs, if any
|
||||
*/
|
||||
getThreadIDs: function () {
|
||||
return this._threadIDs;
|
||||
},
|
||||
/**
|
||||
* Get the tracking values of this message
|
||||
* If this message has no tracking values, returns 'undefined'
|
||||
*
|
||||
* @override
|
||||
* @return {Object[]|undefined}
|
||||
*/
|
||||
getTrackingValues: function () {
|
||||
if (!this.hasTrackingValues()) {
|
||||
return undefined;
|
||||
}
|
||||
return this._trackingValueIDs;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getURL: function () {
|
||||
return session.url('/mail/view?message_id=' + this._id);
|
||||
},
|
||||
/**
|
||||
* State whether this message contains some customer email data
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasCustomerEmailData: function () {
|
||||
return !!(this._customerEmailData && (this._customerEmailData.length > 0));
|
||||
},
|
||||
/**
|
||||
* State whether this message has an email of its sender.
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
hasEmailFrom: function () {
|
||||
return !!(this._emailFrom);
|
||||
},
|
||||
/**
|
||||
* State whether this message has a subject
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasSubject: function () {
|
||||
return !!(this._subject);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasSubtypeDescription: function () {
|
||||
return !!(this._subtypeDescription);
|
||||
},
|
||||
/**
|
||||
* State whether this message contains some tracking values
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasTrackingValues: function () {
|
||||
return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0));
|
||||
},
|
||||
/**
|
||||
* State whether the current user is the author of this message
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isAuthor: function () {
|
||||
return this._isAuthor();
|
||||
},
|
||||
/**
|
||||
* State whether this message is linked to a document thread (not channel)
|
||||
*
|
||||
* Usually, if this is true, then this message comes from a document thread,
|
||||
* but the document model could be a channel. In that case, the document
|
||||
* resID tells the channel that this message originally comes from.
|
||||
*
|
||||
* To detect whether the message comes from a channel, see the method
|
||||
* 'originatesFromChannel'.
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
isLinkedToDocumentThread: function () {
|
||||
return !!(this._documentModel !== 'mail.channel' && this._documentID);
|
||||
},
|
||||
/**
|
||||
* States whether the current message needs moderation in general.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
needsModeration: function () {
|
||||
return this._moderationStatus === 'pending_moderation';
|
||||
},
|
||||
/**
|
||||
* States whether the current message needs moderation by the current user.
|
||||
* Such a message should be in the moderation mailbox.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
needsModerationByUser: function () {
|
||||
return _.contains(this._threadIDs, 'mailbox_moderation');
|
||||
},
|
||||
/**
|
||||
* State whether this message is needaction
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNeedaction: function () {
|
||||
return _.contains(this._threadIDs, 'mailbox_inbox');
|
||||
},
|
||||
/**
|
||||
* State whether this message is starred
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isStarred: function () {
|
||||
return _.contains(this._threadIDs, 'mailbox_starred');
|
||||
},
|
||||
/**
|
||||
* State whether this message is a system notification
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSystemNotification: function () {
|
||||
return (
|
||||
this.getType() === 'notification' &&
|
||||
this._documentModel === 'mail.channel'
|
||||
) || this._isTransient();
|
||||
},
|
||||
/**
|
||||
* States whether the message originates from a channel or not
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
originatesFromChannel: function () {
|
||||
return this._documentModel === 'mail.channel';
|
||||
},
|
||||
/**
|
||||
* Unregister thread with ID `threadID` from this message
|
||||
*
|
||||
* @param {string|integer} threadID ID of thread
|
||||
*/
|
||||
removeThread: function (threadID) {
|
||||
this._threadIDs = _.without(this._threadIDs, threadID);
|
||||
},
|
||||
/**
|
||||
* Update the moderation status of the message, so that it is now accepted
|
||||
* or rejected. When the message is accepted, it may be linked to more
|
||||
* threads, which is the case for relay channels on moderated channels.
|
||||
*
|
||||
* @param {string} newModerationStatus ['accepted', 'rejected']
|
||||
* @param {Object} [options]
|
||||
* @param {Object} [options.additionalThreadIDs] contains additional thread
|
||||
* IDs to be registered on the message.
|
||||
*/
|
||||
setModerationStatus: function (newModerationStatus, options) {
|
||||
var self = this;
|
||||
this._moderationStatus = newModerationStatus;
|
||||
if (newModerationStatus === 'accepted' && options) {
|
||||
_.each(options.additionalThreadIDs, function (threadID) {
|
||||
self._addThread(threadID);
|
||||
});
|
||||
}
|
||||
this._warnMessageModerated();
|
||||
},
|
||||
/**
|
||||
* Set whether the message is starred or not.
|
||||
* If it is starred, the message is moved to the "Starred" mailbox.
|
||||
* Note that this function only applies it locally, the server is not aware
|
||||
*
|
||||
* @param {boolean} starred if set, the message is starred
|
||||
*/
|
||||
setStarred: function (starred) {
|
||||
if (starred) {
|
||||
this._addThread('mailbox_starred');
|
||||
} else {
|
||||
this.removeThread('mailbox_starred');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* State whether this message should display the subject
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
shouldDisplaySubject: function () {
|
||||
return this.hasSubject() &&
|
||||
this.getType() !== 'notification' &&
|
||||
!this.originatesFromChannel();
|
||||
},
|
||||
/**
|
||||
* State whether this message should redirect to the author
|
||||
* when clicking on the author of this message.
|
||||
*
|
||||
* Do not redirect on author clicked of self-posted or Odoobot messages
|
||||
* (note: Odoobot is the default author of transient messages)
|
||||
*
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
shouldRedirectToAuthor: function () {
|
||||
return this._super.apply(this, arguments) && !this._isOdoobotAuthor();
|
||||
},
|
||||
/**
|
||||
* Toggle the star status of the message
|
||||
*
|
||||
* It relies on the star status of the message from the date of the server.
|
||||
* The star status is updated from a 'toggle_star' notification on the
|
||||
* longpoll bus
|
||||
*
|
||||
* @see {mail.Manager.Notification} for the receipt of 'toggle_star'
|
||||
* notification after this rpc.
|
||||
*
|
||||
* @return {$.Promise}
|
||||
*/
|
||||
toggleStarStatus: function () {
|
||||
return this._rpc({
|
||||
model: 'mail.message',
|
||||
method: 'toggle_message_starred',
|
||||
args: [[this._id]],
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Update the customer email status
|
||||
*
|
||||
* @param {string} newCustomerEmailStatus
|
||||
*/
|
||||
updateCustomerEmailStatus: function (newCustomerEmailStatus) {
|
||||
this._customerEmailStatus = newCustomerEmailStatus;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a thread to the message
|
||||
* Useful when you mark a message as 'to do'.
|
||||
* This message will be available in 'Starred' mailbox.
|
||||
*
|
||||
* @private
|
||||
* @param {string|integer} threadID
|
||||
*/
|
||||
_addThread: function (threadID) {
|
||||
if (!this._threadIDs) {
|
||||
this._threadIDs = [];
|
||||
}
|
||||
this._threadIDs.push(threadID);
|
||||
this._threadIDs = _.uniq(this._threadIDs);
|
||||
},
|
||||
/**
|
||||
* Get the name of the author of this message.
|
||||
* If Odoobot is the author (default author for transient messages),
|
||||
* returns 'Odoobot'.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_getAuthorName: function () {
|
||||
if (this._isOdoobotAuthor()) {
|
||||
return "Odoobot";
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @return {string}
|
||||
*/
|
||||
_getModuleIcon: function () {
|
||||
return this._moduleIcon;
|
||||
},
|
||||
/**
|
||||
* State if the author of this message is Odoobot
|
||||
* This is the default author for transient messages.
|
||||
*
|
||||
* @private
|
||||
* @return {boolean}
|
||||
*/
|
||||
_isOdoobotAuthor: function () {
|
||||
return this._serverAuthorID === this.call('mail_service', 'getOdoobotID');
|
||||
},
|
||||
/**
|
||||
* State whether the message is transient or not
|
||||
*
|
||||
* @private
|
||||
* @return {boolean}
|
||||
*/
|
||||
_isTransient: function () {
|
||||
return this._info === 'transient_message';
|
||||
},
|
||||
/**
|
||||
* Convert the server-format body of the message to the client-format.
|
||||
* Basically, it processes emojis and url.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_processBody: function () {
|
||||
var self = this;
|
||||
|
||||
_.each(emojis, function (emoji) {
|
||||
var unicode = emoji.unicode;
|
||||
var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", 'g');
|
||||
var originalBody = self.body;
|
||||
self._body = self._body.replace(regexp,
|
||||
' <span class="o_mail_emoji">' + unicode + '</span> ');
|
||||
// Idiot-proof limit. If the user had the amazing idea of
|
||||
// copy-pasting thousands of emojis, the image rendering can lead
|
||||
// to memory overflow errors on some browsers (e.g. Chrome). Set an
|
||||
// arbitrary limit to 200 from which we simply don't replace them
|
||||
// (anyway, they are already replaced by the unicode counterpart).
|
||||
if (_.str.count(self._body, 'o_mail_emoji') > 200) {
|
||||
self._body = originalBody;
|
||||
}
|
||||
});
|
||||
|
||||
// add anchor tags to urls
|
||||
self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink);
|
||||
},
|
||||
/**
|
||||
* If the message is linked to a document, link this message to the related
|
||||
* document thread. If there is no document thread yet, create it
|
||||
*/
|
||||
_processDocumentThread: function () {
|
||||
if (!this.isLinkedToDocumentThread()) {
|
||||
return;
|
||||
}
|
||||
var resModel = this.getDocumentModel();
|
||||
var resID = this.getDocumentID();
|
||||
var documentThread = this.call('mail_service', 'getOrAddDocumentThread', {
|
||||
resModel: resModel,
|
||||
resID: resID,
|
||||
name: this.getDocumentName(),
|
||||
});
|
||||
this._threadIDs.push(documentThread.getID());
|
||||
},
|
||||
/**
|
||||
* Set the appropriate mailboxes to this message based on server data
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_processMailboxes: function () {
|
||||
if (_.contains(this._needactionPartnerIDs, session.partner_id)) {
|
||||
this._setNeedaction(true);
|
||||
}
|
||||
if (_.contains(this._starredPartnerIDs, session.partner_id)) {
|
||||
this.setStarred(true);
|
||||
}
|
||||
if (
|
||||
this.originatesFromChannel() &&
|
||||
_.contains(
|
||||
this.call('mail_service', 'getModeratedChannelIDs'),
|
||||
this.getOriginChannelID()
|
||||
) &&
|
||||
this.needsModeration()
|
||||
) {
|
||||
this._setModeratedByUser(true);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Do some extra processing at message init, related to the
|
||||
* moderated status of the message.
|
||||
*
|
||||
* If the message needs moderation, it is not yet linked to the
|
||||
* moderated channel server-side. Therefore, it is not registered
|
||||
* in the list of threadIDs, as it is built on server-side information
|
||||
* at message initialisation (using data.channel_ids).
|
||||
*
|
||||
* Since the web client uses the list of thread IDs to show visually the
|
||||
* message in a thread, we should hack the response of the server so that
|
||||
* it assumes the message really belongs to this thread.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_processModeration: function () {
|
||||
if (this.needsModeration()) {
|
||||
// the message is not linked to the moderated channel on the
|
||||
// server, therefore this message has not this channel in
|
||||
// channel_ids. Here, just to show this message in the channel
|
||||
// visually, it links this message to the channel
|
||||
this._threadIDs.push(this.getOriginChannelID());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Process the tracking values on message creation, which
|
||||
* basically format date to the local only once by message
|
||||
*
|
||||
* Cannot be done in preprocess, since it alter the original value
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_processTrackingValues: function () {
|
||||
if (this.hasTrackingValues()) {
|
||||
_.each(this.getTrackingValues(), function (trackingValue) {
|
||||
if (trackingValue.field_type === 'datetime') {
|
||||
if (trackingValue.old_value) {
|
||||
trackingValue.old_value =
|
||||
moment
|
||||
.utc(trackingValue.old_value)
|
||||
.local()
|
||||
.format('LLL');
|
||||
}
|
||||
if (trackingValue.new_value) {
|
||||
trackingValue.new_value =
|
||||
moment
|
||||
.utc(trackingValue.new_value)
|
||||
.local()
|
||||
.format('LLL');
|
||||
}
|
||||
} else if (trackingValue.field_type === 'date') {
|
||||
if (trackingValue.old_value) {
|
||||
trackingValue.old_value =
|
||||
moment(trackingValue.old_value)
|
||||
.local()
|
||||
.format('LL');
|
||||
}
|
||||
if (trackingValue.new_value) {
|
||||
trackingValue.new_value =
|
||||
moment(trackingValue.new_value)
|
||||
.local()
|
||||
.format('LL');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set whether the message is moderated by current user or not.
|
||||
* If it is moderated by the current user, the message is moved to the
|
||||
* "Moderation" mailbox. Note that this function only applies it locally,
|
||||
* the server is not aware
|
||||
*
|
||||
* @private
|
||||
* @param {boolean} moderated if set, the message is moderated by user
|
||||
*/
|
||||
_setModeratedByUser: function (moderated) {
|
||||
if (moderated) {
|
||||
this._addThread('mailbox_moderation');
|
||||
} else {
|
||||
this.removeThread('mailbox_moderation');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set whether the message is needaction or not.
|
||||
* If it is needaction, the message is moved to the "Inbox" mailbox.
|
||||
* Note that this function only applies it locally, the server is not aware
|
||||
*
|
||||
* @private
|
||||
* @param {boolean} needaction if set, the message is needaction
|
||||
*/
|
||||
_setNeedaction: function (needaction) {
|
||||
if (needaction) {
|
||||
this._addThread('mailbox_inbox');
|
||||
} else {
|
||||
this.removeThread('mailbox_inbox');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_warnMessageModerated: function () {
|
||||
var mailBus = this.call('mail_service', 'getMailBus');
|
||||
if (this.needsModerationByUser()) {
|
||||
var moderationBox = this.call('mail_service', 'getMailbox', 'moderation');
|
||||
moderationBox.decrementMailboxCounter();
|
||||
moderationBox.removeMessage(this.getID());
|
||||
mailBus.trigger('update_moderation_counter');
|
||||
}
|
||||
if (this._moderationStatus !== 'accepted') {
|
||||
this.call('mail_service', 'removeMessageFromThreads', this);
|
||||
}
|
||||
mailBus.trigger('update_message', this);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return Message;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
odoo.define('mail.model.Thread', function (require) {
|
||||
"use strict";
|
||||
|
||||
var emojis = require('mail.emojis');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var Class = require('web.Class');
|
||||
var Mixins = require('web.mixins');
|
||||
var ServicesMixin = require('web.ServicesMixin');
|
||||
|
||||
/**
|
||||
* This is the super class modeling threads in JS.
|
||||
* Such threads are stored in the mail manager, and any piece of JS code whose
|
||||
* logic relies on threads must ideally interact with such objects.
|
||||
*
|
||||
* In particular, channels and mailboxes are two different kinds of threads.
|
||||
*/
|
||||
var Thread = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
|
||||
/**
|
||||
* @param {mail.Manager} parent
|
||||
* @param {Object} data
|
||||
* @param {string} [data.channel_type]
|
||||
* @param {integer|string} data.id
|
||||
* @param {string} data.name
|
||||
* @param {string} [data.type]
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
Mixins.EventDispatcherMixin.init.call(this, arguments);
|
||||
this.setParent(parent);
|
||||
|
||||
this._id = data.id;
|
||||
|
||||
// threads are not detached by default
|
||||
this._detached = false;
|
||||
// threads are unfolded by default
|
||||
this._folded = false;
|
||||
// if this._massMailing is set, display subject on messages, use
|
||||
// extended composer and show "Send by messages by email" on discuss
|
||||
// sidebar
|
||||
this._massMailing = false;
|
||||
this._name = data.name;
|
||||
// on 1st request to getPreview, fetch data if incomplete. Otherwise it
|
||||
// means that there is no message in this channel.
|
||||
this._previewed = false;
|
||||
this._type = data.type || data.channel_type;
|
||||
// any new message that has not been read yet
|
||||
this._unreadCounter = 0;
|
||||
// max number of fetched messages from the server
|
||||
this._FETCH_LIMIT = 30;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a message to this thread.
|
||||
*
|
||||
* @abstract
|
||||
* @param {mail.model.Message} message
|
||||
*/
|
||||
addMessage: function (message) {},
|
||||
/**
|
||||
* Updates the _detached state of the thread. Must be overriden to reflect
|
||||
* the new state in the interface.
|
||||
*/
|
||||
close: function () {
|
||||
this._detached = false;
|
||||
this._folded = false;
|
||||
this._warnUpdatedWindowState();
|
||||
},
|
||||
/**
|
||||
* Updates the _detached state of the thread. Must be overriden to reflect
|
||||
* the new state in the interface.
|
||||
*
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.keepFoldState=false] if set, keep the fold state
|
||||
* of this thread. Otherwise unfold it while detaching it.
|
||||
* @param {boolean} [options.passively=false] if set, if the thread window
|
||||
* will be created passively.
|
||||
*/
|
||||
detach: function (options) {
|
||||
options = options || {};
|
||||
this._detached = true;
|
||||
this._folded = options.keepFoldState ? this._folded : false;
|
||||
this._warnUpdatedWindowState({
|
||||
passively: options.passively,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Updates the _folded state of the thread. Must be overriden to reflect
|
||||
* the new state in the interface.
|
||||
*/
|
||||
fold: function (folded) {
|
||||
this._detached = true;
|
||||
this._folded = folded;
|
||||
this._warnUpdatedWindowState();
|
||||
},
|
||||
/**
|
||||
* Get the list of available commands for the thread.
|
||||
* By default, threads do not have any available command.
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
getCommands: function () {
|
||||
return [];
|
||||
},
|
||||
/**
|
||||
* Get the ID of the thread
|
||||
*
|
||||
* @returns {integer|string} string for mailboxes (e.g. 'mailbox_inbox')
|
||||
*/
|
||||
getID: function () {
|
||||
return this._id;
|
||||
},
|
||||
/**
|
||||
* Get the listeners of the thread.
|
||||
* By default, a thread has not listener.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {$.Promise<Object[]>}
|
||||
*/
|
||||
getMentionPartnerSuggestions: function () {
|
||||
return $.when([]);
|
||||
},
|
||||
/**
|
||||
* Get the list of messages in this thread.
|
||||
* By default, a thread has no messages.
|
||||
*
|
||||
* Note that this method only returns some messages, as we do not want to
|
||||
* fetch all messages of a thread at once, just to read the last message.
|
||||
*
|
||||
* As a result, this method fetches only some messages of the thread,
|
||||
* starting from the last message in the thread. At most, it fetches
|
||||
* `this.LIMIT` number of messages at a time.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {$.Promise<mail.model.Message[]>}
|
||||
*/
|
||||
getMessages: function () {
|
||||
return $.when([]);
|
||||
},
|
||||
/**
|
||||
* Get the name of this thread
|
||||
*
|
||||
* @returns {string|Object} object for lazy translated names with _lt
|
||||
*/
|
||||
getName: function () {
|
||||
return this._name;
|
||||
},
|
||||
/**
|
||||
* Returns the information required to render the preview of this channel.
|
||||
*
|
||||
* @returns {Object} a valid object for the rendering of previews
|
||||
* (@see mail.Preview template)
|
||||
*/
|
||||
getPreview: function () {
|
||||
return {
|
||||
id: this.getID(),
|
||||
imageSRC: '/mail/static/src/img/smiley/avatar.jpg',
|
||||
isChat: this.isChat(),
|
||||
status: this.getStatus(),
|
||||
title: this.getName(),
|
||||
unreadCounter: this.getUnreadCounter(),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get the status of the thread (e.g. 'online', 'offline', etc.)
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
getStatus: function () {
|
||||
return this._status;
|
||||
},
|
||||
/**
|
||||
* Returns the title to display in thread window's headers.
|
||||
*
|
||||
* @returns {string|Object} the name of the thread by default (see @getName)
|
||||
*/
|
||||
getTitle: function () {
|
||||
return this.getName();
|
||||
},
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getType: function () {
|
||||
return this._type;
|
||||
},
|
||||
/**
|
||||
* @returns {integer}
|
||||
*/
|
||||
getUnreadCounter: function () {
|
||||
return this._unreadCounter;
|
||||
},
|
||||
/**
|
||||
* State whether this channel has been previewed
|
||||
*
|
||||
* A channel that has been previewed means that it had the necessary data
|
||||
* to display its preview format. A channel needs its meta data and the
|
||||
* last message in order to build its preview format.
|
||||
*
|
||||
* This is useful in order to not fetch preview info on this channel more
|
||||
* than once on channels that have no message at all.
|
||||
*
|
||||
* Any received message updates the last_message, so a channel should
|
||||
* always have all the necessary information to display its preview after
|
||||
* the 1st time.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasBeenPreviewed: function () {
|
||||
return this._previewed;
|
||||
},
|
||||
/**
|
||||
* State whether there are unread messages in this thread
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasUnreadMessages: function () {
|
||||
return this._unreadCounter !== 0;
|
||||
},
|
||||
/**
|
||||
* Increments the needaction counter of this thread
|
||||
* FIXME: this method makes only sense for channels. Not sure, but I think
|
||||
* this method is necessary at this level because of the model
|
||||
* im_support.SupportChannel
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
incrementNeedactionCounter: function () {},
|
||||
/**
|
||||
* Increment the unread counter of this thread by 1 unit
|
||||
*/
|
||||
incrementUnreadCounter: function () {
|
||||
this._unreadCounter++;
|
||||
this._warnNewUnreadCounter();
|
||||
},
|
||||
/**
|
||||
* States whether the thread should be auto-selected on creation
|
||||
*
|
||||
* Note that this is not of the responsibility of the thread: it only
|
||||
* provides guidance for the object that uses threads (e.g. mail.Discuss
|
||||
* must listen on threads and auto-select the thread if autoswitch is set).
|
||||
*
|
||||
* By default, threads are not in autoswitch mode.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAutoswitch: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* States whether this thread is a channel or not. A thread is a channel if
|
||||
* it is an instance of mail.model.Channel (direct or indirect).
|
||||
* By default, any thread is not a channel
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChannel: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* States whether this thread is a chat or not. In particular, public and
|
||||
* private channels are not chat, but DMs and Livechats are chats. Chats
|
||||
* are threads used for communication between two users.
|
||||
* By default, any thread is not a chat
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isChat: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* States whether this thread is detached or not.
|
||||
* A thread that is detached must have a thread window linked to itself.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isDetached: function () {
|
||||
return this._detached;
|
||||
},
|
||||
/**
|
||||
* States whether this thread is folded or not.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isFolded: function () {
|
||||
return this._folded;
|
||||
},
|
||||
/**
|
||||
* States whether the thread is linked to a document
|
||||
* By default, threads are not linked to a document.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLinkedToDocument: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* States whether this thread has the mass mailing setting active or not.
|
||||
* This is a server-side setting, that determine the type of composer that
|
||||
* is used (basic or extended composer).
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isMassMailing: function () {
|
||||
return this._massMailing;
|
||||
},
|
||||
/**
|
||||
|
||||
* States whether the thread is moderated or not.
|
||||
* By default, threads are not moderated.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isModerated: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* States whether the current user is moderator of this thread.
|
||||
* By default, the current user is not moderator of this thread.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isModerator: function () {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Mark this channel as previewed
|
||||
*
|
||||
* This is useful in order to not fetch preview info on this channel
|
||||
* is the server has no preview in the first place.
|
||||
*
|
||||
* Note: preview fetch is useful only when the channel contains messages
|
||||
* that have not been fetched at all. After that, this channel instance
|
||||
* is updated regularly so that the most up-to-date info are available
|
||||
* to make the preview of this channel.
|
||||
*/
|
||||
markAsPreviewed: function () {
|
||||
this._previewed = true;
|
||||
},
|
||||
/**
|
||||
* Mark the thread as read, which resets the unread counter to 0.
|
||||
*
|
||||
* @returns {$.Promise} resolved
|
||||
*/
|
||||
markAsRead: function () {
|
||||
if (this._unreadCounter > 0) {
|
||||
this._unreadCounter = 0;
|
||||
this._warnNewUnreadCounter();
|
||||
}
|
||||
return $.when();
|
||||
},
|
||||
/**
|
||||
* Post a message on the thread.
|
||||
*
|
||||
* This method must be completed by concrete threads,
|
||||
* As it currently only pre-process the messages at the moment.
|
||||
*
|
||||
* @abstract
|
||||
* @param {Object} data
|
||||
* @returns {$.Promise<Object>} resolved with the message object to be sent
|
||||
* to the server
|
||||
*/
|
||||
postMessage: function (data) {
|
||||
// This message will be received from the mail composer as html content
|
||||
// subtype but the urls will not be linkified. If the mail composer
|
||||
// takes the responsibility to linkify the urls we end up with double
|
||||
// linkification a bit everywhere. Ideally we want to keep the content
|
||||
// as text internally and only make html enrichment at display time but
|
||||
// the current design makes this quite hard to do.
|
||||
var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink);
|
||||
body = this._generateEmojis(body);
|
||||
var messageData = {
|
||||
partner_ids: data.partner_ids,
|
||||
body: body,
|
||||
attachment_ids: data.attachment_ids,
|
||||
};
|
||||
if ('subject' in data) {
|
||||
messageData.subject = data.subject;
|
||||
}
|
||||
return $.when(messageData);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
resetUnreadCounter: function () {
|
||||
this._unreadCounter = 0;
|
||||
this._warnNewUnreadCounter();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @private
|
||||
* @return {$.Promise}
|
||||
*/
|
||||
_fetchMessages: function () {
|
||||
return $.when();
|
||||
},
|
||||
/**
|
||||
* Replace character representations of emojis by their unicode
|
||||
* representation in the provided HTML string. Note that the provided html
|
||||
* string is altered by this function.
|
||||
*
|
||||
* @param {string} htmlString
|
||||
* @returns {string}
|
||||
*/
|
||||
_generateEmojis: function (htmlString) {
|
||||
_.each(emojis, function (emoji) {
|
||||
_.each(emoji.sources, function (source) {
|
||||
var escapedSource = String(_.escape(source)).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1');
|
||||
var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g');
|
||||
htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
|
||||
});
|
||||
});
|
||||
return htmlString;
|
||||
},
|
||||
/**
|
||||
* Warn other mail components that the window state of this thread has
|
||||
* changed (it has become closed, folded, unfolded, detached, etc.)
|
||||
*
|
||||
* @private
|
||||
* @param {Object} [options={}]
|
||||
*/
|
||||
_warnUpdatedWindowState: function (options) {
|
||||
options = options || {};
|
||||
this.call('mail_service', 'updateThreadWindow', this.getID(), options);
|
||||
},
|
||||
/**
|
||||
* Warn on the chat bus that the unread counter has been updated
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_warnNewUnreadCounter: function () {
|
||||
this.call('mail_service', 'getMailBus')
|
||||
.trigger('update_thread_unread_counter', this);
|
||||
},
|
||||
});
|
||||
|
||||
return Thread;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
odoo.define('mail.model.ThreadWithCache', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Thread = require('mail.model.Thread');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
/**
|
||||
* This is a version of a thread that is handling a cache.
|
||||
* Basically, this cache is an object indexed by stringified indexes.
|
||||
* These indexes represent the domain of messages that match this domain.
|
||||
*
|
||||
* Any threads that are instances of this Class can be used with a search view,
|
||||
* in order to make searches on messages in a thread.
|
||||
*/
|
||||
var ThreadWithCache = Thread.extend({
|
||||
/**
|
||||
* @override
|
||||
* @param {integer} [data.seen_message_id=undefined]
|
||||
*/
|
||||
init: function (parent, data) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._cache = {
|
||||
'[]': {
|
||||
allHistoryLoaded: false,
|
||||
loaded: false,
|
||||
messages: [],
|
||||
}
|
||||
};
|
||||
this._lastSeenMessageID = data.seen_message_id;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add the message to this thread
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {mail.model.Message} message
|
||||
* @param {Array} domain
|
||||
*/
|
||||
addMessage: function (message, domain) {
|
||||
var cache = this._getCache(domain);
|
||||
var index = _.sortedIndex(cache.messages, message, function (msg) {
|
||||
return msg.getID();
|
||||
});
|
||||
if (cache.messages[index] !== message) {
|
||||
cache.messages.splice(index, 0, message);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the last seen message for a given thread
|
||||
*
|
||||
* @return {integer|null} last seen message ID (if any)
|
||||
*/
|
||||
getLastSeenMessageID: function () {
|
||||
return this._lastSeenMessageID || null;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {Array} [domain]
|
||||
* @param {boolean} [loadMore=false]
|
||||
*/
|
||||
getMessages: function (domain, loadMore) {
|
||||
if (loadMore) {
|
||||
return this._fetchMessages(domain, true);
|
||||
}
|
||||
var cache = this._getCache(domain);
|
||||
if (cache.loaded) {
|
||||
return $.when(cache.messages);
|
||||
} else {
|
||||
return this._fetchMessages(domain);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @return {boolean} true if there is at least a message, false otherwise
|
||||
*/
|
||||
hasMessages: function () {
|
||||
return !!(_.last(this._cache['[]'].messages));
|
||||
},
|
||||
/**
|
||||
* Invalidate the caches of the thread
|
||||
*/
|
||||
invalidateCaches: function () {
|
||||
this._cache = _.pick(this._cache, '[]');
|
||||
},
|
||||
/**
|
||||
* State whether all messages have been loaded or not
|
||||
*
|
||||
* @param {Array} domain
|
||||
* @return {boolean}
|
||||
*/
|
||||
isAllHistoryLoaded: function (domain) {
|
||||
return this._getCache(domain).allHistoryLoaded;
|
||||
},
|
||||
/**
|
||||
* Remove message with ID `messageID` from this thread
|
||||
*
|
||||
* This is equivalent to removing this message from all the caches
|
||||
*
|
||||
* @param {integer} messageID
|
||||
*/
|
||||
removeMessage: function (messageID) {
|
||||
_.each(this._cache, function (cache) {
|
||||
cache.messages = _.reject(cache.messages, function (message) {
|
||||
return message.getID() === messageID;
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Set 'messageID' as the ID of the last message seen in this thread
|
||||
*
|
||||
* @param {integer} messageID
|
||||
*/
|
||||
setLastSeenMessageID: function (messageID) {
|
||||
this._lastSeenMessageID = messageID;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gets messages from thread
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {Array} [pDomain] filter on the messages of the channel
|
||||
* @param {boolean} [loadMore] Whether it should load more message
|
||||
* @return {$.Promise<mail.model.Message[]>} resolved with list of messages
|
||||
*/
|
||||
_fetchMessages: function (pDomain, loadMore) {
|
||||
var self = this;
|
||||
|
||||
var domain = this._getThreadDomain();
|
||||
|
||||
var cache = this._getCache(pDomain);
|
||||
if (pDomain) {
|
||||
domain = domain.concat(pDomain || []);
|
||||
}
|
||||
if (loadMore) {
|
||||
var minMessageID = cache.messages[0].getID();
|
||||
domain = [['id', '<', minMessageID]].concat(domain);
|
||||
}
|
||||
return this._rpc({
|
||||
model: 'mail.message',
|
||||
method: 'message_fetch',
|
||||
args: [domain],
|
||||
kwargs: {
|
||||
limit: this._FETCH_LIMIT,
|
||||
context: session.user_context
|
||||
},
|
||||
}).then(function (messages) {
|
||||
if (!cache.allHistoryLoaded) {
|
||||
cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT;
|
||||
}
|
||||
cache.loaded = true;
|
||||
_.each(messages, function (message) {
|
||||
self.call('mail_service', 'addMessage', message, {
|
||||
silent: true,
|
||||
domain: pDomain,
|
||||
});
|
||||
});
|
||||
cache = self._getCache(pDomain || []);
|
||||
return cache.messages;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get thread content from the cache
|
||||
* Useful to get cached messages.
|
||||
*
|
||||
* @private
|
||||
* @param {Array} domain
|
||||
* @return {Object|undefined}
|
||||
*/
|
||||
_getCache: function (domain) {
|
||||
var stringifiedDomain = JSON.stringify(domain || []);
|
||||
if (!this._cache[stringifiedDomain]) {
|
||||
this._cache[stringifiedDomain] = {
|
||||
allHistoryLoaded: false,
|
||||
loaded: false,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
return this._cache[stringifiedDomain];
|
||||
},
|
||||
/**
|
||||
* Get the domain to fetch all the messages in the current thread
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @returns {Array}
|
||||
*/
|
||||
_getThreadDomain: function () {},
|
||||
});
|
||||
|
||||
return ThreadWithCache;
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,534 +0,0 @@
|
||||
odoo.define('mail.ChatWindowManager', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ExtendedChatWindow = require('mail.ExtendedChatWindow');
|
||||
|
||||
var AbstractService = require('web.AbstractService');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var utils = require('web.utils');
|
||||
var web_client = require('web.web_client');
|
||||
|
||||
var _t = core._t;
|
||||
var QWeb = core.qweb;
|
||||
|
||||
//----------------------------------------------------------------
|
||||
var CHAT_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
|
||||
|
||||
/**
|
||||
* This service handles chat channels that are displayed in "chat window" mode
|
||||
*/
|
||||
var ChatWindowManager = AbstractService.extend({
|
||||
name: 'chat_window_manager',
|
||||
dependencies: ['chat_manager'],
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
var self = this;
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.chatSessions = [];
|
||||
this.newChatSession;
|
||||
this.displayState = {
|
||||
chatWindowsHidden: false, // chat windows aren't displayed when the client action is open
|
||||
hiddenSessions: [],
|
||||
hiddenUnreadCounter: 0, // total number of unread msgs in hidden chat windows
|
||||
nbSlots: 0,
|
||||
spaceLeft: 0,
|
||||
windowsDropdownIsOpen: false, // used to keep dropdown open when closing chat windows
|
||||
};
|
||||
this.call('chat_manager', 'isReady').then(function () {
|
||||
var channels = self.call('chat_manager', 'getChannels');
|
||||
_.each(channels, function (channel) {
|
||||
if (channel.is_detached) {
|
||||
self.openChat(channel);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var chatBus = this.call('chat_manager', 'getChatBus');
|
||||
chatBus.on('update_message', this, this._onUpdateMessage);
|
||||
chatBus.on('new_message', this, this._onNewMessage);
|
||||
chatBus.on('anyone_listening', this, this._onAnyoneListening);
|
||||
chatBus.on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel);
|
||||
chatBus.on('update_channel_unread_counter', this, this._onUpdateChannelUnreadCounter);
|
||||
chatBus.on('update_dm_presence', this, this._onUpdateDmPresence);
|
||||
chatBus.on('detach_channel', this, this._onDetachChannel);
|
||||
chatBus.on('discuss_open', this, this._onDiscussOpen);
|
||||
|
||||
core.bus.on('resize', this, _.debounce(this._repositionWindows.bind(this), 100));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} chatSession
|
||||
* @param {integer} chatSession.id
|
||||
* @param {Object} options
|
||||
*/
|
||||
closeChat: function (chatSession, options) {
|
||||
var session = _.find(this.chatSessions, {id: chatSession.id});
|
||||
if (session) {
|
||||
this._closeChat(session, options);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} session
|
||||
* @param {integer} session.id
|
||||
* @param {boolean} [session.is_chat] true for dm? => server_type="chat" & type="dm"...
|
||||
* @param {boolean} [session.mass_mailing]
|
||||
* @param {string} [session.status] e.g. 'offline'
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.passively] if set to true, open the chat window
|
||||
* without focusing the input and marking messages as read if it is not
|
||||
* open yet, and do nothing otherwise
|
||||
*/
|
||||
openChat: function (session, options) {
|
||||
var self = this;
|
||||
if (!session) {
|
||||
this._openChatWithoutSession();
|
||||
return;
|
||||
}
|
||||
options = options || {};
|
||||
var chatSession = _.findWhere(this.chatSessions, {id: session.id});
|
||||
if (!chatSession) {
|
||||
var prefix = !session.is_chat ? "#" : "";
|
||||
var windowOptions = {
|
||||
autofocus: !options.passively,
|
||||
input_less: session.mass_mailing,
|
||||
status: session.status,
|
||||
};
|
||||
chatSession = {
|
||||
id: session.id,
|
||||
uuid: session.uuid,
|
||||
name: session.name,
|
||||
keep_unread: options.passively, // don't automatically mark unread messages as seen
|
||||
window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
|
||||
};
|
||||
chatSession.window.on("close_chat_session", null, function () {
|
||||
self._closeChat(chatSession);
|
||||
self.call('chat_manager', 'closeChatSession', chatSession.id);
|
||||
});
|
||||
chatSession.window.on("toggle_star_status", null, function (messageID) {
|
||||
self.call('chat_manager', 'toggleStarStatus', messageID);
|
||||
});
|
||||
|
||||
chatSession.window.on("fold_channel", null, function (channelID, folded) {
|
||||
self.call('chat_manager', 'foldChannel', channelID, folded);
|
||||
});
|
||||
|
||||
chatSession.window.on("post_message", null, function (message, channelID) {
|
||||
self.call('chat_manager', 'postMessage', message, {
|
||||
channelID: channelID
|
||||
}).then(function () {
|
||||
chatSession.window.thread.scroll_to();
|
||||
});
|
||||
});
|
||||
chatSession.window.on("redirect", null, function (resModel, resID) {
|
||||
self.call('chat_manager', 'redirect', resModel, resID, self.openChat.bind(self));
|
||||
});
|
||||
chatSession.window.on("redirect_to_channel", null, function (channelID) {
|
||||
var session = _.findWhere(self.chatSessions, {id: channelID});
|
||||
if (!session) {
|
||||
self.call('chat_manager', 'joinChannel', channelID).then(function (channel) {
|
||||
self.call('chat_manager', 'detachChannel', channelID);
|
||||
});
|
||||
} else {
|
||||
session.window.toggle_fold(false);
|
||||
}
|
||||
});
|
||||
|
||||
var removeNewChat = false;
|
||||
if (options.passively) {
|
||||
this.chatSessions.push(chatSession); // simply insert the window to the left
|
||||
} else if (this.newChatSession && this.newChatSession.partner_id && this.newChatSession.partner_id === session.direct_partner_id) {
|
||||
// the window takes the place of the 'newChatSession' window
|
||||
this.chatSessions[_.indexOf(this.chatSessions, this.newChatSession)] = chatSession;
|
||||
removeNewChat = true;
|
||||
} else {
|
||||
this._addChatSession(chatSession); // add session such that window is visible
|
||||
}
|
||||
|
||||
chatSession.window.appendTo($('body'))
|
||||
.then(function () {
|
||||
self._repositionWindows({remove_new_chat: removeNewChat});
|
||||
return self.call('chat_manager', 'getMessages', {channelID: chatSession.id});
|
||||
}).then(function (messages) {
|
||||
chatSession.window.render(messages);
|
||||
chatSession.window.thread.scroll_to();
|
||||
setTimeout(function () {
|
||||
chatSession.window.thread.$el.on("scroll", null, _.debounce(function () {
|
||||
if (!chatSession.keep_unread && chatSession.window.thread.is_at_bottom()) {
|
||||
self.call('chat_manager', 'markChannelAsSeen', session);
|
||||
}
|
||||
}, 100));
|
||||
}, 0); // setTimeout to prevent to execute handler on first scroll_to, which is asynchronous
|
||||
if (options.passively) {
|
||||
// mark first unread messages as seen when focusing the window, then on scroll to bottom as usual
|
||||
chatSession.window.$('.o_mail_thread, .o_chat_composer').one('click', function () {
|
||||
self.call('chat_manager', 'markChannelAsSeen', session);
|
||||
});
|
||||
} else if (!self.displayState.chatWindowsHidden && !session.is_folded) {
|
||||
self.call('chat_manager', 'markChannelAsSeen', session);
|
||||
}
|
||||
});
|
||||
} else if (!options.passively) {
|
||||
if (chatSession.window.is_hidden) {
|
||||
this._makeSessionVisible(chatSession);
|
||||
} else if (session.is_folded !== chatSession.window.folded) {
|
||||
chatSession.window.toggle_fold(session.is_folded);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when unfolding the chat window
|
||||
*
|
||||
* @param {Object} channel
|
||||
* @param {integer} channel.id
|
||||
* @param {boolean} [channel.is_folded]
|
||||
*/
|
||||
toggleFoldChat: function (channel) {
|
||||
var session = _.find(this.chatSessions, {id: channel.id});
|
||||
if (session) {
|
||||
session.window.toggle_fold(channel.is_folded);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add chatSession such that it will be the left-most visible window
|
||||
*
|
||||
* @private
|
||||
* @param {Object} chatSession
|
||||
*/
|
||||
_addChatSession: function (chatSession) {
|
||||
this._computeAvailableSlots(this.chatSessions.length+1);
|
||||
this.chatSessions.splice(this.displayState.nbSlots-1, 0, chatSession);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} chatSession
|
||||
* @param {boolean} [chatSession.keep_unread]
|
||||
* @param {Object} chatSession.window
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.keep_open_if_unread]
|
||||
*/
|
||||
_closeChat: function (chatSession, options) {
|
||||
if (options && options.keep_open_if_unread && chatSession.keep_unread) {
|
||||
return;
|
||||
}
|
||||
this.chatSessions = _.without(this.chatSessions, chatSession);
|
||||
chatSession.window.destroy();
|
||||
this._repositionWindows();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeNewChat: function () {
|
||||
this.chatSessions = _.without(this.chatSessions, this.newChatSession);
|
||||
this._repositionWindows({remove_new_chat: true});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} nbWindows
|
||||
*/
|
||||
_computeAvailableSlots: function (nbWindows) {
|
||||
if (config.device.isMobile) {
|
||||
this.displayState.nbSlots = 1; // one chat window full screen in mobile
|
||||
return;
|
||||
}
|
||||
var width = window.innerWidth;
|
||||
var nbSlots = Math.floor(width/CHAT_WINDOW_WIDTH);
|
||||
var spaceLeft = width - (Math.min(nbSlots, nbWindows)*CHAT_WINDOW_WIDTH);
|
||||
if (nbSlots < nbWindows && spaceLeft < 50) {
|
||||
nbSlots--; // leave at least 50px for the hidden windows dropdown button
|
||||
spaceLeft += CHAT_WINDOW_WIDTH;
|
||||
}
|
||||
this.displayState.nbSlots = nbSlots;
|
||||
this.displayState.spaceLeft = spaceLeft;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_destroyNewChat: function () {
|
||||
this.newChatSession.window.destroy();
|
||||
this.newChatSession = undefined;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} session
|
||||
* @param {Widget} session.window
|
||||
*/
|
||||
_makeSessionVisible: function (session) {
|
||||
utils.swap(this.chatSessions, session, this.chatSessions[this.displayState.nbSlots-1]);
|
||||
this._repositionWindows();
|
||||
session.window.toggle_fold(false);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_openChatWithoutSession: function () {
|
||||
var self = this;
|
||||
if (!this.newChatSession) {
|
||||
this.newChatSession = {
|
||||
id: '_open',
|
||||
window: new ExtendedChatWindow(web_client, undefined, _t('New message'), false, 0, {thread_less: true}),
|
||||
};
|
||||
this.newChatSession.window.on("close_chat_session", null, this._closeNewChat.bind(this));
|
||||
this.newChatSession.window.on('open_dm_session', null, function (partner_id) {
|
||||
self.newChatSession.partner_id = partner_id;
|
||||
var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id);
|
||||
if (!dm) {
|
||||
self.call('chat_manager', 'openAndDetachDm', partner_id);
|
||||
} else {
|
||||
var dmSession = _.findWhere(self.chatSessions, {id: dm.id});
|
||||
if (!dmSession) {
|
||||
self.call('chat_manager', 'detachChannel', dm.id);
|
||||
} else {
|
||||
self._closeChat(dmSession);
|
||||
dm.is_folded = false;
|
||||
self.openChat(dm);
|
||||
}
|
||||
}
|
||||
});
|
||||
this._addChatSession(this.newChatSession);
|
||||
this.newChatSession.window.appendTo($('body')).then(this._repositionWindows.bind(this));
|
||||
} else {
|
||||
if (this.newChatSession.window.is_hidden) {
|
||||
this._makeSessionVisible(this.newChatSession);
|
||||
} else if (this.newChatSession.window.folded) {
|
||||
this.newChatSession.window.toggle_fold(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
* @param {integer[]} message.channel_ids
|
||||
* @param {boolean} scrollBottom
|
||||
*/
|
||||
_updateSessions: function (message, scrollBottom) {
|
||||
var self = this;
|
||||
_.each(this.chatSessions, function (session) {
|
||||
if (_.contains(message.channel_ids, session.id)) {
|
||||
var messageVisible = !self.displayState.chatWindowsHidden && !session.window.folded &&
|
||||
!session.window.is_hidden && session.window.thread.is_at_bottom();
|
||||
if (messageVisible && !session.keep_unread) {
|
||||
var channel = self.call('chat_manager', 'getChannel', session.id);
|
||||
self.call('chat_manager', 'markChannelAsSeen', channel);
|
||||
}
|
||||
self.call('chat_manager', 'getMessages', {channelID: session.id})
|
||||
.then(function (messages) {
|
||||
session.window.render(messages);
|
||||
if (scrollBottom && messageVisible) {
|
||||
session.window.thread.scroll_to();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderHiddenSessionsDropdown: function () {
|
||||
var $dropdown = $(QWeb.render("mail.ChatWindowsDropdown", {
|
||||
sessions: this.displayState.hiddenSessions,
|
||||
open: this.displayState.windowsDropdownIsOpen,
|
||||
unread_counter: this.displayState.hiddenUnreadCounter,
|
||||
widget: {isMobile: config.device.isMobile},
|
||||
}));
|
||||
return $dropdown;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_repositionHiddenSessionsDropdown: function () {
|
||||
// Unfold dropdown to the left if there is enough place
|
||||
var $dropdownUL = this.displayState.$hiddenWindowsDropdown.children('ul');
|
||||
if (this.displayState.spaceLeft > $dropdownUL.width() + 10) {
|
||||
$dropdownUL.addClass('dropdown-menu-right');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.remove_new_chat]
|
||||
*/
|
||||
_repositionWindows: function (options) {
|
||||
var self = this;
|
||||
if (options && options.remove_new_chat) {
|
||||
this._destroyNewChat();
|
||||
}
|
||||
if (this.displayState.chatWindowsHidden) {
|
||||
return;
|
||||
}
|
||||
this._computeAvailableSlots(this.chatSessions.length);
|
||||
var hiddenSessions = [];
|
||||
var hiddenUnreadCounter = 0;
|
||||
var nbSlots = this.displayState.nbSlots;
|
||||
_.each(this.chatSessions, function (session, index) {
|
||||
if (index < nbSlots) {
|
||||
session.window.$el.css({right: CHAT_WINDOW_WIDTH*index, bottom: 0});
|
||||
session.window.do_show();
|
||||
} else {
|
||||
hiddenSessions.push(session);
|
||||
hiddenUnreadCounter += session.window.unread_msgs;
|
||||
session.window.do_hide();
|
||||
}
|
||||
});
|
||||
this.displayState.hiddenSessions = hiddenSessions;
|
||||
this.displayState.hiddenUnreadCounter = hiddenUnreadCounter;
|
||||
|
||||
if (this.displayState.$hiddenWindowsDropdown) {
|
||||
this.displayState.$hiddenWindowsDropdown.remove();
|
||||
}
|
||||
if (hiddenSessions.length) {
|
||||
this.displayState.$hiddenWindowsDropdown = this._renderHiddenSessionsDropdown();
|
||||
var $hiddenWindowsDropdown = this.displayState.$hiddenWindowsDropdown;
|
||||
$hiddenWindowsDropdown.css({right: CHAT_WINDOW_WIDTH * nbSlots, bottom: 0})
|
||||
.appendTo($('body'));
|
||||
this._repositionHiddenSessionsDropdown();
|
||||
this.displayState.windowsDropdownIsOpen = false;
|
||||
|
||||
$hiddenWindowsDropdown.on('click', '.o_chat_header', function (event) {
|
||||
var sessionID = $(event.currentTarget).data('session-id');
|
||||
var session = _.findWhere(hiddenSessions, {id: sessionID});
|
||||
if (session) {
|
||||
self._makeSessionVisible(session);
|
||||
}
|
||||
});
|
||||
$hiddenWindowsDropdown.on('click', '.o_chat_window_close', function (event) {
|
||||
var sessionID = $(event.currentTarget).closest('.o_chat_header').data('session-id');
|
||||
var session = _.findWhere(hiddenSessions, {id: sessionID});
|
||||
if (session) {
|
||||
session.window.on_click_close(event);
|
||||
self.displayState.windowsDropdownIsOpen = true; // keep the dropdown open
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} channel
|
||||
* @param {integer|string} channel.id
|
||||
* @param {Object} query
|
||||
*/
|
||||
_onAnyoneListening: function (channel, query) {
|
||||
_.each(this.chatSessions, function (session) {
|
||||
if (channel.id === session.id && session.window.thread.is_at_bottom() && !session.window.is_hidden) {
|
||||
query.is_displayed = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} channel
|
||||
* @param {integer} channel.id
|
||||
*/
|
||||
_onDetachChannel: function (channel) {
|
||||
var chatSession = _.findWhere(this.chatSessions, {id: channel.id});
|
||||
if (!chatSession || chatSession.window.folded) {
|
||||
this.call('chat_manager', 'detachChannel', channel.id);
|
||||
} else if (chatSession.window.is_hidden) {
|
||||
this._makeSessionVisible(chatSession);
|
||||
} else {
|
||||
chatSession.window.focus_input();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {boolean} open
|
||||
*/
|
||||
_onDiscussOpen: function (open) {
|
||||
this.displayState.chatWindowsHidden = open;
|
||||
if (open) {
|
||||
$('body').addClass('o_no_chat_window');
|
||||
} else {
|
||||
$('body').removeClass('o_no_chat_window');
|
||||
this._repositionWindows();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
*/
|
||||
_onNewMessage: function (message) {
|
||||
this._updateSessions(message, true);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} channel
|
||||
* @param {integer} channel.unread_counter
|
||||
*/
|
||||
_onUpdateChannelUnreadCounter: function (channel) {
|
||||
var self = this;
|
||||
this.displayState.hiddenUnreadCounter = 0;
|
||||
_.each(self.chatSessions, function (session) {
|
||||
if (channel.id === session.id) {
|
||||
session.window.update_unread(channel.unread_counter);
|
||||
if (channel.unread_counter === 0) {
|
||||
session.keep_unread = false;
|
||||
}
|
||||
}
|
||||
if (session.window.is_hidden) {
|
||||
self.displayState.hiddenUnreadCounter += session.window.unread_msgs;
|
||||
}
|
||||
});
|
||||
if (self.displayState.$hiddenWindowsDropdown) {
|
||||
self.displayState.$hiddenWindowsDropdown.html(self._renderHiddenSessionsDropdown().html());
|
||||
self._repositionHiddenSessionsDropdown();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} channel
|
||||
* @param {string|integer} channel.id
|
||||
* @param {string} channel.status e.g. 'offline'
|
||||
*/
|
||||
_onUpdateDmPresence: function (channel) {
|
||||
_.each(this.chatSessions, function (session) {
|
||||
if (channel.id === session.id) {
|
||||
session.window.update_status(channel.status);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
*/
|
||||
_onUpdateMessage: function (message) {
|
||||
this._updateSessions(message, false);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string|integer} channelID
|
||||
*/
|
||||
_onUnsubscribeFromChannel: function (channelID) {
|
||||
var self = this;
|
||||
_.each(self.chatSessions, function (session) {
|
||||
if (channelID === session.id) {
|
||||
self._closeChat(session);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
core.serviceRegistry.add('chat_window_manager', ChatWindowManager);
|
||||
|
||||
return ChatWindowManager;
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
odoo.define('mail.Manager.Notification', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Mail Notification Manager
|
||||
*
|
||||
* This part of the mail manager is responsible for receiving notifications on
|
||||
* the longpoll bus, which are data received from the server.
|
||||
*/
|
||||
var MailManager = require('mail.Manager');
|
||||
var MailFailure = require('mail.model.MailFailure');
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
MailManager.include({
|
||||
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._busBus.on('notification', this, this._onNotification);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remove channel notifications if there is an unsubscribe notification
|
||||
* on this channel.
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} notifications
|
||||
* @returns {Object[]} notifications filtered of channel notifications
|
||||
* matching unsubscribe notifs
|
||||
*/
|
||||
_filterNotificationsOnUnsubscribe: function (notifications) {
|
||||
var unsubscribedNotif = _.find(notifications, function (notif) {
|
||||
return notif[1].info === 'unsubscribe';
|
||||
});
|
||||
if (unsubscribedNotif) {
|
||||
notifications = _.reject(notifications, function (notif) {
|
||||
return notif[0][1] === 'mail.channel' &&
|
||||
notif[0][2] === unsubscribedNotif[1].id;
|
||||
});
|
||||
}
|
||||
return notifications;
|
||||
},
|
||||
/**
|
||||
* Called when a new or updated message is received on a channel
|
||||
*
|
||||
* @private
|
||||
* @param {Object} messageData
|
||||
* @param {Array} messageData.channel_ids list of integers and strings,
|
||||
* where strings for static channels, e.g. 'mailbox_inbox'.
|
||||
*/
|
||||
_handleChannelNotification: function (messageData) {
|
||||
var self = this;
|
||||
var def;
|
||||
var channelAlreadyInCache = true;
|
||||
if (messageData.channel_ids.length === 1) {
|
||||
channelAlreadyInCache = !!this.getChannel(messageData.channel_ids[0]);
|
||||
def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false });
|
||||
} else {
|
||||
def = $.when();
|
||||
}
|
||||
def.then(function () {
|
||||
// don't increment unread if channel wasn't in cache yet as
|
||||
// its unread counter has just been fetched
|
||||
self.addMessage(messageData, {
|
||||
showNotification: true,
|
||||
incrementUnread: channelAlreadyInCache
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* On message becoming a need action (pinned to inbox)
|
||||
*
|
||||
* @private
|
||||
* @param {Object} messageData
|
||||
* @param {integer[]} messageData.channel_ids
|
||||
*/
|
||||
_handleNeedactionNotification: function (messageData) {
|
||||
var self = this;
|
||||
var inbox = this.getMailbox('inbox');
|
||||
var message = this.addMessage(messageData, {
|
||||
incrementUnread: true,
|
||||
showNotification: true,
|
||||
});
|
||||
inbox.incrementMailboxCounter();
|
||||
_.each(message.getThreadIDs(), function (threadID) {
|
||||
var channel = self.getChannel(threadID);
|
||||
if (channel) {
|
||||
channel.incrementNeedactionCounter();
|
||||
}
|
||||
});
|
||||
this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
|
||||
},
|
||||
/**
|
||||
* Called when an activity record has been updated on the server
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data key, value to decide activity created or deleted
|
||||
*/
|
||||
_handlePartnerActivityUpdateNotification: function (data) {
|
||||
this._mailBus.trigger('activity_updated', data);
|
||||
},
|
||||
/**
|
||||
* Called when a channel has been seen, and the server responses with the
|
||||
* last message seen. Useful in order to track last message seen.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {integer} data.id ID of a channel
|
||||
* @param {integer} [data.last_message_id] mandatory if 'id' refers to an
|
||||
* existing channel.
|
||||
*/
|
||||
_handlePartnerChannelSeenNotification: function (data) {
|
||||
var channel = this.getChannel(data.id);
|
||||
if (channel) {
|
||||
channel.setLastSeenMessageID(data.last_message_id);
|
||||
if (channel.hasUnreadMessages()) {
|
||||
channel.resetUnreadCounter();
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when receiving a channel state as a partner notification:
|
||||
*
|
||||
* - if it is a new channel, it means we have been invited to this channel
|
||||
* - if it is an existing channel, it means the window state of the channel
|
||||
* may have changed (as this state is stored server-side)
|
||||
*
|
||||
* @private
|
||||
* @param {Object} channelData
|
||||
* @param {integer} channelData.id
|
||||
* @param {boolean} channelData.is_minimized
|
||||
* @param {string} channelData.state
|
||||
*/
|
||||
_handlePartnerChannelNotification: function (channelData) {
|
||||
if (
|
||||
(channelData.channel_type === 'channel') &&
|
||||
(channelData.state === 'open')
|
||||
) {
|
||||
// invited to a new channel
|
||||
this._addChannel(channelData, { autoswitch: false });
|
||||
if (
|
||||
!channelData.is_minimized &&
|
||||
channelData.info !== 'creation'
|
||||
) {
|
||||
this.do_notify(
|
||||
_t("Invitation"),
|
||||
_t("You have been invited to: ") + channelData.name);
|
||||
}
|
||||
}
|
||||
var channel = this.getChannel(channelData.id);
|
||||
if (channel) {
|
||||
channel.updateWindowState({
|
||||
folded: channelData.state === 'folded' ? true : false,
|
||||
detached: channelData.is_minimized,
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Add or remove failure when receiving a failure update message
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object[]} data.elements list of mail failure data
|
||||
* @param {string} data.elements[].message_id ID of related message that
|
||||
* has a mail failure.
|
||||
* @param {Array} data.elements[].notifications list of notifications
|
||||
* that is related to a mail failure.
|
||||
* @param {string} data.elements[].notifications[0] sending state of a mail
|
||||
* failure (e.g. 'exception').
|
||||
*/
|
||||
_handlePartnerMailFailureNotification: function (datas) {
|
||||
var self = this;
|
||||
_.each(datas.elements, function (data) {
|
||||
var isNewFailure = _.some(data.notifications, function (notif) {
|
||||
return notif[0] === 'exception' || notif[0] === 'bounce';
|
||||
});
|
||||
var matchedFailure = _.find(self._mailFailures, function (failure) {
|
||||
return failure.getMessageID() === data.message_id;
|
||||
});
|
||||
if (matchedFailure) {
|
||||
var index = _.findIndex(self._mailFailures, matchedFailure);
|
||||
if (isNewFailure) {
|
||||
self._mailFailures[index] = new MailFailure(self, data);
|
||||
} else {
|
||||
self._mailFailures.splice(index, 1);
|
||||
}
|
||||
} else if (isNewFailure) {
|
||||
self._mailFailures.push(new MailFailure(self, data));
|
||||
}
|
||||
var message = _.find(self._messages, function (msg) {
|
||||
return msg.getID() === data.message_id;
|
||||
});
|
||||
if (message) {
|
||||
if (isNewFailure) {
|
||||
message.updateCustomerEmailStatus('exception');
|
||||
} else {
|
||||
message.updateCustomerEmailStatus('sent');
|
||||
}
|
||||
self._updateMessageNotificationStatus(data, message);
|
||||
self._mailBus.trigger('update_message', message);
|
||||
}
|
||||
});
|
||||
this._mailBus.trigger('update_needaction', this.needactionCounter);
|
||||
},
|
||||
/**
|
||||
* Updates mailbox_inbox when a message has marked as read.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {integer[]} [data.channel_ids]
|
||||
* @param {integer[]} [data.message_ids]
|
||||
* @param {string} [data.type]
|
||||
*/
|
||||
_handlePartnerMarkAsReadNotification: function (data) {
|
||||
var self = this;
|
||||
_.each(data.message_ids, function (messageID) {
|
||||
var message = _.find(self._messages, function (msg) {
|
||||
return msg.getID() === messageID;
|
||||
});
|
||||
if (message) {
|
||||
self._removeMessageFromThread('mailbox_inbox', message);
|
||||
self._mailBus.trigger('update_message', message, data.type);
|
||||
}
|
||||
});
|
||||
if (data.channel_ids) {
|
||||
_.each(data.channel_ids, function (channelID) {
|
||||
var channel = self.getChannel(channelID);
|
||||
if (channel) {
|
||||
channel.decrementNeedactionCounter(data.message_ids.length, 0);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// if no channel_ids specified, this is a 'mark all read' in inbox
|
||||
_.each(this._threads, function (thread) {
|
||||
thread.resetNeedactionCounter();
|
||||
});
|
||||
}
|
||||
var inbox = this.getMailbox('inbox');
|
||||
inbox.decrementMailboxCounter(data.message_ids.length);
|
||||
this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
|
||||
},
|
||||
/**
|
||||
* On receiving a message made by the current user, on a moderated channel,
|
||||
* which is pending moderation.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object} data.message server-side data of the message
|
||||
*/
|
||||
_handlePartnerMessageAuthorNotification: function (data) {
|
||||
this.addMessage(data.message);
|
||||
},
|
||||
/**
|
||||
* Notification to delete several messages locally
|
||||
* Useful when a pending moderation message has been rejected, so that
|
||||
* this message should not be displayed anymore.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object[]} [data.message_ids] IDs of messages to delete locally.
|
||||
*/
|
||||
_handlePartnerMessageDeletionNotification: function (data) {
|
||||
var self = this;
|
||||
_.each(data.message_ids, function (messageID) {
|
||||
var message = self.getMessage(messageID);
|
||||
if (message) {
|
||||
message.setModerationStatus('rejected');
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* On receiving a message pending moderation, and current user is moderator
|
||||
* of such message.
|
||||
*
|
||||
* @param {Object} data notification data
|
||||
* @param {Object} data.message data of message
|
||||
*/
|
||||
_handlePartnerMessageModeratorNotification: function (data) {
|
||||
this.addMessage(data.message);
|
||||
this._mailBus.trigger('update_moderation_counter');
|
||||
},
|
||||
/**
|
||||
* On receiving a notification that is specific to a user
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data structure depending on the type
|
||||
* @param {integer} data.id
|
||||
*/
|
||||
_handlePartnerNotification: function (data) {
|
||||
if (data.info === 'unsubscribe') {
|
||||
this._handlePartnerUnsubscribeNotification(data);
|
||||
} else if (data.type === 'toggle_star') {
|
||||
this._handlePartnerToggleStarNotification(data);
|
||||
} else if (data.type === 'mark_as_read') {
|
||||
this._handlePartnerMarkAsReadNotification(data);
|
||||
} else if (data.type === 'moderator') {
|
||||
this._handlePartnerMessageModeratorNotification(data);
|
||||
} else if (data.type === 'author') {
|
||||
this._handlePartnerMessageAuthorNotification(data);
|
||||
} else if (data.type === 'deletion') {
|
||||
this._handlePartnerMessageDeletionNotification(data);
|
||||
} else if (data.info === 'channel_seen') {
|
||||
this._handlePartnerChannelSeenNotification(data);
|
||||
} else if (data.info === 'transient_message') {
|
||||
this._handlePartnerTransientMessageNotification(data);
|
||||
} else if (data.type === 'activity_updated') {
|
||||
this._handlePartnerActivityUpdateNotification(data);
|
||||
} else if (data.type === 'mail_failure') {
|
||||
this._handlePartnerMailFailureNotification(data);
|
||||
} else {
|
||||
this._handlePartnerChannelNotification(data);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* On toggling on or off the star status of one or several messages.
|
||||
* As the information is stored server-side, the web client must adapt
|
||||
* itself from server's data on the messages.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {integer[]} data.message_ids IDs of messages that have a change
|
||||
* of their starred status.
|
||||
* @param {boolean} data.starred states whether the messages with id in
|
||||
* `data.message_ids` have become either starred or unstarred
|
||||
*/
|
||||
_handlePartnerToggleStarNotification: function (data) {
|
||||
var self = this;
|
||||
var starred = this.getMailbox('starred');
|
||||
_.each(data.message_ids, function (messageID) {
|
||||
var message = _.find(self._messages, function (msg) {
|
||||
return msg.getID() === messageID;
|
||||
});
|
||||
if (message) {
|
||||
message.setStarred(data.starred);
|
||||
if (!message.isStarred()) {
|
||||
self._removeMessageFromThread('mailbox_starred', message);
|
||||
} else {
|
||||
self._addMessageToThreads(message, []);
|
||||
var channelStarred = self.getMailbox('starred');
|
||||
channelStarred.invalidateCaches();
|
||||
}
|
||||
self._mailBus.trigger('update_message', message);
|
||||
}
|
||||
});
|
||||
|
||||
if (data.starred) {
|
||||
// increase starred counter if message is marked as star
|
||||
starred.incrementMailboxCounter(data.message_ids.length);
|
||||
} else {
|
||||
// decrease starred counter if message is remove from starred
|
||||
// if unstar_all then it will set to 0.
|
||||
starred.decrementMailboxCounter(data.message_ids.length);
|
||||
}
|
||||
|
||||
this._mailBus.trigger('update_starred', starred.getMailboxCounter());
|
||||
},
|
||||
/**
|
||||
* On receiving a transient message, i.e. a message which does not come from
|
||||
* a member of the channel. Usually a log message, such as one generated
|
||||
* from a command with ('/').
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {string} data.author_id
|
||||
*/
|
||||
_handlePartnerTransientMessageNotification: function (data) {
|
||||
var lastMessage = _.last(this._messages);
|
||||
data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01;
|
||||
data.author_id = data.author_id || this.getOdoobotID();
|
||||
this.addMessage(data);
|
||||
},
|
||||
/**
|
||||
* On receiving a unsubscribe from channel notification, confirm
|
||||
* unsubscription from channel and adapt screen accordingly.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object} data.id ID of the unsubscribed channel
|
||||
*/
|
||||
_handlePartnerUnsubscribeNotification: function (data) {
|
||||
var channel = this.getChannel(data.id);
|
||||
if (channel) {
|
||||
var message;
|
||||
if (_.contains(['public', 'private'], channel.getType())) {
|
||||
message = _.str.sprintf(
|
||||
_t("You unsubscribed from <b>%s</b>."),
|
||||
channel.getName()
|
||||
);
|
||||
} else {
|
||||
message = _.str.sprintf(
|
||||
_t("You unpinned your conversation with <b>%s</b>."),
|
||||
channel.getName()
|
||||
);
|
||||
}
|
||||
this._removeChannel(channel);
|
||||
this._mailBus.trigger('unsubscribe_from_channel', data.id);
|
||||
this.do_notify(_("Unsubscribed"), message);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* On receiving an update on user status (e.g. becoming 'online', 'offline',
|
||||
* 'idle', etc.).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data partner infos
|
||||
* @param {integer} data.id
|
||||
* @param {string} data.im_status
|
||||
*/
|
||||
_handlePresenceNotification: function (data) {
|
||||
var dm = this.getDmFromPartnerID(data.id);
|
||||
if (dm) {
|
||||
dm.setStatus(data.im_status);
|
||||
this._mailBus.trigger('update_dm_presence', dm);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update the message notification status of message based on update_message
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object[]} data.notifications
|
||||
* @param {mail.model.Message} message
|
||||
*/
|
||||
_updateMessageNotificationStatus: function (data, message) {
|
||||
_.each(data.notifications, function (notif, id) {
|
||||
var partnerName = notif[1];
|
||||
var notifStatus = notif[0];
|
||||
var res = _.find(message.getCustomerEmailData(), function (entry) {
|
||||
return entry[0] === parseInt(id);
|
||||
});
|
||||
if (res) {
|
||||
res[2] = notifStatus;
|
||||
} else {
|
||||
message.addCustomerEmailData([parseInt(id), partnerName, notifStatus]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Notification handlers
|
||||
* Sometimes, the web client receives unsubscribe notification and an extra
|
||||
* notification on that channel. This is then followed by an attempt to
|
||||
* rejoin the channel that we just left. The next few lines remove the
|
||||
* extra notification to prevent that situation to occur.
|
||||
*
|
||||
* @private
|
||||
* @param {Array} notifs
|
||||
*/
|
||||
_onNotification: function (notifs) {
|
||||
var self = this;
|
||||
notifs = this._filterNotificationsOnUnsubscribe(notifs);
|
||||
_.each(notifs, function (notif) {
|
||||
var model = notif[0][1];
|
||||
if (model === 'ir.needaction') {
|
||||
// new message in the inbox
|
||||
self._handleNeedactionNotification(notif[1]);
|
||||
} else if (model === 'mail.channel') {
|
||||
// new message in a channel
|
||||
self._handleChannelNotification(notif[1]);
|
||||
} else if (model === 'res.partner') {
|
||||
// channel joined/left, message marked as read/(un)starred,
|
||||
// chat open/closed, moderation specific, etc.
|
||||
self._handlePartnerNotification(notif[1]);
|
||||
} else if (model === 'bus.presence') {
|
||||
// update presence of users
|
||||
self._handlePresenceNotification(notif[1]);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return MailManager;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
odoo.define('mail.Service', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
var MailManager = require('mail.Manager');
|
||||
|
||||
require('mail.Manager.Notification');
|
||||
require('mail.Manager.Window');
|
||||
|
||||
core.serviceRegistry.add('mail_service', MailManager);
|
||||
|
||||
return MailManager;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,684 @@
|
||||
odoo.define('mail.Manager.Window', function (require) {
|
||||
"use strict";
|
||||
|
||||
var MailManager = require('mail.Manager');
|
||||
var ThreadWindow = require('mail.ThreadWindow');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var utils = require('web.utils');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
|
||||
|
||||
/**
|
||||
* Mail Window Manager
|
||||
*
|
||||
* This part of the mail manager is responsible for the management of thread
|
||||
* windows.
|
||||
*/
|
||||
MailManager.include({
|
||||
|
||||
// tell where to append thread window
|
||||
THREAD_WINDOW_APPENDTO: 'body',
|
||||
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._availableSlotsForThreadWindows = 0;
|
||||
this._hiddenThreadWindows = [];
|
||||
// used to keep dropdown open when closing thread windows
|
||||
this._keepHiddenThreadWindowsDropdownOpen = false;
|
||||
this._spaceLeftForThreadWindows = 0;
|
||||
this._threadWindows = [];
|
||||
// jQuery element for the dropdown of hidden thread windows
|
||||
// see _renderHiddenThreadWindowsDropdown
|
||||
this._$hiddenThreadWindowsDropdown = null;
|
||||
|
||||
this._mailBus
|
||||
.on('update_message', this, this._onUpdateMessage)
|
||||
.on('new_message', this, this._onNewMessage)
|
||||
.on('new_channel', this, this._onNewChannel)
|
||||
.on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible)
|
||||
.on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel)
|
||||
.on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter)
|
||||
.on('update_dm_presence', this, this._onUpdateDmPresence);
|
||||
|
||||
core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open the blank thread window (i.e. the thread window without any thread
|
||||
* linked to it). Make it if there is no blank thread window yet.
|
||||
*/
|
||||
openBlankThreadWindow: function () {
|
||||
var blankThreadWindow = this._getBlankThreadWindow();
|
||||
if (!blankThreadWindow) {
|
||||
blankThreadWindow = new ThreadWindow(this, null);
|
||||
this._addThreadWindow(blankThreadWindow);
|
||||
blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO)
|
||||
.then(this._repositionThreadWindows.bind(this));
|
||||
} else {
|
||||
if (blankThreadWindow.isHidden()) {
|
||||
this._makeThreadWindowVisible(blankThreadWindow);
|
||||
} else if (blankThreadWindow.isFolded()) {
|
||||
blankThreadWindow.toggleFold(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Open a DM in a thread window. This is useful when selecting a DM in the
|
||||
* blank thread window, so that it replaces it with the DM window.
|
||||
*
|
||||
* @param {integer} partnerID
|
||||
*/
|
||||
openDmWindow: function (partnerID) {
|
||||
var dm = this.getDmFromPartnerID(partnerID);
|
||||
if (!dm) {
|
||||
this._openAndDetachDm(partnerID);
|
||||
} else {
|
||||
this.openThreadWindow(dm.getID());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Open the thread window if discuss is not opened
|
||||
*
|
||||
* @override
|
||||
* @param {integer|string} threadID
|
||||
*/
|
||||
openThread: function (threadID) {
|
||||
if (!this._isDiscussOpen()) {
|
||||
var thread = this.getThread(threadID);
|
||||
if (thread) {
|
||||
thread.detach();
|
||||
}
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Open a thread in a thread window
|
||||
*
|
||||
* @param {integer} threadID a valid thread ID
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.passively] if set to true, open the thread
|
||||
* window without focusing the input and marking messages as read if it
|
||||
* is not open yet, and do nothing otherwise.
|
||||
* @param {boolean} [options.keepFoldState=false] if set to true, keep the
|
||||
* fold state of the thread
|
||||
*/
|
||||
openThreadWindow: function (threadID, options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
// valid threadID, therefore no check
|
||||
var thread = this.getThread(threadID);
|
||||
var threadWindow = this._getThreadWindow(threadID);
|
||||
if (!threadWindow) {
|
||||
threadWindow = this._makeNewThreadWindow(thread, options);
|
||||
this._placeNewThreadWindow(threadWindow, options.passively);
|
||||
|
||||
threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO))
|
||||
.then(function () {
|
||||
self._repositionThreadWindows();
|
||||
return thread.getMessages();
|
||||
}).then(function (messages) {
|
||||
threadWindow.render(messages);
|
||||
threadWindow.threadWidget.scrollToBottom();
|
||||
// setTimeout to prevent to execute handler on first
|
||||
// scrollTo, which is asynchronous
|
||||
setTimeout(function () {
|
||||
threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () {
|
||||
if (
|
||||
!threadWindow.isPassive() &&
|
||||
threadWindow.threadWidget.isAtBottom()
|
||||
) {
|
||||
thread.markAsRead();
|
||||
}
|
||||
}, 100));
|
||||
}, 0);
|
||||
if (options.passively) {
|
||||
// mark first unread messages as seen when focusing the
|
||||
// window, then on scroll to bottom as usual
|
||||
threadWindow.$('.o_mail_thread, .o_thread_composer')
|
||||
.one('click', function () {
|
||||
thread.markAsRead();
|
||||
});
|
||||
} else if (
|
||||
!self._areThreadWindowsHidden() &&
|
||||
!thread.isFolded()
|
||||
) {
|
||||
thread.markAsRead();
|
||||
}
|
||||
});
|
||||
} else if (!options.passively) {
|
||||
if (threadWindow.isHidden()) {
|
||||
this._makeThreadWindowVisible(threadWindow);
|
||||
}
|
||||
}
|
||||
threadWindow.updateVisualFoldState();
|
||||
},
|
||||
/**
|
||||
* Called when a thread has its window state that has been changed, so its
|
||||
* thread window view should be changed to match the model.
|
||||
*
|
||||
* @param {integer|string} threadID
|
||||
* @param {Object} options option to be applied on opening thread window, if
|
||||
* the thread is detached
|
||||
*/
|
||||
updateThreadWindow: function (threadID, options) {
|
||||
var thread = this.getThread(threadID);
|
||||
if (thread) {
|
||||
if (thread.isDetached()) {
|
||||
_.extend(options, { keepFoldState: true });
|
||||
this.openThreadWindow(threadID, options);
|
||||
} else {
|
||||
this._closeThreadWindow(threadID);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add the thread window such that it will be the left-most visible window
|
||||
*
|
||||
* @private
|
||||
* @param {mail.ThreadWindow} threadWindow
|
||||
*/
|
||||
_addThreadWindow: function (threadWindow) {
|
||||
this._computeAvailableSlotsForThreadWindows(this._threadWindows.length+1);
|
||||
this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow);
|
||||
},
|
||||
/**
|
||||
* States whether the thread windows are hidden or not.
|
||||
* When discuss is open, the thread windows are hidden.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_areThreadWindowsHidden: function () {
|
||||
return this._isDiscussOpen();
|
||||
},
|
||||
/**
|
||||
* Close the thread window linked to the thread with ID `threadID`.
|
||||
* If there is no window linked to this thread, do nothing.
|
||||
*
|
||||
* @private
|
||||
* @param {integer|string} threadID
|
||||
*/
|
||||
_closeThreadWindow: function (threadID) {
|
||||
var threadWindow = _.find(this._threadWindows, function (threadWindow) {
|
||||
return threadWindow.getID() === threadID;
|
||||
});
|
||||
if (threadWindow) {
|
||||
this._threadWindows = _.without(this._threadWindows, threadWindow);
|
||||
this._repositionThreadWindows();
|
||||
threadWindow.destroy();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Compute the number of available slots to display thread windows on the
|
||||
* screen. This is based on the width of the screen, and the width of a
|
||||
* single thread window.
|
||||
*
|
||||
* The available slots attributes are updated as a consequence of this
|
||||
* method call.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} nbWindows
|
||||
*/
|
||||
_computeAvailableSlotsForThreadWindows: function (nbWindows) {
|
||||
if (config.device.isMobile) {
|
||||
// one thread window full screen in mobile
|
||||
this._availableSlotsForThreadWindows = 1;
|
||||
return;
|
||||
}
|
||||
var width = window.innerWidth;
|
||||
var availableSlots = Math.floor(width/THREAD_WINDOW_WIDTH);
|
||||
var spaceLeft = width - (Math.min(availableSlots, nbWindows)*THREAD_WINDOW_WIDTH);
|
||||
if (availableSlots < nbWindows && spaceLeft < 50) {
|
||||
// leave at least 50px for the hidden windows dropdown button
|
||||
availableSlots--;
|
||||
spaceLeft += THREAD_WINDOW_WIDTH;
|
||||
}
|
||||
this._availableSlotsForThreadWindows = availableSlots;
|
||||
this._spaceLeftForThreadWindows = spaceLeft;
|
||||
},
|
||||
/**
|
||||
* Get the blank thread window, which is the special thread window that has
|
||||
* no thread linked to it.
|
||||
*
|
||||
* This is useful in case a DM window may replace the blank thread window,
|
||||
* when we want to open a DM from the blank thread window.
|
||||
*
|
||||
* @private
|
||||
* @returns {mail.ThreadWindow|undefined} the "blank thread" window,
|
||||
* if it exists, otherwise undefined
|
||||
*/
|
||||
_getBlankThreadWindow: function () {
|
||||
return _.find(this._threadWindows, function (threadWindow) {
|
||||
return threadWindow.getID() === '_blank';
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get thread window in the hidden windows matching ID `threadID`.
|
||||
*
|
||||
* Note: hidden windows are open windows that cannot be displayed
|
||||
* due to having more thread windows open than available slots for thread
|
||||
* windows on the screen. These thread windows are displayed in the hidden
|
||||
* thread window dropdown menu.
|
||||
*
|
||||
* @private
|
||||
* @param {integer|string} threadID
|
||||
* @returns {mail.ThreadWindow|undefined} the hidden thread window,
|
||||
* if exists
|
||||
*/
|
||||
_getHiddenThreadWindow: function (threadID) {
|
||||
return _.find(this._hiddenThreadWindows, function (threadWindow) {
|
||||
return threadWindow.getID() === threadID;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get thread window matching ID `threadID`
|
||||
*
|
||||
* @private
|
||||
* @param {integer} threadID
|
||||
* @returns {mail.ThreadWindow|undefined} the thread window, if exists
|
||||
*/
|
||||
_getThreadWindow: function (threadID) {
|
||||
return _.find(this._threadWindows, function (threadWindow) {
|
||||
return threadWindow.getID() === threadID;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Make the hidden thread window dropdown menu, that is render it and set
|
||||
* event listener on this dropdown menu DOM element.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_makeHiddenThreadWindowsDropdown: function () {
|
||||
var self = this;
|
||||
if (this._$hiddenThreadWindowsDropdown) {
|
||||
this._$hiddenThreadWindowsDropdown.remove();
|
||||
}
|
||||
if (this._hiddenThreadWindows.length) {
|
||||
this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown();
|
||||
var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown;
|
||||
$hiddenWindowsDropdown.css({right: THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 })
|
||||
.appendTo(this.THREAD_WINDOW_APPENDTO);
|
||||
this._repositionHiddenWindowsDropdown();
|
||||
this._keepHiddenThreadWindowsDropdownOpen = false;
|
||||
|
||||
$hiddenWindowsDropdown
|
||||
.on('click', '.o_thread_window_header', function (ev) {
|
||||
var threadID = $(ev.currentTarget).data('thread-id');
|
||||
var threadWindow = self._getHiddenThreadWindow(threadID);
|
||||
if (threadWindow) {
|
||||
self._makeThreadWindowVisible(threadWindow);
|
||||
}
|
||||
})
|
||||
.on('click', '.o_thread_window_close', function (ev) {
|
||||
var threadID = $(ev.currentTarget).closest('.o_thread_window_header')
|
||||
.data('thread-id');
|
||||
var threadWindow = self._getHiddenThreadWindow(threadID);
|
||||
if (threadWindow) {
|
||||
threadWindow.close();
|
||||
// keep the dropdown open
|
||||
self._keepHiddenThreadWindowsDropdownOpen = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Make a new thread window linked to a thread.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Thread} thread
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.passively=false]
|
||||
*/
|
||||
_makeNewThreadWindow: function (thread, options) {
|
||||
return new ThreadWindow(this, thread, _.extend(options, {
|
||||
autofocus: !options.passively,
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* Make an open thread window fully visible on screen.
|
||||
*
|
||||
* This method assumes that the thread window is hidden (i.e. in the hidden
|
||||
* dropdown menu). To make it visible, it swap the position of this thread
|
||||
* window with the last thread window that is visible (i.e. the left-most
|
||||
* visible thread window).
|
||||
*
|
||||
* @private
|
||||
* @param {mail.ThreadWindow} threadWindow
|
||||
*/
|
||||
_makeThreadWindowVisible: function (threadWindow) {
|
||||
utils.swap(
|
||||
this._threadWindows,
|
||||
threadWindow,
|
||||
this._threadWindows[this._availableSlotsForThreadWindows-1]
|
||||
);
|
||||
this._repositionThreadWindows();
|
||||
threadWindow.toggleFold(false);
|
||||
},
|
||||
/**
|
||||
* Open and detach the DM in a thread window.
|
||||
*
|
||||
* This method assumes that no such DM exists locally, so it is kind of a
|
||||
* "create DM and open DM window" operation
|
||||
*
|
||||
* @private
|
||||
* @param {integer} partnerID
|
||||
* @returns {$.Promise<integer>} resolved with ID of the dm channel
|
||||
*/
|
||||
_openAndDetachDm: function (partnerID) {
|
||||
return this._rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_get_and_minimize',
|
||||
args: [[partnerID]],
|
||||
})
|
||||
.then(this._addChannel.bind(this));
|
||||
},
|
||||
/**
|
||||
* On opening a new thread window, place it with other thread windows:
|
||||
*
|
||||
* - if it has been open with the blank thread window, replace the blank
|
||||
* thread window with this one
|
||||
* - if it has been open passively, simply but it after all windows
|
||||
* - otherwise, make it the left-most visible thread window
|
||||
*
|
||||
* @param {mail.ThreadWindow} threadWindow a thread window that is linked
|
||||
* to a thread (this must not be the blank thread window)
|
||||
* @param {boolean} [passively=false] if set, if the thread window does not
|
||||
* replace the blank thread window, it is add at the tail of the list of
|
||||
* thread windows, which might be put in the thread window hidden dropdown
|
||||
* menu if there are not enough space on the screen.
|
||||
*/
|
||||
_placeNewThreadWindow: function (threadWindow, passively) {
|
||||
var thread = this.getThread(threadWindow.getID());
|
||||
// replace the blank thread window?
|
||||
// the thread window should be a DM
|
||||
var blankThreadWindow = this._getBlankThreadWindow();
|
||||
if (
|
||||
blankThreadWindow &&
|
||||
thread.getType() === 'dm' &&
|
||||
thread.getDirectPartnerID() === blankThreadWindow.directPartnerID
|
||||
) {
|
||||
// the window takes the place of the 'blank' thread window
|
||||
var index = _.indexOf(this._threadWindows, blankThreadWindow);
|
||||
this._threadWindows[index] = threadWindow;
|
||||
blankThreadWindow.destroy();
|
||||
} else if (passively) {
|
||||
// simply insert the window to the left
|
||||
this._threadWindows.push(threadWindow);
|
||||
} else {
|
||||
// add window such that it is visible
|
||||
this._addThreadWindow(threadWindow);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Unfold dropdown to the left if there is enough space on the screen.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_repositionHiddenWindowsDropdown: function () {
|
||||
var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul');
|
||||
if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) {
|
||||
$dropdownUL.addClass('dropdown-menu-right');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Load the template of the hidden thread window dropdown
|
||||
*
|
||||
* @private
|
||||
* @returns {jQuery.Element}
|
||||
*/
|
||||
_renderHiddenThreadWindowsDropdown: function () {
|
||||
var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', {
|
||||
threadWindows: this._hiddenThreadWindows,
|
||||
open: this._keepHiddenThreadWindowsDropdownOpen,
|
||||
unreadCounter: this._hiddenThreadWindowsUnreadCounter,
|
||||
widget: {
|
||||
isMobile: function () {
|
||||
return config.device.isMobile;
|
||||
},
|
||||
},
|
||||
}));
|
||||
return $dropdown;
|
||||
},
|
||||
/**
|
||||
* Reposition the thread windows that should be hidden on the screen.
|
||||
* Thread windows that have an index equal or greater than `index` in the
|
||||
* attribute `threadWindows` should be hidden. Those thread windows are put
|
||||
* in the hidden thread window dropdown menu.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} startIndex the index of the first thread window to hide,
|
||||
* in increasing order of the thread windows in the `threadWindows`
|
||||
* attribute
|
||||
*/
|
||||
_repositionHiddenThreadWindows: function (startIndex) {
|
||||
var hiddenWindows = [];
|
||||
var hiddenUnreadCounter = 0;
|
||||
var index = startIndex;
|
||||
while (index < this._threadWindows.length) {
|
||||
var threadWindow = this._threadWindows[index];
|
||||
hiddenWindows.push(threadWindow);
|
||||
hiddenUnreadCounter += threadWindow.getUnreadCounter();
|
||||
threadWindow.do_hide();
|
||||
index++;
|
||||
}
|
||||
this._hiddenThreadWindows = hiddenWindows;
|
||||
this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter;
|
||||
|
||||
this._makeHiddenThreadWindowsDropdown();
|
||||
},
|
||||
/**
|
||||
* Reposition the thread windows, based on the size of the screen:
|
||||
*
|
||||
* - display thread windows by increasing order of index in
|
||||
* `_threadWindows` attribute, from right to left on the screen
|
||||
* - if there is no enough space to show all windows at once, display
|
||||
* a dropdown menu for hidden windows.
|
||||
*
|
||||
* This method should be called whenever there is a change of state of a
|
||||
* thread in a window. Also, when this method is called, all the windows
|
||||
* are visible and stacked in the top-left corner of the screen.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_repositionThreadWindows: function () {
|
||||
if (this._areThreadWindowsHidden()) {
|
||||
return;
|
||||
}
|
||||
this._computeAvailableSlotsForThreadWindows(this._threadWindows.length);
|
||||
var availableSlots = this._availableSlotsForThreadWindows;
|
||||
|
||||
this._repositionVisibleThreadWindows(availableSlots-1);
|
||||
this._repositionHiddenThreadWindows(availableSlots);
|
||||
},
|
||||
/**
|
||||
* Reposition the thread windows that should be visible on the screen.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} count how many thread windows can should be visible,
|
||||
* which are picked in the attribute `_threadWindows` in increasing index
|
||||
* order of appearance in the array. constraint:
|
||||
* 0 <= count < this._threadWindows.length
|
||||
*/
|
||||
_repositionVisibleThreadWindows: function (count) {
|
||||
var index = 0;
|
||||
while (index < count && index < this._threadWindows.length) {
|
||||
var threadWindow = this._threadWindows[index];
|
||||
threadWindow.$el.css({ right: THREAD_WINDOW_WIDTH*index, bottom: 0 });
|
||||
threadWindow.do_show();
|
||||
index++;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update thread windows state of threads that have `message`.
|
||||
* This is either a new message or an updated message.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Message} message
|
||||
* @param {boolean} [scrollBottom=false] if set, thread windows with this
|
||||
* message should scroll to the bottom if the message is visible
|
||||
*/
|
||||
_updateThreadWindows: function (message, scrollBottom) {
|
||||
var self = this;
|
||||
_.each(this._threadWindows, function (threadWindow) {
|
||||
if (_.contains(message.getThreadIDs(), threadWindow.getID())) {
|
||||
var thread = self.getThread(threadWindow.getID());
|
||||
var messageVisible = !self._areThreadWindowsHidden() &&
|
||||
!threadWindow.isFolded() &&
|
||||
!threadWindow.isHidden() &&
|
||||
threadWindow.threadWidget.isAtBottom();
|
||||
if (messageVisible && !threadWindow.isPassive()) {
|
||||
thread.markAsRead();
|
||||
}
|
||||
thread.getMessages()
|
||||
.then(function (messages) {
|
||||
threadWindow.render(messages);
|
||||
if (scrollBottom && messageVisible) {
|
||||
threadWindow.threadWidget.scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {boolean} open
|
||||
*/
|
||||
_onDiscussOpen: function (open) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
if (open) {
|
||||
$(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window');
|
||||
} else {
|
||||
$(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window');
|
||||
this._repositionThreadWindows();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when someone asks window manager whether the bottom of `thread` is
|
||||
* visible or not. An object `query` is provided in order to reponse on the
|
||||
* key `isDisplayed`.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Thread} thread
|
||||
* @param {Object} query
|
||||
* @param {boolean} query.isBottomVisible write on it
|
||||
*/
|
||||
_onIsThreadBottomVisible: function (thread, query) {
|
||||
_.each(this._threadWindows, function (threadWindow) {
|
||||
if (
|
||||
thread.getID() === threadWindow.getID() &&
|
||||
threadWindow.threadWidget.isAtBottom() &&
|
||||
!threadWindow.isHidden()
|
||||
) {
|
||||
query.isBottomVisible = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Show or hide window of this channel when a new channel is added.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Channel} channel
|
||||
*/
|
||||
_onNewChannel: function (channel) {
|
||||
if (channel.isDetached()) {
|
||||
this.openThreadWindow(channel.getID(), { keepFoldState: true });
|
||||
} else {
|
||||
this._closeThreadWindow(channel.getID());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update thread window containing this message when a new message is added.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} message
|
||||
*/
|
||||
_onNewMessage: function (message) {
|
||||
this._updateThreadWindows(message, true);
|
||||
},
|
||||
/**
|
||||
* Close the thread window when unsusbscribe from a channel.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} channelID
|
||||
*/
|
||||
_onUnsubscribeFromChannel: function (channelID) {
|
||||
this._closeThreadWindow(channelID);
|
||||
},
|
||||
/**
|
||||
* Called when a thread has its unread counter that has changed.
|
||||
* The unread counter on the thread windows should be updated.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Thread} thread
|
||||
*/
|
||||
_onUpdateThreadUnreadCounter: function (thread) {
|
||||
var self = this;
|
||||
this._hiddenThreadWindowsUnreadCounter = 0;
|
||||
_.each(this._threadWindows, function (threadWindow) {
|
||||
if (thread.getID() === threadWindow.getID()) {
|
||||
threadWindow.updateHeader();
|
||||
if (thread.getUnreadCounter() === 0) {
|
||||
threadWindow.removePassive();
|
||||
}
|
||||
}
|
||||
if (threadWindow.isHidden()) {
|
||||
self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter();
|
||||
}
|
||||
});
|
||||
if (this._$hiddenThreadWindowsDropdown) {
|
||||
this._$hiddenThreadWindowsDropdown.html(
|
||||
this._renderHiddenThreadWindowsDropdown().html());
|
||||
this._repositionHiddenWindowsDropdown();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when there is a change of the im status of the user linked to
|
||||
* DMs. The header of the thread window should be updated accordingly,
|
||||
* in order to display the correct new im status of this users.
|
||||
*
|
||||
* @private
|
||||
* @param {mail.model.Thread} thread
|
||||
*/
|
||||
_onUpdateDmPresence: function (thread) {
|
||||
_.each(this._threadWindows, function (threadWindow) {
|
||||
if (thread.getID() === threadWindow.getID()) {
|
||||
threadWindow.updateHeader();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called when a message has been updated.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} message
|
||||
*/
|
||||
_onUpdateMessage: function (message) {
|
||||
this._updateThreadWindows(message, false);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return MailManager;
|
||||
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
odoo.define('mail.systray', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var SystrayMenu = require('web.SystrayMenu');
|
||||
var time = require('web.time');
|
||||
var Widget = require('web.Widget');
|
||||
var QWeb = core.qweb;
|
||||
|
||||
/**
|
||||
* Menu item appended in the systray part of the navbar
|
||||
*
|
||||
* The menu item indicates the counter of needactions + unread messages in chat channels. When
|
||||
* clicking on it, it toggles a dropdown containing a preview of each pinned channels (except
|
||||
* static and mass mailing channels) with a quick link to open them in chat windows. It also
|
||||
* contains a direct link to the Inbox in Discuss.
|
||||
**/
|
||||
var MessagingMenu = Widget.extend({
|
||||
template:'mail.chat.MessagingMenu',
|
||||
events: {
|
||||
"click": "on_click",
|
||||
"click .o_filter_button": "on_click_filter_button",
|
||||
"click .o_new_message": "on_click_new_message",
|
||||
"click .o_mail_channel_preview": "_onClickChannel",
|
||||
"click .o_mail_channel_mark_read":"_onClickMarkRead",
|
||||
},
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.isMobile = config.device.isMobile; // used by the template
|
||||
},
|
||||
start: function () {
|
||||
this.$filter_buttons = this.$('.o_filter_button');
|
||||
this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels');
|
||||
this.filter = false;
|
||||
var self = this;
|
||||
this.call('chat_manager', 'isReady').then(function () {
|
||||
self.update_counter();
|
||||
var chatBus = self.call('chat_manager', 'getChatBus');
|
||||
chatBus.on('update_needaction', self, self.update_counter);
|
||||
chatBus.on('update_channel_unread_counter', self, self.update_counter);
|
||||
});
|
||||
return this._super();
|
||||
},
|
||||
is_open: function () {
|
||||
return this.$el.hasClass('open');
|
||||
},
|
||||
update_counter: function () {
|
||||
var messageFailures = this.call('chat_manager', 'getMessageFailures');
|
||||
var needactionCounter = this.call('chat_manager', 'getNeedactionCounter');
|
||||
var unreadConversationCounter = this.call('chat_manager', 'getUnreadConversationCounter');
|
||||
var counter = needactionCounter + unreadConversationCounter + messageFailures.length;
|
||||
this.$('.o_notification_counter').text(counter);
|
||||
this.$el.toggleClass('o_no_notification', !counter);
|
||||
if (this.is_open()) {
|
||||
this.update_channels_preview();
|
||||
}
|
||||
},
|
||||
update_channels_preview: function () {
|
||||
var self = this;
|
||||
// Display spinner while waiting for channels preview
|
||||
this.$channels_preview.html(QWeb.render('Spinner'));
|
||||
this.call('chat_manager', 'isReady').then(function () {
|
||||
var allChannels = self.call('chat_manager', 'getChannels');
|
||||
var channels = _.filter(allChannels, function (channel) {
|
||||
if (self.filter === 'chat') {
|
||||
return channel.is_chat;
|
||||
} else if (self.filter === 'channels') {
|
||||
return !channel.is_chat && channel.type !== 'static';
|
||||
} else {
|
||||
return channel.type !== 'static';
|
||||
}
|
||||
});
|
||||
var formatedFailures = [];
|
||||
var messageFailures = self.call('chat_manager', 'getMessageFailures');
|
||||
_.each(messageFailures, function (messageFailure) {
|
||||
var model = messageFailure.model;
|
||||
var existingFailure = _.findWhere(formatedFailures, { model: model });
|
||||
if (existingFailure) {
|
||||
if (existingFailure.res_id !== messageFailure.res_id) {
|
||||
existingFailure.res_id = null;
|
||||
existingFailure.record_name = messageFailure.model_name;//for display, will be used as subject
|
||||
}
|
||||
existingFailure.unread_counter ++;
|
||||
} else {
|
||||
messageFailure = _.clone(messageFailure);
|
||||
messageFailure.unread_counter = 1;
|
||||
messageFailure.id = "mail_failure";
|
||||
messageFailure.body = "An error occured sending an email";
|
||||
messageFailure.date = moment(time.str_to_datetime(messageFailure.last_message_date));
|
||||
messageFailure.displayed_author = "";
|
||||
formatedFailures.push(messageFailure);
|
||||
}
|
||||
});
|
||||
channels = _.union(channels, formatedFailures);
|
||||
self.call('chat_manager', 'getMessages', {channelID: 'channel_inbox'})
|
||||
.then(function (messages) {
|
||||
var res = [];
|
||||
_.each(messages, function (message) {
|
||||
message.unread_counter = 1;
|
||||
var duplicatedMessage = _.findWhere(res, {
|
||||
model: message.model,
|
||||
'res_id': message.res_id
|
||||
});
|
||||
if (message.model && message.res_id && duplicatedMessage) {
|
||||
message.unread_counter = duplicatedMessage.unread_counter + 1;
|
||||
res[_.findIndex(res, duplicatedMessage)] = message;
|
||||
} else {
|
||||
res.push(message);
|
||||
}
|
||||
});
|
||||
if (self.filter === 'channel_inbox' || !self.filter) {
|
||||
channels = _.union(channels, res);
|
||||
}
|
||||
self.call('chat_manager', 'getChannelsPreview', channels)
|
||||
.then(self._render_channels_preview.bind(self));
|
||||
});
|
||||
});
|
||||
},
|
||||
_render_channels_preview: function (channels_preview) {
|
||||
this.$channels_preview.html(QWeb.render('mail.chat.ChannelsPreview', {
|
||||
channels: channels_preview,
|
||||
}));
|
||||
},
|
||||
on_click: function () {
|
||||
if (!this.is_open()) {
|
||||
this.update_channels_preview(); // we are opening the dropdown so update its content
|
||||
}
|
||||
},
|
||||
on_click_filter_button: function (event) {
|
||||
event.stopPropagation();
|
||||
this.$filter_buttons.removeClass('active');
|
||||
var $target = $(event.currentTarget);
|
||||
$target.addClass('active');
|
||||
this.filter = $target.data('filter');
|
||||
this.update_channels_preview();
|
||||
},
|
||||
on_click_new_message: function () {
|
||||
this.call('chat_window_manager', 'openChat');
|
||||
},
|
||||
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
/**
|
||||
* When a channel is clicked on, we want to open chat/channel window
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickChannel: function (event) {
|
||||
var self = this;
|
||||
var channelID = $(event.currentTarget).data('channel_id');
|
||||
if (channelID === 'channel_inbox' || channelID === 'mail_failure') {
|
||||
var resID = $(event.currentTarget).data('res_id');
|
||||
var resModel = $(event.currentTarget).data('res_model');
|
||||
if (resModel && resModel !== 'mail.channel' && resID) {
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: resModel,
|
||||
views: [[false, 'form']],
|
||||
res_id: resID
|
||||
});
|
||||
} else if (resModel && resModel !== 'mail.channel' && !resID) {
|
||||
this.do_action({
|
||||
name: "Mail failures",
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: resModel,
|
||||
domain: [['message_has_error', '=', true]],
|
||||
});
|
||||
} else {
|
||||
var clientChatOptions = {clear_breadcrumbs: true};
|
||||
if (resModel && resModel === 'mail.channel' && resID) {
|
||||
clientChatOptions.active_id = resID;
|
||||
}
|
||||
this.do_action('mail.mail_channel_action_client_chat', clientChatOptions)
|
||||
.then(function () {
|
||||
self.trigger_up('hide_home_menu'); // we cannot 'go back to previous page' otherwise
|
||||
core.bus.trigger('change_menu_section', self.call('chat_manager', 'getDiscussMenuID'));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var channel = this.call('chat_manager', 'getChannel', channelID);
|
||||
if (channel) {
|
||||
this.call('chat_manager', 'openChannel', channel);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a channel Mark As Read button is clicked on, we want mark message as read
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickMarkRead: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var channelID = $(ev.currentTarget).data('channel_id'),
|
||||
channel = this.call('chat_manager', 'getChannel', channelID);
|
||||
//Mark only static channel's messages as read and clear notification
|
||||
if (channelID === 'channel_inbox') {
|
||||
var resID = $(ev.currentTarget).data('res_id');
|
||||
var model = $(ev.currentTarget).data('model');
|
||||
var domain = [['model', '=', model], ['res_id', '=', resID]];
|
||||
this.call('chat_manager', 'markAllAsRead', channel, domain);
|
||||
} else {
|
||||
this.call('chat_manager', 'markChannelAsSeen', channel);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Menu item appended in the systray part of the navbar, redirects to the next activities of all app
|
||||
*/
|
||||
var ActivityMenu = Widget.extend({
|
||||
template:'mail.chat.ActivityMenu',
|
||||
events: {
|
||||
"click": "_onActivityMenuClick",
|
||||
"click .o_mail_channel_preview": "_onActivityFilterClick",
|
||||
},
|
||||
start: function () {
|
||||
this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels');
|
||||
this.call('chat_manager', 'getChatBus').on("activity_updated", this, this._updateCounter);
|
||||
this.call('chat_manager', 'isReady').then(this._updateCounter.bind(this));
|
||||
this._updateActivityPreview();
|
||||
return this._super();
|
||||
},
|
||||
//--------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------
|
||||
/**
|
||||
* Make RPC and get current user's activity details
|
||||
* @private
|
||||
*/
|
||||
_getActivityData: function () {
|
||||
var self = this;
|
||||
|
||||
return self._rpc({
|
||||
model: 'res.users',
|
||||
method: 'systray_get_activities',
|
||||
args: [],
|
||||
kwargs: {context: session.user_context},
|
||||
}).then(function (data) {
|
||||
self.activities = data;
|
||||
self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0);
|
||||
self.$('.o_notification_counter').text(self.activityCounter);
|
||||
self.$el.toggleClass('o_no_notification', !self.activityCounter);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get particular model view to redirect on click of activity scheduled on that model.
|
||||
* @private
|
||||
* @param {string} model
|
||||
*/
|
||||
_getActivityModelViewID: function (model) {
|
||||
return this._rpc({
|
||||
model: model,
|
||||
method: 'get_activity_view_id'
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Check wether activity systray dropdown is open or not
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isOpen: function () {
|
||||
return this.$el.hasClass('open');
|
||||
},
|
||||
/**
|
||||
* Update(render) activity system tray view on activity updation.
|
||||
* @private
|
||||
*/
|
||||
_updateActivityPreview: function () {
|
||||
var self = this;
|
||||
self._getActivityData().then(function (){
|
||||
self.$activities_preview.html(QWeb.render('mail.chat.ActivityMenuPreview', {
|
||||
activities : self.activities
|
||||
}));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* update counter based on activity status(created or Done)
|
||||
* @private
|
||||
* @param {Object} [data] key, value to decide activity created or deleted
|
||||
* @param {String} [data.type] notification type
|
||||
* @param {Boolean} [data.activity_deleted] when activity deleted
|
||||
* @param {Boolean} [data.activity_created] when activity created
|
||||
*/
|
||||
_updateCounter: function (data) {
|
||||
if (data) {
|
||||
if (data.activity_created) {
|
||||
this.activityCounter ++;
|
||||
}
|
||||
if (data.activity_deleted && this.activityCounter > 0) {
|
||||
this.activityCounter --;
|
||||
}
|
||||
this.$('.o_notification_counter').text(this.activityCounter);
|
||||
this.$el.toggleClass('o_no_notification', !this.activityCounter);
|
||||
}
|
||||
},
|
||||
//------------------------------------------------------------
|
||||
// Handlers
|
||||
//-----------------------------------------------------------
|
||||
/**
|
||||
* Redirect to particular model view
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onActivityFilterClick: function (event) {
|
||||
// fetch the data from the button otherwise fetch the ones from the parent (.o_mail_channel_preview).
|
||||
var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data());
|
||||
var context = {};
|
||||
if (data.filter === 'my') {
|
||||
context['search_default_activities_overdue'] = 1;
|
||||
context['search_default_activities_today'] = 1;
|
||||
} else {
|
||||
context['search_default_activities_' + data.filter] = 1;
|
||||
}
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
name: data.model_name,
|
||||
res_model: data.res_model,
|
||||
views: [[false, 'kanban'], [false, 'form']],
|
||||
search_view_id: [false],
|
||||
domain: [['activity_user_id', '=', session.uid]],
|
||||
context:context,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* When menu clicked update activity preview if counter updated
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onActivityMenuClick: function () {
|
||||
if (!this._isOpen()) {
|
||||
this._updateActivityPreview();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
SystrayMenu.Items.push(MessagingMenu);
|
||||
SystrayMenu.Items.push(ActivityMenu);
|
||||
|
||||
// to test activity and messaging menus in qunit test cases we need it
|
||||
return {
|
||||
ActivityMenu: ActivityMenu,
|
||||
MessagingMenu: MessagingMenu,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
odoo.define('mail.systray.ActivityMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var SystrayMenu = require('web.SystrayMenu');
|
||||
var Widget = require('web.Widget');
|
||||
var QWeb = core.qweb;
|
||||
|
||||
/**
|
||||
* Menu item appended in the systray part of the navbar, redirects to the next
|
||||
* activities of all app
|
||||
*/
|
||||
var ActivityMenu = Widget.extend({
|
||||
name: 'activity_menu',
|
||||
template:'mail.systray.ActivityMenu',
|
||||
events: {
|
||||
'click': '_onActivityMenuClick',
|
||||
'click .o_mail_preview': '_onActivityFilterClick',
|
||||
},
|
||||
willStart: function () {
|
||||
return $.when(this.call('mail_service', 'isReady'));
|
||||
},
|
||||
start: function () {
|
||||
this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items');
|
||||
this.call('mail_service', 'getMailBus').on('activity_updated', this, this._updateCounter);
|
||||
this._updateCounter();
|
||||
this._updateActivityPreview();
|
||||
return this._super();
|
||||
},
|
||||
//--------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------
|
||||
/**
|
||||
* Make RPC and get current user's activity details
|
||||
* @private
|
||||
*/
|
||||
_getActivityData: function () {
|
||||
var self = this;
|
||||
|
||||
return self._rpc({
|
||||
model: 'res.users',
|
||||
method: 'systray_get_activities',
|
||||
args: [],
|
||||
kwargs: {context: session.user_context},
|
||||
}).then(function (data) {
|
||||
self._activities = data;
|
||||
self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0);
|
||||
self.$('.o_notification_counter').text(self.activityCounter);
|
||||
self.$el.toggleClass('o_no_notification', !self.activityCounter);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get particular model view to redirect on click of activity scheduled on that model.
|
||||
* @private
|
||||
* @param {string} model
|
||||
*/
|
||||
_getActivityModelViewID: function (model) {
|
||||
return this._rpc({
|
||||
model: model,
|
||||
method: 'get_activity_view_id'
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Check wether activity systray dropdown is open or not
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isOpen: function () {
|
||||
return this.$el.hasClass('open');
|
||||
},
|
||||
/**
|
||||
* Update(render) activity system tray view on activity updation.
|
||||
* @private
|
||||
*/
|
||||
_updateActivityPreview: function () {
|
||||
var self = this;
|
||||
self._getActivityData().then(function (){
|
||||
self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', {
|
||||
activities : self._activities
|
||||
}));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* update counter based on activity status(created or Done)
|
||||
* @private
|
||||
* @param {Object} [data] key, value to decide activity created or deleted
|
||||
* @param {String} [data.type] notification type
|
||||
* @param {Boolean} [data.activity_deleted] when activity deleted
|
||||
* @param {Boolean} [data.activity_created] when activity created
|
||||
*/
|
||||
_updateCounter: function (data) {
|
||||
if (data) {
|
||||
if (data.activity_created) {
|
||||
this.activityCounter ++;
|
||||
}
|
||||
if (data.activity_deleted && this.activityCounter > 0) {
|
||||
this.activityCounter --;
|
||||
}
|
||||
this.$('.o_notification_counter').text(this.activityCounter);
|
||||
this.$el.toggleClass('o_no_notification', !this.activityCounter);
|
||||
}
|
||||
},
|
||||
|
||||
//------------------------------------------------------------
|
||||
// Handlers
|
||||
//------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Redirect to particular model view
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onActivityFilterClick: function (event) {
|
||||
// fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview).
|
||||
var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data());
|
||||
var context = {};
|
||||
if (data.filter === 'my') {
|
||||
context['search_default_activities_overdue'] = 1;
|
||||
context['search_default_activities_today'] = 1;
|
||||
} else {
|
||||
context['search_default_activities_' + data.filter] = 1;
|
||||
}
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
name: data.model_name,
|
||||
res_model: data.res_model,
|
||||
views: [[false, 'kanban'], [false, 'form']],
|
||||
search_view_id: [false],
|
||||
domain: [['activity_user_id', '=', session.uid]],
|
||||
context:context,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* When menu clicked update activity preview if counter updated
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onActivityMenuClick: function () {
|
||||
if (!this._isOpen()) {
|
||||
this._updateActivityPreview();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
SystrayMenu.Items.push(ActivityMenu);
|
||||
|
||||
return ActivityMenu;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
odoo.define('mail.systray.MessagingMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var SystrayMenu = require('web.SystrayMenu');
|
||||
var Widget = require('web.Widget');
|
||||
var QWeb = core.qweb;
|
||||
|
||||
/**
|
||||
* Menu item appended in the systray part of the navbar
|
||||
*
|
||||
* The menu item indicates the counter of needactions + unread messages in chat
|
||||
* channels. When clicking on it, it toggles a dropdown containing a preview of
|
||||
* each pinned channels (except mailbox and mass mailing channels) with a quick
|
||||
* link to open them in chat windows. It also contains a direct link to the
|
||||
* Inbox in Discuss.
|
||||
**/
|
||||
var MessagingMenu = Widget.extend({
|
||||
name: 'messaging_menu',
|
||||
template:'mail.systray.MessagingMenu',
|
||||
events: {
|
||||
'click': '_onClick',
|
||||
'click .o_mail_preview': '_onClickPreview',
|
||||
'click .o_filter_button': '_onClickFilterButton',
|
||||
'click .o_new_message': '_onClickNewMessage',
|
||||
'click .o_mail_preview_mark_as_read': '_onClickPreviewMarkAsRead',
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
return $.when(this._super.apply(this, arguments), this.call('mail_service', 'isReady'));
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._$filterButtons = this.$('.o_filter_button');
|
||||
this._$previews = this.$('.o_mail_systray_dropdown_items');
|
||||
this._filter = false;
|
||||
this._updateCounter();
|
||||
var mailBus = this.call('mail_service', 'getMailBus');
|
||||
mailBus.on('update_needaction', this, this._updateCounter);
|
||||
mailBus.on('new_channel', this, this._updateCounter);
|
||||
mailBus.on('update_thread_unread_counter', this, this._updateCounter);
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* States whether the widget is in mobile mode or not.
|
||||
* This is used by the template.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMobile: function () {
|
||||
return config.device.isMobile;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when clicking on a preview related to a mail failure
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $target DOM of preview element clicked
|
||||
*/
|
||||
_clickMailFailurePreview: function ($target) {
|
||||
var documentID = $target.data('document-id');
|
||||
var documentModel = $target.data('document-model');
|
||||
if (documentModel && documentID) {
|
||||
this._openDocument(documentModel, documentID);
|
||||
} else if (documentModel !== 'mail.channel') {
|
||||
// preview of mail failures grouped to different document of same model
|
||||
this.do_action({
|
||||
name: "Mail failures",
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: documentModel,
|
||||
domain: [['message_has_error', '=', true]],
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @return {boolean} whether the messaging menu is open or not.
|
||||
*/
|
||||
_isOpen: function () {
|
||||
return this.$el.hasClass('open');
|
||||
},
|
||||
/**
|
||||
* Open discuss
|
||||
*
|
||||
* @private
|
||||
* @param {integer} [channelID] if set, auto-select this channel when
|
||||
* opening the discuss app.
|
||||
*/
|
||||
_openDiscuss: function (channelID) {
|
||||
var self = this;
|
||||
var discussOptions = { clear_breadcrumbs: true };
|
||||
|
||||
if (channelID) {
|
||||
discussOptions.active_id = channelID;
|
||||
}
|
||||
|
||||
this.do_action('mail.mail_channel_action_client_chat', discussOptions)
|
||||
.then(function () {
|
||||
// we cannot 'go back to previous page' otherwise
|
||||
self.trigger_up('hide_home_menu');
|
||||
core.bus.trigger('change_menu_section',
|
||||
self.call('mail_service', 'getDiscussMenuID'));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Open the document
|
||||
*
|
||||
* @private
|
||||
* @param {string} documentModel the model of the document
|
||||
* @param {integer} documentID
|
||||
*/
|
||||
_openDocument: function (documentModel, documentID) {
|
||||
if (documentModel === 'mail.channel') {
|
||||
this._openDiscuss(documentID);
|
||||
} else {
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: documentModel,
|
||||
views: [[false, 'form']],
|
||||
res_id: documentID
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Render the list of conversation previews
|
||||
*
|
||||
* @private
|
||||
* @param {Object} previews list of valid objects for preview rendering
|
||||
* (see mail.Preview template)
|
||||
*/
|
||||
_renderPreviews: function (previews) {
|
||||
this._$previews.html(QWeb.render('mail.systray.MessagingMenu.Previews', {
|
||||
previews: previews,
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* Get and render list of previews, based on the selected filter
|
||||
*
|
||||
* preview shows the last message of a channel with inline format.
|
||||
* There is a hack where filter "All" also shows preview of chatter
|
||||
* messages (which are not channels).
|
||||
*
|
||||
* List of filters:
|
||||
*
|
||||
* 1. All
|
||||
* - filter: undefined
|
||||
* - previews: last messages of all non-mailbox channels, in addition
|
||||
* to last messages of chatter (get from inbox)
|
||||
*
|
||||
* 2. Channel
|
||||
* - filter: "Channels"
|
||||
* - previews: last messages of all non-mailbox and non-DM channels
|
||||
*
|
||||
* 3. Chat
|
||||
* - filter: "Chat"
|
||||
* - previews: last messages of all DM channels
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updatePreviews: function () {
|
||||
// Display spinner while waiting for conversations preview
|
||||
this._$previews.html(QWeb.render('Spinner'));
|
||||
this.call('mail_service', 'getSystrayPreviews', this._filter)
|
||||
.then(this._renderPreviews.bind(this));
|
||||
},
|
||||
/**
|
||||
* Update the counter on the systray messaging menu icon.
|
||||
* The counter display the number of unread messages in channels (DM included), the number of
|
||||
* messages in Inbox mailbox, and the number of mail failures.
|
||||
* Also updates the previews if the messaging menu is open.
|
||||
*
|
||||
* Note that the number of unread messages in document thread are ignored, because they are
|
||||
* already considered in the number of messages in Inbox with the current design.
|
||||
* Also, some unread messages in channel can also be in inbox, so they are considered twice in
|
||||
* the counter. This is intended, as the number of needaction messages in a channel are
|
||||
* separately considered in the messaging menu from the unread messages, even though a message
|
||||
* can be both unread and needaction (such a message increments the counter twice).
|
||||
*
|
||||
* The global counter of the messaging menu should match the counter next to each of the preview
|
||||
* item when the messaging menu is open.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateCounter: function () {
|
||||
var counter;
|
||||
|
||||
var channels = this.call('mail_service', 'getChannels');
|
||||
var channelUnreadCounters = _.map(channels, function (channel) {
|
||||
return channel.getUnreadCounter();
|
||||
});
|
||||
var unreadChannelCounter = _.reduce(channelUnreadCounters, function (c1, c2) {
|
||||
return c1 + c2;
|
||||
}, 0);
|
||||
var inboxCounter = this.call('mail_service', 'getMailbox', 'inbox').getMailboxCounter();
|
||||
var mailFailureCounter = this.call('mail_service', 'getMailFailures').length;
|
||||
|
||||
counter = unreadChannelCounter + inboxCounter + mailFailureCounter;
|
||||
this.$('.o_notification_counter').text(counter);
|
||||
this.$el.toggleClass('o_no_notification', !counter);
|
||||
if (this._isOpen()) {
|
||||
this._updatePreviews();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClick: function () {
|
||||
if (!this._isOpen()) {
|
||||
// we are opening the dropdown so update its content
|
||||
this._updatePreviews();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickFilterButton: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._$filterButtons.removeClass('active');
|
||||
var $target = $(ev.currentTarget);
|
||||
$target.addClass('active');
|
||||
this._filter = $target.data('filter');
|
||||
this._updatePreviews();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickNewMessage: function () {
|
||||
this.call('mail_service', 'openBlankThreadWindow');
|
||||
},
|
||||
/**
|
||||
* When a preview is clicked on, we want to open the related object
|
||||
* (thread, mail failure, etc.)
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickPreview: function (ev) {
|
||||
var $target = $(ev.currentTarget);
|
||||
var previewID = $target.data('preview-id');
|
||||
|
||||
if (previewID === 'mail_failure') {
|
||||
this._clickMailFailurePreview($target);
|
||||
} else if (previewID === 'mailbox_inbox') {
|
||||
// inbox preview for non-document thread,
|
||||
// e.g. needaction message of channel
|
||||
var documentID = $target.data('document-id');
|
||||
var documentModel = $target.data('document-model');
|
||||
this._openDocument(documentModel, documentID);
|
||||
} else {
|
||||
// preview of thread
|
||||
this.call('mail_service', 'openThread', previewID);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a preview "Mark as read" button is clicked on, we want mark message
|
||||
* as read
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickPreviewMarkAsRead: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var thread;
|
||||
var $preview = $(ev.currentTarget).closest('.o_mail_preview');
|
||||
var previewID = $preview.data('preview-id');
|
||||
if (previewID === 'mailbox_inbox') {
|
||||
var documentModel = $preview.data('document-model');
|
||||
var documentID = $preview.data('document-id');
|
||||
var inbox = this.call('mail_service', 'getMailbox', 'inbox');
|
||||
var messages = inbox.getLocalMessages({
|
||||
documentModel: documentModel,
|
||||
documentID: documentID,
|
||||
});
|
||||
var messageIDs = _.map(messages, function (message) {
|
||||
return message.getID();
|
||||
});
|
||||
this.call('mail_service', 'markMessagesAsRead', messageIDs);
|
||||
} else if (previewID === 'mail_failure') {
|
||||
// AKU: ignored for the moment, this should be fixed when task
|
||||
// 1860054 is merged
|
||||
} else {
|
||||
// this is mark as read on a thread
|
||||
thread = this.call('mail_service', 'getThread', previewID);
|
||||
if (thread) {
|
||||
thread.markAsRead();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Systray menu items display order matches order in the list
|
||||
// lower index comes first, and display is from right to left.
|
||||
// For messagin menu, it should come before activity menu, if any
|
||||
// otherwise, it is the next systray item.
|
||||
var activityMenuIndex = _.findIndex(SystrayMenu.Items, function (SystrayMenuItem) {
|
||||
return SystrayMenuItem.prototype.name === 'activity_menu';
|
||||
});
|
||||
if (activityMenuIndex > 0) {
|
||||
SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu);
|
||||
} else {
|
||||
SystrayMenu.Items.push(MessagingMenu);
|
||||
}
|
||||
|
||||
return MessagingMenu;
|
||||
|
||||
});
|
||||
+401
-299
@@ -1,9 +1,11 @@
|
||||
odoo.define('mail.ChatThread', function (require) {
|
||||
odoo.define('mail.widget.Thread', function (require) {
|
||||
"use strict";
|
||||
|
||||
var DocumentViewer = require('mail.DocumentViewer');
|
||||
var mailUtils = require('mail.utils');
|
||||
|
||||
var core = require('web.core');
|
||||
var time = require('web.time');
|
||||
var DocumentViewer = require('mail.DocumentViewer');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
@@ -14,60 +16,31 @@ var ORDER = {
|
||||
DESC: -1,
|
||||
};
|
||||
|
||||
var read_more = _t('read more');
|
||||
var read_less = _t('read less');
|
||||
var READ_MORE = _t("read more");
|
||||
var READ_LESS = _t("read less");
|
||||
|
||||
function time_from_now(date) {
|
||||
if (moment().diff(date, 'seconds') < 45) {
|
||||
return _t("now");
|
||||
}
|
||||
return date.fromNow();
|
||||
}
|
||||
|
||||
var Thread = Widget.extend({
|
||||
/**
|
||||
* This is a generic widget to render a chat thread
|
||||
*/
|
||||
var ThreadWidget = Widget.extend({
|
||||
className: 'o_mail_thread',
|
||||
|
||||
events: {
|
||||
"click a": "on_click_redirect",
|
||||
"click img": "on_click_redirect",
|
||||
"click strong": "on_click_redirect",
|
||||
"click .o_thread_show_more": "on_click_show_more",
|
||||
"click .o_attachment_download": "_onAttachmentDownload",
|
||||
"click .o_attachment_view": "_onAttachmentView",
|
||||
"click .o_thread_message_needaction": function (event) {
|
||||
var messageId = $(event.currentTarget).data('message-id');
|
||||
this.trigger("mark_as_read", messageId);
|
||||
},
|
||||
"click .o_thread_message_moderation": "_onClickMessageModeration",
|
||||
"change .moderation_checkbox": "_onChangeModerationCheckbox",
|
||||
"click .o_thread_message_star": function (event) {
|
||||
var messageId = $(event.currentTarget).data('message-id');
|
||||
this.trigger("toggle_star_status", messageId);
|
||||
},
|
||||
"click .o_thread_message_email_exception": "_onClickMailException",
|
||||
"click .o_thread_message_reply": function (event) {
|
||||
this.selected_id = $(event.currentTarget).data('message-id');
|
||||
this.$('.o_thread_message').removeClass('o_thread_selected_message');
|
||||
this.$('.o_thread_message[data-message-id="' + this.selected_id + '"]')
|
||||
.addClass('o_thread_selected_message');
|
||||
this.trigger('select_message', this.selected_id);
|
||||
event.stopPropagation();
|
||||
},
|
||||
"click .oe_mail_expand": function (event) {
|
||||
event.preventDefault();
|
||||
var $message = $(event.currentTarget).parents('.o_thread_message');
|
||||
$message.addClass('o_message_expanded');
|
||||
this.expanded_msg_ids.push($message.data('message-id'));
|
||||
},
|
||||
"click .o_thread_message": function (event) {
|
||||
$(event.currentTarget).toggleClass('o_thread_selected_message');
|
||||
},
|
||||
"click": function () {
|
||||
if (this.selected_id) {
|
||||
this.unselect();
|
||||
this.trigger('unselect_message');
|
||||
}
|
||||
},
|
||||
'click a': '_onClickRedirect',
|
||||
'click img': '_onClickRedirect',
|
||||
'click strong': '_onClickRedirect',
|
||||
'click .o_thread_show_more': '_onClickShowMore',
|
||||
'click .o_attachment_download': '_onAttachmentDownload',
|
||||
'click .o_attachment_view': '_onAttachmentView',
|
||||
'click .o_thread_message_needaction': '_onClickMessageNeedaction',
|
||||
'click .o_thread_message_star': '_onClickMessageStar',
|
||||
'click .o_thread_message_reply': '_onClickMessageReply',
|
||||
'click .oe_mail_expand': '_onClickMailExpand',
|
||||
'click .o_thread_message': '_onClickMessage',
|
||||
'click': '_onClick',
|
||||
'click .o_thread_message_email_exception': '_onClickEmailException',
|
||||
'click .o_thread_message_moderation': '_onClickMessageModeration',
|
||||
'change .moderation_checkbox': '_onChangeModerationCheckbox',
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -77,243 +50,211 @@ var Thread = Widget.extend({
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
this.enabledOptions = _.defaults(options || {}, {
|
||||
display_order: ORDER.ASC,
|
||||
display_needactions: true,
|
||||
display_stars: true,
|
||||
display_document_link: true,
|
||||
display_avatar: true,
|
||||
squash_close_messages: true,
|
||||
display_email_icon: true,
|
||||
display_reply_icon: false,
|
||||
// options when the thread is enabled (e.g. can send message,
|
||||
// interact on messages, etc.)
|
||||
this._enabledOptions = _.defaults(options || {}, {
|
||||
displayOrder: ORDER.ASC,
|
||||
displayMarkAsRead: true,
|
||||
displayStars: true,
|
||||
displayDocumentLinks: true,
|
||||
displayAvatars: true,
|
||||
squashCloseMessages: true,
|
||||
displayEmailIcons: true,
|
||||
displayReplyIcons: false,
|
||||
loadMoreOnScroll: false,
|
||||
});
|
||||
this.disabledOptions = {
|
||||
display_order: this.enabledOptions.display_order,
|
||||
display_needactions: false,
|
||||
display_stars: false,
|
||||
display_document_link: false,
|
||||
display_avatar: this.enabledOptions.display_avatar,
|
||||
squash_close_messages: false,
|
||||
display_email_icon: false,
|
||||
display_reply_icon: false,
|
||||
loadMoreOnScroll: this.enabledOptions.loadMoreOnScroll,
|
||||
// options when the thread is disabled
|
||||
this._disabledOptions = {
|
||||
displayOrder: this._enabledOptions.displayOrder,
|
||||
displayMarkAsRead: false,
|
||||
displayStars: false,
|
||||
displayDocumentLinks: false,
|
||||
displayAvatars: this._enabledOptions.displayAvatars,
|
||||
squashCloseMessages: false,
|
||||
displayEmailIcons: false,
|
||||
displayReplyIcons: false,
|
||||
loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll,
|
||||
};
|
||||
this.expanded_msg_ids = [];
|
||||
this.selected_id = null;
|
||||
this._selectedMessageID = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object[]} messages
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
clearInterval(this._updateTimestampsInterval);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {mail.model.AbstractMessage[]} messages list of messages of the
|
||||
* thread, ordered by increasing IDs (a higher ID means a more recent
|
||||
* message).
|
||||
* @param {Object} [options]
|
||||
* @param {integer} [options.display_order=ORDER.ASC]
|
||||
* @param {boolean} [options.display_load_more]
|
||||
* @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
|
||||
* messages in the thread:
|
||||
* - ORDER.ASC: last message is at the bottom of the thread
|
||||
* - ORDER.DESC: last message is at the top of the thread
|
||||
* @param {boolean} [options.displayLoadMore]
|
||||
* @param {boolean} [options.isCreateMode]
|
||||
* @param {boolean} [options.squash_close_messages]
|
||||
* @param {boolean} [options.squashCloseMessages]
|
||||
*/
|
||||
render: function (messages, options) {
|
||||
var self = this;
|
||||
var msgs = _.map(messages, this._preprocessMessage.bind(this));
|
||||
|
||||
var modeOptions = options.isCreateMode ? this.disabledOptions : this.enabledOptions;
|
||||
if (modeOptions.display_order === ORDER.DESC) {
|
||||
msgs.reverse();
|
||||
// copy so that reverse do not alter order in the thread object
|
||||
messages = _.clone(messages);
|
||||
|
||||
var modeOptions = options.isCreateMode ? this._disabledOptions :
|
||||
this._enabledOptions;
|
||||
|
||||
// attachments ordered by messages order (increasing ID)
|
||||
this.attachments = _.uniq(_.flatten(_.map(messages, function (message) {
|
||||
return message.getAttachments();
|
||||
})));
|
||||
|
||||
if (modeOptions.displayOrder === ORDER.DESC) {
|
||||
messages.reverse();
|
||||
}
|
||||
options = _.extend({}, modeOptions, options);
|
||||
options = _.extend({}, modeOptions, options, {
|
||||
selectedMessageID: this._selectedMessageID,
|
||||
});
|
||||
|
||||
// Hide avatar and info of a message if that message and the previous
|
||||
// one are both comments wrote by the same author at the same minute
|
||||
// and in the same document (users can now post message in documents
|
||||
// directly from a channel that follows it)
|
||||
var prev_msg;
|
||||
_.each(msgs, function (msg) {
|
||||
if (!prev_msg || (Math.abs(msg.date.diff(prev_msg.date)) > 60000) ||
|
||||
prev_msg.message_type !== 'comment' || msg.message_type !== 'comment' ||
|
||||
(prev_msg.author_id[0] !== msg.author_id[0]) || prev_msg.model !== msg.model ||
|
||||
prev_msg.res_id !== msg.res_id) {
|
||||
msg.display_author = true;
|
||||
var prevMessage;
|
||||
_.each(messages, function (message) {
|
||||
if (
|
||||
// is first message of thread
|
||||
!prevMessage ||
|
||||
// more than 1 min. elasped
|
||||
(Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) ||
|
||||
prevMessage.getType() !== 'comment' ||
|
||||
message.getType() !== 'comment' ||
|
||||
// from a different author
|
||||
(prevMessage.getAuthorID() !== message.getAuthorID()) ||
|
||||
(
|
||||
// messages are linked to a document thread
|
||||
(
|
||||
prevMessage.isLinkedToDocumentThread() &&
|
||||
message.isLinkedToDocumentThread()
|
||||
) &&
|
||||
(
|
||||
// are from different documents
|
||||
prevMessage.getDocumentModel() !== message.getDocumentModel() ||
|
||||
prevMessage.getDocumentID() !== message.getDocumentID()
|
||||
)
|
||||
)
|
||||
) {
|
||||
message.displayAuthor(true);
|
||||
} else {
|
||||
msg.display_author = !options.squash_close_messages;
|
||||
message.displayAuthor(!options.squashCloseMessages);
|
||||
}
|
||||
if (msg.attachment_ids) {
|
||||
var sortedAttachments = _.partition(msg.attachment_ids, function (att) {
|
||||
return att.mimetype && att.mimetype.split('/')[0] === 'image';
|
||||
});
|
||||
msg.images = sortedAttachments[0];
|
||||
msg.others = sortedAttachments[1];
|
||||
}
|
||||
prev_msg = msg;
|
||||
prevMessage = message;
|
||||
});
|
||||
this.$el.html(QWeb.render('mail.ChatThread', {
|
||||
messages: msgs,
|
||||
this.$el.html(QWeb.render('mail.widget.Thread', {
|
||||
messages: messages,
|
||||
options: options,
|
||||
ORDER: ORDER,
|
||||
date_format: time.getLangDatetimeFormat(),
|
||||
dateFormat: time.getLangDatetimeFormat(),
|
||||
}));
|
||||
|
||||
this.attachments = _.uniq(_.flatten(_.map(messages, 'attachment_ids')));
|
||||
_.each(messages, function (message) {
|
||||
var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]');
|
||||
$message.find('.o_mail_timestamp').data('date', message.getDate());
|
||||
|
||||
_.each(msgs, function (msg) {
|
||||
var $msg = self.$('.o_thread_message[data-message-id="'+ msg.id +'"]');
|
||||
$msg.find('.o_mail_timestamp').data('date', msg.date);
|
||||
|
||||
self.insert_read_more($msg);
|
||||
self._insertReadMore($message);
|
||||
});
|
||||
|
||||
if (!this.update_timestamps_interval) {
|
||||
this.update_timestamps_interval = setInterval(function () {
|
||||
self.update_timestamps();
|
||||
if (!this._updateTimestampsInterval) {
|
||||
this.updateTimestampsInterval = setInterval(function () {
|
||||
self._updateTimestamps();
|
||||
}, 1000*60);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Modifies $element to add the 'read more/read less' functionality
|
||||
* All element nodes with "data-o-mail-quote" attribute are concerned.
|
||||
* All text nodes after a ``#stopSpelling`` element are concerned.
|
||||
* Those text nodes need to be wrapped in a span (toggle functionality).
|
||||
* All consecutive elements are joined in one 'read more/read less'.
|
||||
*/
|
||||
insert_read_more: function ($element) {
|
||||
var self = this;
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
var groups = [];
|
||||
var read_more_nodes;
|
||||
|
||||
// nodeType 1: element_node
|
||||
// nodeType 3: text_node
|
||||
var $children = $element.contents()
|
||||
.filter(function () {
|
||||
return this.nodeType === 1 || this.nodeType === 3 && this.nodeValue.trim();
|
||||
});
|
||||
|
||||
_.each($children, function (child) {
|
||||
var $child = $(child);
|
||||
|
||||
// Hide Text nodes if "stopSpelling"
|
||||
if (child.nodeType === 3 && $child.prevAll("[id*='stopSpelling']").length > 0) {
|
||||
// Convert Text nodes to Element nodes
|
||||
$child = $('<span>', {
|
||||
text: child.textContent,
|
||||
"data-o-mail-quote": "1",
|
||||
});
|
||||
child.parentNode.replaceChild($child[0], child);
|
||||
}
|
||||
|
||||
// Create array for each "read more" with nodes to toggle
|
||||
if ($child.attr('data-o-mail-quote') || ($child.get(0).nodeName === 'BR' && $child.prev("[data-o-mail-quote='1']").length > 0)) {
|
||||
if (!read_more_nodes) {
|
||||
read_more_nodes = [];
|
||||
groups.push(read_more_nodes);
|
||||
}
|
||||
$child.hide();
|
||||
read_more_nodes.push($child);
|
||||
} else {
|
||||
read_more_nodes = undefined;
|
||||
self.insert_read_more($child);
|
||||
}
|
||||
});
|
||||
|
||||
_.each(groups, function (group) {
|
||||
// Insert link just before the first node
|
||||
var $read_more = $('<a>', {
|
||||
class: "o_mail_read_more",
|
||||
href: "#",
|
||||
text: read_more,
|
||||
}).insertBefore(group[0]);
|
||||
|
||||
// Toggle All next nodes
|
||||
var is_read_more = true;
|
||||
$read_more.click(function (e) {
|
||||
e.preventDefault();
|
||||
is_read_more = !is_read_more;
|
||||
_.each(group, function ($child) {
|
||||
$child.hide();
|
||||
$child.toggle(!is_read_more);
|
||||
});
|
||||
$read_more.text(is_read_more ? read_more : read_less);
|
||||
});
|
||||
});
|
||||
},
|
||||
update_timestamps: function () {
|
||||
var isAtBottom = this.is_at_bottom();
|
||||
this.$('.o_mail_timestamp').each(function () {
|
||||
var date = $(this).data('date');
|
||||
$(this).html(time_from_now(date));
|
||||
});
|
||||
if (isAtBottom && !this.is_at_bottom()) {
|
||||
this.scroll_to();
|
||||
}
|
||||
},
|
||||
on_click_redirect: function (event) {
|
||||
// ignore inherited branding
|
||||
if ($(event.target).data('oe-field') !== undefined) {
|
||||
return;
|
||||
}
|
||||
var id = $(event.target).data('oe-id');
|
||||
if (id) {
|
||||
event.preventDefault();
|
||||
var model = $(event.target).data('oe-model');
|
||||
var options = model && (model !== 'mail.channel') ? {model: model, id: id} : {channel_id: id};
|
||||
this._redirect(options);
|
||||
}
|
||||
},
|
||||
|
||||
on_click_show_more: function () {
|
||||
this.trigger('load_more_messages');
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes a message and re-renders the thread
|
||||
* @param {int} [message_id] the id of the removed message
|
||||
* @param {array} [messages] the list of messages to display, without the removed one
|
||||
* @param {object} [options] options for the thread rendering
|
||||
*/
|
||||
remove_message_and_render: function (message_id, messages, options) {
|
||||
var self = this;
|
||||
var done = $.Deferred();
|
||||
this.$('.o_thread_message[data-message-id="' + message_id + '"]').fadeOut({
|
||||
done: function () { self.render(messages, options); done.resolve();},
|
||||
duration: 200,
|
||||
});
|
||||
return done;
|
||||
},
|
||||
|
||||
/**
|
||||
* Scrolls the thread to a given message or offset if any, to bottom otherwise
|
||||
* @param {int} [options.id] optional: the id of the message to scroll to
|
||||
* @param {int} [options.offset] optional: the number of pixels to scroll
|
||||
*/
|
||||
scroll_to: function (options) {
|
||||
options = options || {};
|
||||
if (options.id !== undefined) {
|
||||
var $target = this.$('.o_thread_message[data-message-id="' + options.id + '"]');
|
||||
if (options.only_if_necessary) {
|
||||
var delta = $target.parent().height() - $target.height();
|
||||
var offset = delta < 0 ? 0 : delta - ($target.offset().top - $target.offsetParent().offset().top);
|
||||
offset = - Math.min(offset, 0);
|
||||
this.$el.scrollTo("+=" + offset + "px", options);
|
||||
} else if ($target.length) {
|
||||
this.$el.scrollTo($target);
|
||||
}
|
||||
} else if (options.offset !== undefined) {
|
||||
this.$el.scrollTop(options.offset);
|
||||
} else {
|
||||
this.$el.scrollTop(this.el.scrollHeight);
|
||||
}
|
||||
},
|
||||
get_scrolltop: function () {
|
||||
getScrolltop: function () {
|
||||
return this.$el.scrollTop();
|
||||
},
|
||||
is_at_bottom: function () {
|
||||
return this.el.scrollHeight - this.$el.scrollTop() - this.$el.outerHeight() < 5;
|
||||
/**
|
||||
* State whether the bottom of the thread is visible or not,
|
||||
* with a tolerance of 5 pixels
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isAtBottom: function () {
|
||||
var fullHeight = this.el.scrollHeight;
|
||||
var topHiddenHeight = this.$el.scrollTop();
|
||||
var visibleHeight = this.$el.outerHeight();
|
||||
var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight;
|
||||
return bottomHiddenHeight < 5;
|
||||
},
|
||||
unselect: function () {
|
||||
this.$('.o_thread_message').removeClass('o_thread_selected_message');
|
||||
this.selected_id = null;
|
||||
/**
|
||||
* Removes a message and re-renders the thread
|
||||
*
|
||||
* @param {integer} [messageID] the id of the removed message
|
||||
* @param {mail.model.AbstractMessage[]} [messages] the list of messages to
|
||||
* display, without the removed one
|
||||
* @param {Object} [options] options for the thread rendering
|
||||
*/
|
||||
removeMessageAndRender: function (messageID, messages, options) {
|
||||
var self = this;
|
||||
var done = $.Deferred();
|
||||
this.$('.o_thread_message[data-message-id="' + messageID + '"]')
|
||||
.fadeOut({
|
||||
done: function () {
|
||||
self.render(messages, options);
|
||||
done.resolve();
|
||||
},
|
||||
duration: 200,
|
||||
});
|
||||
return done;
|
||||
},
|
||||
destroy: function () {
|
||||
clearInterval(this.update_timestamps_interval);
|
||||
/**
|
||||
* Scroll to the bottom of the thread
|
||||
*/
|
||||
scrollToBottom: function () {
|
||||
this.$el.scrollTop(this.el.scrollHeight);
|
||||
},
|
||||
/**
|
||||
* Scrolls the thread to a given message
|
||||
*
|
||||
* @param {integer} options.messageID the ID of the message to scroll to
|
||||
* @param {integer} [options.duration]
|
||||
* @param {boolean} [options.onlyIfNecessary]
|
||||
*/
|
||||
scrollToMessage: function (options) {
|
||||
var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]');
|
||||
if (options.onlyIfNecessary) {
|
||||
var delta = $target.parent().height() - $target.height();
|
||||
var offset = delta < 0 ?
|
||||
0 :
|
||||
delta - ($target.offset().top - $target.offsetParent().offset().top);
|
||||
offset = - Math.min(offset, 0);
|
||||
this.$el.scrollTo("+=" + offset + "px", options.duration);
|
||||
} else if ($target.length) {
|
||||
this.$el.scrollTo($target);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Scroll to the specific position in pixel
|
||||
*
|
||||
* If no position is provided, scroll to the bottom of the thread
|
||||
*
|
||||
* @param {integer} [position] distance from top to position in pixels.
|
||||
* If not provided, scroll to the bottom.
|
||||
*/
|
||||
scrollToPosition: function (position) {
|
||||
if (position) {
|
||||
this.$el.scrollTop(position);
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Toggle all the moderation checkboxes in the thread
|
||||
@@ -324,56 +265,132 @@ var Thread = Widget.extend({
|
||||
toggleModerationCheckboxes: function (checked) {
|
||||
this.$('.moderation_checkbox').prop('checked', checked);
|
||||
},
|
||||
/**
|
||||
* Unselect the selected message
|
||||
*/
|
||||
unselectMessage: function () {
|
||||
this.$('.o_thread_message').removeClass('o_thread_selected_message');
|
||||
this._selectedMessageID = null;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Modifies $element to add the 'read more/read less' functionality
|
||||
* All element nodes with 'data-o-mail-quote' attribute are concerned.
|
||||
* All text nodes after a ``#stopSpelling`` element are concerned.
|
||||
* Those text nodes need to be wrapped in a span (toggle functionality).
|
||||
* All consecutive elements are joined in one 'read more/read less'.
|
||||
*
|
||||
* @private
|
||||
* @param {jQuery} $element
|
||||
*/
|
||||
_insertReadMore: function ($element) {
|
||||
var self = this;
|
||||
|
||||
var groups = [];
|
||||
var readMoreNodes;
|
||||
|
||||
// nodeType 1: element_node
|
||||
// nodeType 3: text_node
|
||||
var $children = $element.contents()
|
||||
.filter(function () {
|
||||
return this.nodeType === 1 ||
|
||||
this.nodeType === 3 &&
|
||||
this.nodeValue.trim();
|
||||
});
|
||||
|
||||
_.each($children, function (child) {
|
||||
var $child = $(child);
|
||||
|
||||
// Hide Text nodes if "stopSpelling"
|
||||
if (
|
||||
child.nodeType === 3 &&
|
||||
$child.prevAll('[id*="stopSpelling"]').length > 0
|
||||
) {
|
||||
// Convert Text nodes to Element nodes
|
||||
$child = $('<span>', {
|
||||
text: child.textContent,
|
||||
'data-o-mail-quote': '1',
|
||||
});
|
||||
child.parentNode.replaceChild($child[0], child);
|
||||
}
|
||||
|
||||
// Create array for each 'read more' with nodes to toggle
|
||||
if (
|
||||
$child.attr('data-o-mail-quote') ||
|
||||
(
|
||||
$child.get(0).nodeName === 'BR' &&
|
||||
$child.prev('[data-o-mail-quote="1"]').length > 0
|
||||
)
|
||||
) {
|
||||
if (!readMoreNodes) {
|
||||
readMoreNodes = [];
|
||||
groups.push(readMoreNodes);
|
||||
}
|
||||
$child.hide();
|
||||
readMoreNodes.push($child);
|
||||
} else {
|
||||
readMoreNodes = undefined;
|
||||
self._insertReadMore($child);
|
||||
}
|
||||
});
|
||||
|
||||
_.each(groups, function (group) {
|
||||
// Insert link just before the first node
|
||||
var $readMore = $('<a>', {
|
||||
class: 'o_mail_read_more',
|
||||
href: '#',
|
||||
text: READ_MORE,
|
||||
}).insertBefore(group[0]);
|
||||
|
||||
// Toggle All next nodes
|
||||
var isReadMore = true;
|
||||
$readMore.click(function (e) {
|
||||
e.preventDefault();
|
||||
isReadMore = !isReadMore;
|
||||
_.each(group, function ($child) {
|
||||
$child.hide();
|
||||
$child.toggle(!isReadMore);
|
||||
});
|
||||
$readMore.text(isReadMore ? READ_MORE : READ_LESS);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
* @param {integer} [message.id]
|
||||
* @param {string} [message.model]
|
||||
* @param {string} [message.subject]
|
||||
* @param {string} [message.message_type]
|
||||
*/
|
||||
_preprocessMessage: function (message) {
|
||||
var msg = _.extend({}, message);
|
||||
|
||||
msg.date = moment.min(msg.date, moment());
|
||||
msg.hour = time_from_now(msg.date);
|
||||
|
||||
var date = msg.date.format('YYYY-MM-DD');
|
||||
if (date === moment().format('YYYY-MM-DD')) {
|
||||
msg.day = _t("Today");
|
||||
} else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
|
||||
msg.day = _t("Yesterday");
|
||||
} else {
|
||||
msg.day = msg.date.format('LL');
|
||||
}
|
||||
|
||||
if (_.contains(this.expanded_msg_ids, message.id)) {
|
||||
msg.expanded = true;
|
||||
}
|
||||
|
||||
msg.display_subject = message.subject && message.message_type !== 'notification' && !(message.model && (message.model !== 'mail.channel'));
|
||||
msg.is_selected = msg.id === this.selected_id;
|
||||
return msg;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} options
|
||||
* @param {integer} [options.channel_id]
|
||||
* @param {integer} [options.channelID]
|
||||
* @param {string} options.model
|
||||
* @param {integer} options.id
|
||||
*/
|
||||
_redirect: _.debounce(function (options) {
|
||||
if ('channel_id' in options) {
|
||||
this.trigger('redirect_to_channel', options.channel_id);
|
||||
if ('channelID' in options) {
|
||||
this.trigger('redirect_to_channel', options.channelID);
|
||||
} else {
|
||||
this.trigger('redirect', options.model, options.id);
|
||||
}
|
||||
}, 500, true),
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_updateTimestamps: function () {
|
||||
var isAtBottom = this.isAtBottom();
|
||||
this.$('.o_mail_timestamp').each(function () {
|
||||
var date = $(this).data('date');
|
||||
$(this).html(mailUtils.timeFromNow(date));
|
||||
});
|
||||
if (isAtBottom && !this.isAtBottom()) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
@@ -407,30 +424,115 @@ var Thread = Widget.extend({
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageModeration: function (ev) {
|
||||
var $button = $(ev.currentTarget);
|
||||
var messageID = $button.data('message-id');
|
||||
var decision = $button.data('decision');
|
||||
this.trigger_up('message_moderation', { messageID: messageID, decision: decision });
|
||||
_onClick: function () {
|
||||
if (this._selectedMessageID) {
|
||||
this.unselectMessage();
|
||||
this.trigger('unselect_message');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMailException: function (event) {
|
||||
var messageId = $(event.currentTarget).data('message-id');
|
||||
_onClickEmailException: function (ev) {
|
||||
var messageID = $(ev.currentTarget).data('message-id');
|
||||
this.do_action('mail.mail_resend_message_action', {
|
||||
additional_context: {
|
||||
mail_message_to_resend: messageId
|
||||
additional_context: {
|
||||
mail_message_to_resend: messageID
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMailExpand: function (ev) {
|
||||
ev.preventDefault();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessage: function (ev) {
|
||||
$(ev.currentTarget).toggleClass('o_thread_selected_message');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageNeedaction: function (ev) {
|
||||
var messageID = $(ev.currentTarget).data('message-id');
|
||||
this.trigger('mark_as_read', messageID);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageReply: function (ev) {
|
||||
this._selectedMessageID = $(ev.currentTarget).data('message-id');
|
||||
this.$('.o_thread_message').removeClass('o_thread_selected_message');
|
||||
this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]')
|
||||
.addClass('o_thread_selected_message');
|
||||
this.trigger('select_message', this._selectedMessageID);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageStar: function (ev) {
|
||||
var messageID = $(ev.currentTarget).data('message-id');
|
||||
this.trigger('toggle_star_status', messageID);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageModeration: function (ev) {
|
||||
var $button = $(ev.currentTarget);
|
||||
var messageID = $button.data('message-id');
|
||||
var decision = $button.data('decision');
|
||||
this.trigger_up('message_moderation', {
|
||||
messageID: messageID,
|
||||
decision: decision,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickRedirect: function (ev) {
|
||||
// ignore inherited branding
|
||||
if ($(ev.target).data('oe-field') !== undefined) {
|
||||
return;
|
||||
}
|
||||
var id = $(ev.target).data('oe-id');
|
||||
if (id) {
|
||||
ev.preventDefault();
|
||||
var model = $(ev.target).data('oe-model');
|
||||
var options;
|
||||
if (model && (model !== 'mail.channel')) {
|
||||
options = {
|
||||
model: model,
|
||||
id: id
|
||||
};
|
||||
} else {
|
||||
options = { channelID: id };
|
||||
}
|
||||
this._redirect(options);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickShowMore: function () {
|
||||
this.trigger('load_more_messages');
|
||||
},
|
||||
});
|
||||
|
||||
Thread.ORDER = ORDER;
|
||||
ThreadWidget.ORDER = ORDER;
|
||||
|
||||
return Thread;
|
||||
return ThreadWidget;
|
||||
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
odoo.define('mail.ThreadField', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatThread = require('mail.ChatThread');
|
||||
var Message = require('mail.model.Message');
|
||||
var ThreadWidget = require('mail.widget.Thread');
|
||||
|
||||
var AbstractField = require('web.AbstractField');
|
||||
var core = require('web.core');
|
||||
@@ -11,69 +12,69 @@ var session = require('web.session');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 'mail_thread' widget: displays the thread of messages
|
||||
// -----------------------------------------------------------------------------
|
||||
/**
|
||||
* 'mail_thread' widget: displays the thread of messages
|
||||
*/
|
||||
var ThreadField = AbstractField.extend({
|
||||
// inherited
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.msgIDs = this.value.res_ids;
|
||||
this._setDocumentThread();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
return this.alive(this.call('chat_manager', 'isReady'));
|
||||
return this.alive(this.call('mail_service', 'isReady'));
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
|
||||
this.thread = new ChatThread(this, {
|
||||
display_order: ChatThread.ORDER.DESC,
|
||||
display_document_link: false,
|
||||
display_needactions: false,
|
||||
squash_close_messages: false,
|
||||
this._threadWidget = new ThreadWidget(this, {
|
||||
displayOrder: ThreadWidget.ORDER.DESC,
|
||||
displayDocumentLinks: false,
|
||||
displayMarkAsRead: false,
|
||||
squashCloseMessages: false,
|
||||
});
|
||||
|
||||
this.thread.on('load_more_messages', this, this._onLoadMoreMessages);
|
||||
this.thread.on('redirect', this, this._onRedirect);
|
||||
this.thread.on('redirect_to_channel', this, this._onRedirectToChannel);
|
||||
this.thread.on('toggle_star_status', this, function (messageID) {
|
||||
self.call('chat_manager', 'toggleStarStatus', messageID);
|
||||
this._threadWidget.on('load_more_messages', this, this._onLoadMoreMessages);
|
||||
this._threadWidget.on('redirect', this, this._onRedirect);
|
||||
this._threadWidget.on('redirect_to_channel', this, this._onRedirectToChannel);
|
||||
this._threadWidget.on('toggle_star_status', this, function (messageID) {
|
||||
var message = self.call('mail_service', 'getMessage', messageID);
|
||||
message.toggleStarStatus();
|
||||
});
|
||||
|
||||
var def1 = this.thread.appendTo(this.$el);
|
||||
var def1 = this._threadWidget.appendTo(this.$el);
|
||||
var def2 = this._super.apply(this, arguments);
|
||||
|
||||
return this.alive($.when(def1, def2)).then(function () {
|
||||
// unwrap the thread to remove an unnecessary level on div
|
||||
self.setElement(self.thread.$el);
|
||||
var chatBus = self.call('chat_manager', 'getChatBus');
|
||||
chatBus.on('new_message', self, self._onNewMessage);
|
||||
chatBus.on('update_message', self, self._onUpdateMessage);
|
||||
self.setElement(self._threadWidget.$el);
|
||||
var mailBus = self.call('mail_service', 'getMailBus');
|
||||
mailBus.on('new_message', self, self._onNewMessage);
|
||||
mailBus.on('update_message', self, self._onUpdateMessage);
|
||||
});
|
||||
},
|
||||
_render: function () {
|
||||
return this._fetchAndRenderThread(this.msgIDs);
|
||||
},
|
||||
isSet: function () {
|
||||
return true;
|
||||
},
|
||||
destroy: function () {
|
||||
this.call('chat_manager', 'removeChatterMessages', this.model);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
_reset: function (record) {
|
||||
this._super.apply(this, arguments);
|
||||
this.msgIDs = this.value.res_ids;
|
||||
// the mail widgets being persistent, one need to update the res_id on reset
|
||||
this.res_id = record.res_id;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @return {boolean}
|
||||
*/
|
||||
isSet: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* @param {Object} message
|
||||
* @param {integer[]} message.partner_ids
|
||||
@@ -81,15 +82,14 @@ var ThreadField = AbstractField.extend({
|
||||
*/
|
||||
postMessage: function (message) {
|
||||
var self = this;
|
||||
var options = {model: this.model, res_id: this.res_id};
|
||||
return this.call('chat_manager', 'postMessage', message, options)
|
||||
return this._documentThread.postMessage(message)
|
||||
.then(function () {
|
||||
if (message.partner_ids.length) {
|
||||
self.trigger_up('reload_mail_fields', {followers: true});
|
||||
self.trigger_up('reload_mail_fields', { followers: true });
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
self.do_notify(_t('Sending Error'), _t('Your message has not been sent.'));
|
||||
self.do_notify(_t("Sending Error"), _t("Your message has not been sent."));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -99,43 +99,80 @@ var ThreadField = AbstractField.extend({
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Object[]} an array containing a single message 'Creating a new record...'
|
||||
*/
|
||||
_forgeCreationModeMessages: function () {
|
||||
return [{
|
||||
id: 0,
|
||||
body: "<p>Creating a new record...</p>",
|
||||
date: moment(),
|
||||
author_id: [session.partner_id, session.partner_display_name],
|
||||
displayed_author: session.partner_display_name,
|
||||
avatar_src: "/web/image/res.partner/" + session.partner_id + "/image_small",
|
||||
attachment_ids: [],
|
||||
customer_email_data: [],
|
||||
tracking_value_ids: [],
|
||||
}];
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer[]} ids
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.forceFetch]
|
||||
* @return {$.Deferred}
|
||||
*/
|
||||
_fetchAndRenderThread: function (ids, options) {
|
||||
_fetchAndRenderThread: function (options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
options.ids = ids;
|
||||
var fetch_def = this.dp.add(this.call('chat_manager', 'getMessages', options));
|
||||
return fetch_def.then(function (raw_messages) {
|
||||
options.ids = this._messageIDs;
|
||||
var fetchDef = this.dp.add(this._documentThread.getMessages(options));
|
||||
return fetchDef.then(function (rawMessages) {
|
||||
var isCreateMode = false;
|
||||
if (!self.res_id) {
|
||||
raw_messages = self._forgeCreationModeMessages();
|
||||
rawMessages = self._forgeCreateMessages();
|
||||
isCreateMode = true;
|
||||
}
|
||||
self.thread.render(raw_messages, {
|
||||
display_load_more: raw_messages.length < ids.length,
|
||||
self._threadWidget.render(rawMessages, {
|
||||
displayLoadMore: rawMessages.length < self._documentThread.getMessageIDs().length,
|
||||
isCreateMode: isCreateMode,
|
||||
});
|
||||
return self._documentThread.markAsRead();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Make a fake list of messages to render in create mode.
|
||||
* Instead of rendering no messages at all, it displays a single message
|
||||
* with body content "Creating a new record".
|
||||
*
|
||||
* @private
|
||||
* @returns {mail.model.Message[]} an array containing a single message 'Creating a new record...'
|
||||
*/
|
||||
_forgeCreateMessages: function () {
|
||||
var createMessage = new Message(this, {
|
||||
id: 0,
|
||||
body: _t("<p>Creating a new record...</p>"),
|
||||
author_id: [session.partner_id, session.partner_display_name],
|
||||
});
|
||||
return [createMessage];
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @returns {$.Deferred}
|
||||
*/
|
||||
_render: function () {
|
||||
return this._fetchAndRenderThread();
|
||||
},
|
||||
/**
|
||||
* The mail widget being persistent, one needs to update the res_id and
|
||||
* to set the correct DocumentThread on reset.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {any} record
|
||||
*/
|
||||
_reset: function (record) {
|
||||
this._super.apply(this, arguments);
|
||||
this.res_id = record.res_id;
|
||||
this._setDocumentThread();
|
||||
},
|
||||
/**
|
||||
* Sets this._documentThread, the DocumentThread associated with the current
|
||||
* model and resID.
|
||||
*
|
||||
* @returns {mail.model.DocumentThread}
|
||||
*/
|
||||
_setDocumentThread: function () {
|
||||
var params = {
|
||||
messageIDs: this.value.res_ids,
|
||||
name: this.recordData.display_name,
|
||||
resID: this.res_id,
|
||||
resModel: this.model,
|
||||
};
|
||||
this._documentThread = this.call('mail_service', 'getOrAddDocumentThread', params);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
@@ -143,38 +180,62 @@ var ThreadField = AbstractField.extend({
|
||||
|
||||
/**
|
||||
* When a new message arrives, fetch its data to render it
|
||||
* @param {Number} message_id : the identifier of the new message
|
||||
* @returns {Deferred}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onLoadMoreMessages: function () {
|
||||
this._fetchAndRenderThread(this.msgIDs, {forceFetch: true});
|
||||
this._fetchAndRenderThread({ forceFetch: true });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {mail.model.Message}
|
||||
*/
|
||||
_onNewMessage: function (message) {
|
||||
if (message.model === this.model && message.res_id === this.res_id) {
|
||||
this.msgIDs.unshift(message.id);
|
||||
if (
|
||||
message.isLinkedToDocumentThread() &&
|
||||
message.getDocumentModel() === this.model &&
|
||||
message.getDocumentID() === this.res_id
|
||||
) {
|
||||
this.trigger_up('new_message', {
|
||||
id: this.value.id,
|
||||
msgIDs: this.msgIDs,
|
||||
messageIDs: this._documentThread.getMessageIDs(),
|
||||
});
|
||||
this._fetchAndRenderThread(this.msgIDs);
|
||||
this._fetchAndRenderThread();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} channelID
|
||||
*/
|
||||
_onRedirectToChannel: function (channelID) {
|
||||
var self = this;
|
||||
this.call('chat_manager', 'joinChannel', channelID).then(function () {
|
||||
// Execute Discuss client action with 'channel' as default channel
|
||||
self.do_action('mail.mail_channel_action_client_chat', {active_id: channelID});
|
||||
this.call('mail_service', 'joinChannel', channelID).then(function () {
|
||||
// Execute Discuss with 'channel' as default channel
|
||||
self.do_action('mail.mail_channel_action_client_chat', { active_id: channelID });
|
||||
});
|
||||
},
|
||||
_onRedirect: function (res_model, res_id) {
|
||||
/**
|
||||
* @private
|
||||
* @param {string} resModel
|
||||
* @param {integer} resID
|
||||
*/
|
||||
_onRedirect: function (resModel, resID) {
|
||||
this.trigger_up('redirect', {
|
||||
res_id: res_id,
|
||||
res_model: res_model,
|
||||
res_id: resID,
|
||||
res_model: resModel,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {mail.model.Message}
|
||||
*/
|
||||
_onUpdateMessage: function (message) {
|
||||
if (message.model === this.model && message.res_id === this.res_id) {
|
||||
this._fetchAndRenderThread(this.msgIDs);
|
||||
if (
|
||||
message.isLinkedToDocumentThread() &&
|
||||
message.getDocumentModel() === this.model &&
|
||||
message.getDocumentID() === this.res_id
|
||||
) {
|
||||
this._fetchAndRenderThread();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
odoo.define('mail.AbstractThreadWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ThreadWidget = require('mail.widget.Thread');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var HEIGHT_OPEN = '400px';
|
||||
var HEIGHT_FOLDED = '34px';
|
||||
|
||||
/**
|
||||
* This is an abstract widget for rendering thread windows.
|
||||
*
|
||||
* It contains logic that are shared between mail.ThreadWindow and
|
||||
* mail.LivechatWindow.
|
||||
*
|
||||
* The reason for having two different implementation of thread windows is
|
||||
* that mail.ThreadWindow makes use of mail.Manager, which is used in the
|
||||
* backend, while mail.LivechatWindow must work without this mail service.
|
||||
*/
|
||||
var AbstractThreadWindow = Widget.extend({
|
||||
template: 'mail.AbstractThreadWindow',
|
||||
custom_events: {
|
||||
escape_pressed: '_onEscapePressed',
|
||||
document_viewer_closed: '_onDocumentViewerClose',
|
||||
},
|
||||
events: {
|
||||
'click .o_thread_window_close': '_onClickClose',
|
||||
'click .o_thread_window_title': '_onClickFold',
|
||||
'click .o_thread_composer': '_onComposerClick',
|
||||
'click .o_mail_thread': '_onThreadWindowClicked',
|
||||
'keydown .o_thread_composer': '_onKeydown',
|
||||
'keypress .o_thread_composer': '_onKeypress',
|
||||
},
|
||||
/**
|
||||
* Children of this class must make use of `thread`, which is an object that
|
||||
* represent the thread that is linked to this thread window.
|
||||
*
|
||||
* If no thread is provided, this will represent the "blank" thread window.
|
||||
*
|
||||
* @abstract
|
||||
* @param {Widget} parent
|
||||
* @param {Object} thread
|
||||
* @param {Object} [options={}]
|
||||
*/
|
||||
init: function (parent, thread, options) {
|
||||
this._super(parent);
|
||||
|
||||
this.options = _.defaults(options || {}, {
|
||||
autofocus: true,
|
||||
displayStars: true,
|
||||
displayReplyIcons: false,
|
||||
displayEmailIcons: false,
|
||||
placeholder: _t("Say something"),
|
||||
});
|
||||
|
||||
this._hidden = false;
|
||||
},
|
||||
start: function () {
|
||||
this.$input = this.$('.o_composer_text_field');
|
||||
this.$header = this.$('.o_thread_window_header');
|
||||
|
||||
this.threadWidget = new ThreadWidget(this, {
|
||||
threadID: this._getThreadID(),
|
||||
displayDocumentLinks: false,
|
||||
displayMarkAsRead: false,
|
||||
displayStars: this.options.displayStars,
|
||||
});
|
||||
|
||||
if (this.isFolded()) {
|
||||
this.$el.css('height', HEIGHT_FOLDED);
|
||||
} else if (this.options.autofocus) {
|
||||
this._focusInput();
|
||||
}
|
||||
if (!config.device.isMobile) {
|
||||
this.$el.css('margin-right', $.position.scrollbarWidth());
|
||||
}
|
||||
var def = this.threadWidget.replace(this.$('.o_chat_content'));
|
||||
return $.when(this._super(), def);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
do_hide: function () {
|
||||
this._hidden = true;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
do_show: function () {
|
||||
this._hidden = false;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
do_toggle: function (display) {
|
||||
this._hidden = _.isBoolean(display) ? !display : !this._hidden;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close this window
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
close: function () {},
|
||||
/**
|
||||
* Get the status of the thread, such as the im status of a DM.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {string}
|
||||
*/
|
||||
getStatus: function () {},
|
||||
/**
|
||||
* Get the title of the thread window, which usually contains the name of
|
||||
* the thread.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {string}
|
||||
*/
|
||||
getTitle: function () {},
|
||||
/**
|
||||
* Get the unread counter of the thread linked to this thread window.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {integer}
|
||||
*/
|
||||
getUnreadCounter: function () {},
|
||||
/**
|
||||
* States whether this thread window is related to a thread or not.
|
||||
*
|
||||
* This is useful in order to provide specific behaviour for thread windows
|
||||
* without any thread, e.g. let them open a thread from this "blank" thread
|
||||
* window.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasThread: function () {},
|
||||
/**
|
||||
* States whether this thread is folded or not. This information is stored
|
||||
* on the thread model.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isFolded: function () {},
|
||||
/**
|
||||
* States whether the current environment is in mobile or not. This is
|
||||
* useful in order to customize the template rendering for mobile view.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMobile: function () {
|
||||
return config.device.isMobile;
|
||||
},
|
||||
/**
|
||||
* States whether the thread window is hidden or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHidden: function () {
|
||||
return this._hidden;
|
||||
},
|
||||
/**
|
||||
* Render the thread window, using the provided list of messages
|
||||
*
|
||||
* TODO: use messages in thread, instead of providing list of messages
|
||||
*
|
||||
* @param {mail.model.AbstractMessage[]} messages
|
||||
*/
|
||||
render: function (messages) {
|
||||
this._renderHeader();
|
||||
this.threadWidget.render(messages, { displayLoadMore: false });
|
||||
},
|
||||
/**
|
||||
* Toggle the fold state of this thread window. Also update the fold state
|
||||
* of the thread model.
|
||||
*
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
toggleFold: function (folded) {
|
||||
if (!_.isBoolean(folded)) {
|
||||
folded = !this.isFolded();
|
||||
}
|
||||
this._updateThreadFoldState(folded);
|
||||
},
|
||||
/**
|
||||
* Update the visual state of the window so that it matched the internal
|
||||
* fold state. This is useful in case the related thread has its fold state
|
||||
* that has been changed.
|
||||
*/
|
||||
updateVisualFoldState: function () {
|
||||
if (!this.isFolded()) {
|
||||
this.threadWidget.scrollToBottom();
|
||||
this._focusInput();
|
||||
}
|
||||
this._animateFold();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when there is a change of the fold state of the thread window.
|
||||
* This method animates the change of fold state of this thread window.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_animateFold: function () {
|
||||
this.$el.animate({
|
||||
height: this.isFolded() ? HEIGHT_FOLDED : HEIGHT_OPEN
|
||||
}, 200);
|
||||
},
|
||||
/**
|
||||
* Set the focus on the composer of the thread window. This operation is
|
||||
* ignored in mobile context.
|
||||
*
|
||||
* @private
|
||||
* Set the focus on the input of the window
|
||||
*/
|
||||
_focusInput: function () {
|
||||
if (
|
||||
config.device.touch &&
|
||||
config.device.size_class <= config.device.SIZES.SM
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.$input.focus();
|
||||
},
|
||||
/**
|
||||
* Returns the options used by the rendering of the window's header
|
||||
*
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getHeaderRenderingOptions: function () {
|
||||
return {
|
||||
status: this.getStatus(),
|
||||
title: this.getTitle(),
|
||||
unreadCounter: this.getUnreadCounter(),
|
||||
widget: this,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get the ID of the related thread.
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @return {integer}
|
||||
*/
|
||||
_getThreadID: function () {},
|
||||
/**
|
||||
* Post a message on this thread window
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @param {Object} messageData
|
||||
*/
|
||||
_postMessage: function (messageData) {},
|
||||
/**
|
||||
* Update the fold state of the related thread.
|
||||
* This function is called when toggling the fold state of this window
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
_updateThreadFoldState: function (folded) {},
|
||||
/**
|
||||
* Render the header of this thread window
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_renderHeader: function () {
|
||||
var options = this._getHeaderRenderingOptions();
|
||||
this.$header.html(
|
||||
QWeb.render('mail.AbstractThreadWindowHeaderContent', options));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close the thread
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickClose: function (ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.close();
|
||||
},
|
||||
/**
|
||||
* Fold/unfold the thread window
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onClickFold: function () {
|
||||
if (!config.device.isMobile) {
|
||||
this.toggleFold();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when the composer is clicked -> forces focus on input even if
|
||||
* jquery's blockUI is enabled.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onComposerClick: function (ev) {
|
||||
if ($(ev.target).closest('a, button').length) {
|
||||
return;
|
||||
}
|
||||
this._focusInput();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onDocumentViewerClose: function () {
|
||||
this._focusInput();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEscapePressed: function () {
|
||||
if (!this.isFolded()) {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when typing something on the composer of this thread window.
|
||||
*
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown: function (ev) {
|
||||
ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
|
||||
// ENTER key (avoid requiring jquery ui for external livechat)
|
||||
if (ev.which === 13) {
|
||||
var content = _.str.trim(this.$input.val());
|
||||
var messageData = {
|
||||
content: content,
|
||||
attachment_ids: [],
|
||||
partner_ids: [],
|
||||
};
|
||||
this.$input.val('');
|
||||
if (content) {
|
||||
this._postMessage(messageData);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeypress: function (ev) {
|
||||
ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
|
||||
},
|
||||
/**
|
||||
* When a thread window is clicked on, we want to give the focus to the main
|
||||
* input. An exception is made when the user is selecting something.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onThreadWindowClicked: function () {
|
||||
var selectObj = window.getSelection();
|
||||
if (selectObj.anchorOffset === selectObj.focusOffset) {
|
||||
this.$input.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return AbstractThreadWindow;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
odoo.define('mail.ThreadWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractThreadWindow = require('mail.AbstractThreadWindow');
|
||||
var BasicComposer = require('mail.composer.Basic');
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* This is the main widget for rendering small windows for mail.model.Thread.
|
||||
* Almost all instances of this class are linked to a thread. The sole
|
||||
* exception is the "blank" thread window. This window let us open another
|
||||
* thread window, using this "blank" thread window.
|
||||
*/
|
||||
var ThreadWindow = AbstractThreadWindow.extend({
|
||||
template: 'mail.ThreadWindow',
|
||||
events: _.extend({}, AbstractThreadWindow.prototype.events, {
|
||||
'click .o_thread_window_expand': '_onClickExpand',
|
||||
}),
|
||||
/**
|
||||
* Version of thread window that supports {mail.model.Thread}
|
||||
*
|
||||
* @override
|
||||
* @param {mail.Manager} parent
|
||||
* @param {mail.model.Thread} [thread = null] if not set, this is a "blank"
|
||||
* thread window. It lets us open a DM by providing the name of a chat.
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.passively=false]
|
||||
*/
|
||||
init: function (parent, thread, options) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// don't automatically mark unread messages as seen when at the bottom
|
||||
// of the thread
|
||||
this._passive = this.options.passively;
|
||||
this._thread = thread || null;
|
||||
|
||||
if (!this.hasThread()) {
|
||||
// internal fold state of thread window without any thread
|
||||
this._folded = false;
|
||||
// remembered partner ID of "blank" thread window in order to be
|
||||
// replaced with newly opened DM window
|
||||
this.directPartnerID = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
|
||||
var superDef = this._super().then(this._listenThreadWidget.bind(this));
|
||||
|
||||
var composerDef;
|
||||
if (!this.hasThread()) {
|
||||
this._startWithoutThread();
|
||||
} else if (this._needsComposer()) {
|
||||
var basicComposer = new BasicComposer(this, {
|
||||
mentionPartnersRestricted: this._thread.getType() !== 'document_thread',
|
||||
isMini: true
|
||||
});
|
||||
basicComposer.on('post_message', this, this._postMessage);
|
||||
basicComposer.once('input_focused', this, function () {
|
||||
var commands = this._thread.getCommands();
|
||||
var partners = this._thread.getMentionPartnerSuggestions();
|
||||
basicComposer.mentionSetCommands(commands);
|
||||
basicComposer.mentionSetPrefetchedPartners(partners);
|
||||
});
|
||||
composerDef = basicComposer.replace(this.$('.o_thread_composer'));
|
||||
composerDef.then(function () {
|
||||
self.$input = self.$('.o_composer_text_field');
|
||||
});
|
||||
}
|
||||
|
||||
return $.when(superDef, composerDef);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
close: function () {
|
||||
if (this._hasThread()) {
|
||||
this._thread.close();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the focus on the input of this thread window
|
||||
*/
|
||||
focusInput: function () {
|
||||
this._focusInput();
|
||||
},
|
||||
/**
|
||||
* Get the ID of the thread window, which is equivalent to the ID of the
|
||||
* thread related to this window
|
||||
*
|
||||
* @returns {integer|string}
|
||||
*/
|
||||
getID: function () {
|
||||
return this._getThreadID();
|
||||
},
|
||||
/**
|
||||
* Get the status of the thread. If this window has no thread, returns
|
||||
* 'undefined'
|
||||
*
|
||||
* @override
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
getStatus: function () {
|
||||
if (!this.hasThread()) {
|
||||
return undefined;
|
||||
}
|
||||
return this._thread.getStatus();
|
||||
},
|
||||
/**
|
||||
* Get the title of the thread window, which is equivalent to the name of
|
||||
* related thread, prefixed with "#" if this is a chat (i.e. DM, backend
|
||||
* livechat).
|
||||
*
|
||||
* If there is no thread linked to this thread window, display title of
|
||||
* "blank" thread window.
|
||||
*
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
getTitle: function () {
|
||||
if (!this.hasThread()) {
|
||||
return _t("New message");
|
||||
}
|
||||
return this._thread.getTitle();
|
||||
},
|
||||
/**
|
||||
* Get the unread counter of the related thread. If there are no thread
|
||||
* linked to this window, returns 0.
|
||||
*
|
||||
* @override
|
||||
* @returns {integer}
|
||||
*/
|
||||
getUnreadCounter: function () {
|
||||
if (!this.hasThread()) {
|
||||
return 0;
|
||||
}
|
||||
return this._thread.getUnreadCounter();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasThread: function () {
|
||||
return !!this._thread;
|
||||
},
|
||||
/**
|
||||
* State whether the related thread is folded or not. If there are no
|
||||
* thread related to this window, it means this is the "blank" thread
|
||||
* window, therefore we use the internal folded state.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isFolded: function () {
|
||||
if (!this.hasThread()) {
|
||||
return this._folded;
|
||||
}
|
||||
return this._thread.isFolded();
|
||||
},
|
||||
/**
|
||||
* Tell whether the thread window is passive or not. A passive thread window
|
||||
* does not auto-mark the thread as read when scrolling at the bottom.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPassive: function () {
|
||||
return this._passive;
|
||||
},
|
||||
/**
|
||||
* Turn the thread window in active mode, so that when the bottom of the
|
||||
* thread is visible, it is automatically marked as read.
|
||||
*/
|
||||
removePassive: function () {
|
||||
this._passive = false;
|
||||
},
|
||||
/**
|
||||
* Update the header of this thread window
|
||||
* This is useful when some information on the header have be updated
|
||||
* such as the status or the title of the thread that have changed.
|
||||
*/
|
||||
updateHeader: function () {
|
||||
this._renderHeader();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the ID of the thread window, which is equivalent to the ID of the
|
||||
* related thread.
|
||||
*
|
||||
* If this window is not related to a thread, it means this is the "blank"
|
||||
* thread window, therefore it returns "_blank" as its ID.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @returns {integer|string} the threadID, or '_blank' for the window that
|
||||
* is not related to any thread.
|
||||
*/
|
||||
_getThreadID: function () {
|
||||
if (!this.hasThread()) {
|
||||
return '_blank';
|
||||
}
|
||||
return this._thread.getID();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_getHeaderRenderingOptions: function () {
|
||||
var options = this._super.apply(this, arguments);
|
||||
if (this._thread.getType() === 'document_thread') {
|
||||
options.expandTitle = _t("Open document");
|
||||
} else {
|
||||
options.expandTitle = _t("Open in Discuss");
|
||||
}
|
||||
return options;
|
||||
},
|
||||
/**
|
||||
* States whether the window is linked to a thread or not
|
||||
*
|
||||
* Useful to detect whether this is the "blank" thread window or not.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_hasThread: function () {
|
||||
return !!this._thread;
|
||||
},
|
||||
/**
|
||||
* Listen on thread widget events
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_listenThreadWidget: function () {
|
||||
this.threadWidget
|
||||
.on('redirect', this, this._onRedirect)
|
||||
.on('redirect_to_channel', this, this._onRedirectToChannel)
|
||||
.on('toggle_star_status', this, this._onToggleStarStatus);
|
||||
},
|
||||
/**
|
||||
* States whether the input of the thread window should be displayed or not.
|
||||
* This is based on the type of the thread:
|
||||
*
|
||||
* Do not display the input in the following cases:
|
||||
*
|
||||
* - no thread related to this window
|
||||
* - window of a mailbox (temp: let us have mailboxes in window mode)
|
||||
* - window of a thread with mass mailing
|
||||
*
|
||||
* Any other threads show the input in the window.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_needsComposer: function () {
|
||||
return this.hasThread() &&
|
||||
(this._thread.getType() !== 'mailbox') &&
|
||||
!this._thread.isMassMailing();
|
||||
},
|
||||
/**
|
||||
* Open this thread window.
|
||||
* This private method exists only for the purpose of providing a callback
|
||||
* function on redirect to a partner.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_open: function () {
|
||||
this.call('mail_service', 'openThreadWindow', this.getID());
|
||||
},
|
||||
/**
|
||||
* Post a message in the thread related to this window.
|
||||
* If this window has no thread, do nothing
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} messageData
|
||||
*/
|
||||
_postMessage: function (messageData) {
|
||||
var self = this;
|
||||
if (!this._hasThread()) {
|
||||
return;
|
||||
}
|
||||
this._thread.postMessage(messageData)
|
||||
.then(function () {
|
||||
self.threadWidget.scrollToBottom();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_startWithoutThread: function () {
|
||||
var self = this;
|
||||
this.$el.addClass('o_thread_less');
|
||||
this.$('.o_thread_search_input input')
|
||||
.autocomplete({
|
||||
source: function (request, response) {
|
||||
self.call('mail_service', 'searchPartner', request.term, 10)
|
||||
.done(response);
|
||||
},
|
||||
select: function (event, ui) {
|
||||
// remember partner ID so that we can replace this window
|
||||
// with new DM window
|
||||
var partnerID = ui.item.id;
|
||||
self.directPartnerID = partnerID;
|
||||
self.call('mail_service', 'openDmWindow', partnerID);
|
||||
},
|
||||
})
|
||||
.focus();
|
||||
},
|
||||
/**
|
||||
* Update the fold state of the thread
|
||||
* If there is no thread linked to this window, it means this is the
|
||||
* "blank" thread window, therefore we use the internal state 'folded'
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
_updateThreadFoldState: function (folded) {
|
||||
if (this.hasThread()) {
|
||||
this._thread.fold(folded);
|
||||
} else {
|
||||
this._folded = folded;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expand the thread, which will open the thread in discuss.
|
||||
*
|
||||
* If there is no thread linked to this window, it means this is the "blank"
|
||||
* thread window, therefore it opens discuss with the default thread (which
|
||||
* should be the mailbox 'inbox')
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickExpand: _.debounce(function (ev) {
|
||||
var self = this;
|
||||
ev.preventDefault();
|
||||
if (this._thread.getType() === 'document_thread') {
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: this._thread.getDocumentModel(),
|
||||
views: [[false, 'form']],
|
||||
res_id: this._thread.getDocumentID(),
|
||||
});
|
||||
} else {
|
||||
this.do_action('mail.mail_channel_action_client_chat', {
|
||||
clear_breadcrumbs: false,
|
||||
active_id: this.hasThread() ? this._getThreadID() : undefined,
|
||||
on_reverse_breadcrumb: function () {
|
||||
self.call('mail_service', 'getMailBus')
|
||||
.trigger('discuss_open', false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 1000, true),
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*
|
||||
* Override _onKeydown to only prevent jquery's blockUI to cancel event,
|
||||
* but without sending the message on ENTER keydown as this is handled by
|
||||
* the BasicComposer
|
||||
*/
|
||||
_onKeydown: function (ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* callback function receives the threadID linked to resModel and resID
|
||||
* Note: this callback function is only called for 'res.partner'
|
||||
*
|
||||
* @private
|
||||
* @param {string} resModel
|
||||
* @param {integer} resID
|
||||
*/
|
||||
_onRedirect: function (resModel, resID) {
|
||||
var callback = this._open.bind(this);
|
||||
this.call('mail_service', 'redirect', resModel, resID, callback);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} channelID
|
||||
*/
|
||||
_onRedirectToChannel: function (channelID) {
|
||||
var thread = this.call('mail_service', 'getThread', channelID);
|
||||
if (!thread) {
|
||||
this.call('mail_service', 'joinChannel', channelID)
|
||||
.then(function (channel) {
|
||||
channel.detach();
|
||||
});
|
||||
} else {
|
||||
this.toggleFold(false);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} messageID
|
||||
*/
|
||||
_onToggleStarStatus: function (messageID) {
|
||||
var message = this.call('mail_service', 'getMessage', messageID);
|
||||
message.toggleStarStatus();
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return ThreadWindow;
|
||||
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
odoo.define('mail.tour', function(require) {
|
||||
odoo.define('mail.tour', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
@@ -7,24 +7,24 @@ var tour = require('web_tour.tour');
|
||||
var _t = core._t;
|
||||
|
||||
tour.register('mail_tour', [{
|
||||
trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="public"]',
|
||||
trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="public"]',
|
||||
content: _t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>"),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_mail_chat .o_composer_text_field',
|
||||
content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>\'@\'</i> or link another channel with <i>\'#\'</i>. Start your message with <i>\'/\'</i> to get the list of possible commands.</p>"),
|
||||
trigger: '.o_mail_discuss .o_composer_text_field',
|
||||
content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>'@'</i> or link another channel with <i>'#'</i>. Start your message with <i>'/'</i> to get the list of possible commands.</p>"),
|
||||
position: "top",
|
||||
width: 350,
|
||||
}, {
|
||||
trigger: ".o_mail_chat .o_mail_thread .o_thread_message_star",
|
||||
trigger: ".o_mail_discuss .o_mail_thread .o_thread_message_star",
|
||||
content: _t("Messages can be <b>starred</b> to remind you to check back later."),
|
||||
position: "right",
|
||||
}, {
|
||||
trigger: '.o_mail_chat .o_mail_chat_channel_item[data-channel-id="channel_starred"]',
|
||||
trigger: '.o_mail_discuss .o_mail_item_item[data-thread-id="mailbox_starred"]',
|
||||
content: _t("Once a message has been starred, you can come back and review it at any time here."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="dm"]',
|
||||
trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm"]',
|
||||
content: _t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>"),
|
||||
position: 'bottom',
|
||||
}]);
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
odoo.define('mail.utils', function (require) {
|
||||
"use strict";
|
||||
|
||||
function parse_and_transform(html_string, transform_function) {
|
||||
var open_token = "OPEN" + Date.now();
|
||||
var string = html_string.replace(/</g, open_token);
|
||||
var core = require('web.core');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
function parseAndTransform(htmlString, transformFunction) {
|
||||
var openToken = "OPEN" + Date.now();
|
||||
var string = htmlString.replace(/</g, openToken);
|
||||
var children = $('<div>').html(string).contents();
|
||||
return _parse_and_transform(children, transform_function)
|
||||
.replace(new RegExp(open_token, "g"), "<");
|
||||
return _parseAndTransform(children, transformFunction)
|
||||
.replace(new RegExp(openToken, "g"), "<");
|
||||
}
|
||||
function _parse_and_transform(nodes, transform_function) {
|
||||
function _parseAndTransform(nodes, transformFunction) {
|
||||
return _.map(nodes, function (node) {
|
||||
return transform_function(node, function () {
|
||||
return _parse_and_transform(node.childNodes, transform_function);
|
||||
return transformFunction(node, function () {
|
||||
return _parseAndTransform(node.childNodes, transformFunction);
|
||||
});
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
|
||||
// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match.
|
||||
var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&'$//=]*)/gi;
|
||||
var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=]*)/gi;
|
||||
function linkify(text, attrs) {
|
||||
attrs = attrs || {};
|
||||
if (attrs.target === undefined) {
|
||||
@@ -27,28 +31,28 @@ function linkify(text, attrs) {
|
||||
attrs = _.map(attrs, function (value, key) {
|
||||
return key + '="' + _.escape(value) + '"';
|
||||
}).join(' ');
|
||||
return text.replace(url_regexp, function (url) {
|
||||
return text.replace(urlRegexp, function (url) {
|
||||
var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url;
|
||||
return '<a ' + attrs + ' href="' + href + '">' + url + '</a>';
|
||||
});
|
||||
}
|
||||
|
||||
function add_link (node, transform_children) {
|
||||
function addLink(node, transformChildren) {
|
||||
if (node.nodeType === 3) { // text node
|
||||
return linkify(node.data);
|
||||
}
|
||||
if (node.tagName === "A") return node.outerHTML;
|
||||
node.innerHTML = transform_children();
|
||||
node.innerHTML = transformChildren();
|
||||
return node.outerHTML;
|
||||
}
|
||||
|
||||
function strip_html (node, transform_children) {
|
||||
function stripHTML(node, transformChildren) {
|
||||
if (node.nodeType === 3) return node.data; // text node
|
||||
if (node.tagName === "BR") return "\n";
|
||||
return transform_children();
|
||||
return transformChildren();
|
||||
}
|
||||
|
||||
function inline (node, transform_children) {
|
||||
function inline(node, transform_children) {
|
||||
if (node.nodeType === 3) return node.data;
|
||||
if (node.nodeType === 8) return "";
|
||||
if (node.tagName === "BR") return " ";
|
||||
@@ -58,7 +62,7 @@ function inline (node, transform_children) {
|
||||
}
|
||||
|
||||
// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
|
||||
function parse_email (text) {
|
||||
function parseEmail(text) {
|
||||
if (text){
|
||||
var result = text.match(/(.*)<(.*@.*)>/);
|
||||
if (result) {
|
||||
@@ -74,13 +78,13 @@ function parse_email (text) {
|
||||
|
||||
// Replaces textarea text into html text (add <p>, <a>)
|
||||
// TDE note : should be done server-side, in Python -> use mail.compose.message ?
|
||||
function get_text2html (text) {
|
||||
function getTextToHTML(text) {
|
||||
return text
|
||||
.replace(/((?:https?|ftp):\/\/[\S]+)/g,'<a href="$1">$1</a> ')
|
||||
.replace(/[\n\r]/g,'<br/>');
|
||||
}
|
||||
|
||||
var accented_letters_mapping = {
|
||||
var accentedLettersMapping = {
|
||||
'a': '[àáâãäå]',
|
||||
'ae': 'æ',
|
||||
'c': 'ç',
|
||||
@@ -92,21 +96,29 @@ var accented_letters_mapping = {
|
||||
'u': '[ùúûűü]',
|
||||
'y': '[ýÿ]',
|
||||
};
|
||||
function unaccent (str) {
|
||||
_.each(accented_letters_mapping, function (value, key) {
|
||||
function unaccent(str) {
|
||||
_.each(accentedLettersMapping, function (value, key) {
|
||||
str = str.replace(new RegExp(value, 'g'), key);
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
function timeFromNow(date) {
|
||||
if (moment().diff(date, 'seconds') < 45) {
|
||||
return _t("now");
|
||||
}
|
||||
return date.fromNow();
|
||||
}
|
||||
|
||||
return {
|
||||
parse_and_transform: parse_and_transform,
|
||||
add_link: add_link,
|
||||
linkify: linkify,
|
||||
strip_html: strip_html,
|
||||
addLink: addLink,
|
||||
getTextToHTML: getTextToHTML,
|
||||
inline: inline,
|
||||
parse_email: parse_email,
|
||||
get_text2html: get_text2html,
|
||||
linkify: linkify,
|
||||
parseAndTransform: parseAndTransform,
|
||||
parseEmail: parseEmail,
|
||||
stripHTML: stripHTML,
|
||||
timeFromNow: timeFromNow,
|
||||
unaccent: unaccent,
|
||||
};
|
||||
|
||||
|
||||
+25
-25
@@ -1,17 +1,17 @@
|
||||
$o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
|
||||
|
||||
.o_chat_window {
|
||||
.o_thread_window {
|
||||
@include o-flex-display;
|
||||
@include o-flex-flow(column, nowrap);
|
||||
position: fixed;
|
||||
width: $o-mail-chat-window-width;
|
||||
width: $o-mail-thread-window-width;
|
||||
max-width: 100%;
|
||||
height: 400px;
|
||||
max-height: 100%;
|
||||
font-size: 12px;
|
||||
background-color: $o-mail-chat-window-bg;
|
||||
background-color: $o-mail-thread-window-bg;
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: $o-mail-chat-window-zindex;
|
||||
z-index: $o-mail-thread-window-zindex;
|
||||
box-shadow: -5px -5px 10px rgba(black, 0.18);
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
@@ -27,7 +27,7 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_chat_header {
|
||||
.o_thread_window_header {
|
||||
@include o-flex-display;
|
||||
@include o-flex(0, 0, auto);
|
||||
color: white;
|
||||
@@ -41,26 +41,26 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
height: $o-mail-chat-header-height;
|
||||
padding-top: 12px;
|
||||
border-radius: 0px;
|
||||
.o_chat_title {
|
||||
.o_thread_window_title {
|
||||
font-size: 16px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.o_chat_window_close {
|
||||
.o_thread_window_close {
|
||||
font-size: 17px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.o_chat_title {
|
||||
.o_thread_window_title {
|
||||
cursor: pointer;
|
||||
@include o-flex(1, 1, auto);
|
||||
@include o-text-overflow;
|
||||
}
|
||||
|
||||
.o_chat_window_buttons {
|
||||
.o_thread_window_buttons {
|
||||
@include o-flex(0, 0, auto);
|
||||
.o_chat_window_close, .o_chat_window_expand {
|
||||
.o_thread_window_close, .o_thread_window_expand {
|
||||
color: white;
|
||||
padding: 3px;
|
||||
margin-left: 5px;
|
||||
@@ -77,7 +77,7 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
.o_thread_date_separator {
|
||||
margin: 0px 0px 15px 0px;
|
||||
.o_thread_date {
|
||||
background-color: $o-mail-chat-window-bg;
|
||||
background-color: $o-mail-thread-window-bg;
|
||||
}
|
||||
}
|
||||
.o_thread_message {
|
||||
@@ -88,12 +88,12 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
}
|
||||
}
|
||||
|
||||
.o_chat_composer input {
|
||||
.o_thread_composer input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_chat_window_dropdown {
|
||||
.o_thread_window_dropdown {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
color: white;
|
||||
@@ -105,11 +105,11 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_chat_header {
|
||||
.o_thread_window_header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.o_chat_window_dropdown_toggler {
|
||||
.o_thread_window_dropdown_toggler {
|
||||
padding: 5px;
|
||||
|
||||
.o_total_unread_counter {
|
||||
@@ -119,18 +119,18 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
&.open .o_chat_window_dropdown_toggler .o_total_unread_counter, .o_chat_window_expand {
|
||||
&.open .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> ul {
|
||||
max-width: $o-mail-chat-window-width;
|
||||
max-width: $o-mail-thread-window-width;
|
||||
padding: 0;
|
||||
|
||||
> li.o_chat_header {
|
||||
> li.o_thread_window_header {
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
&~li.o_chat_header {
|
||||
&~li.o_thread_window_header {
|
||||
border-top: 1px solid white;
|
||||
}
|
||||
&:hover {
|
||||
@@ -140,17 +140,17 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default;
|
||||
}
|
||||
}
|
||||
|
||||
.o_ui_blocked .o_chat_window {
|
||||
// We cannot put the z-index of chat windows directly to be greater than
|
||||
.o_ui_blocked .o_thread_window {
|
||||
// We cannot put the z-index of thread windows directly to be greater than
|
||||
// blockUI's as ui-autocomplete dropdowns (which are below blockUI) would
|
||||
// appear under the chat windows (and ui-autocomplete is used to choose the
|
||||
// appear under the thread windows (and ui-autocomplete is used to choose the
|
||||
// person you want to chat with). So we only raise the z-index value when
|
||||
// the ui is really blocked (in that case, the ui-autocomplete dropdowns
|
||||
// will disappear under the chat windows but this is not really an issue as
|
||||
// will disappear under the thread windows but this is not really an issue as
|
||||
// there should not be any at that time).
|
||||
z-index: 1101; // blockUI's z-index is 1100
|
||||
}
|
||||
|
||||
.o_no_chat_window .o_chat_window {
|
||||
.o_no_thread_window .o_thread_window {
|
||||
display: none;
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
local(Symbola);
|
||||
}
|
||||
|
||||
.o_chat_composer {
|
||||
.o_thread_composer {
|
||||
position: relative;
|
||||
@include o-flex(0, 0, auto);
|
||||
@include o-flex-display;
|
||||
@@ -244,8 +244,8 @@
|
||||
//font-size:150%;
|
||||
font-family: emojifont;
|
||||
}
|
||||
.o_mail_channel_preview .o_mail_emoji{
|
||||
font-size:100%;
|
||||
.o_mail_preview .o_mail_emoji {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
// Attachment Icons (common for chat thread and chat composer)
|
||||
@@ -428,7 +428,7 @@ $o-mail-attachment-margin: 5px;
|
||||
}
|
||||
}
|
||||
.o_modal_fullscreen {
|
||||
z-index: $o-mail-chat-window-zindex + 1;
|
||||
z-index: $o-mail-thread-window-zindex + 1;
|
||||
|
||||
.o_viewer_content {
|
||||
position: relative;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Layout
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_mail_chat {
|
||||
.o_mail_discuss {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_chat_sidebar {
|
||||
.o_mail_discuss_sidebar {
|
||||
width: $o-mail-chat-sidebar-width;
|
||||
height: 100%;
|
||||
float: left;
|
||||
@@ -63,12 +63,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.o_mail_chat_channel_item {
|
||||
.o_mail_discuss_item {
|
||||
@include o-flex-display;
|
||||
width: 100%;
|
||||
padding: 2px $o-horizontal-padding 2px ($o-horizontal-padding + 12px); // compensate icon
|
||||
|
||||
.o_channel_name {
|
||||
.o_thread_name {
|
||||
@include o-flex(1, 1, auto);
|
||||
@include o-text-overflow;
|
||||
}
|
||||
@@ -85,17 +85,17 @@
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
&.o_mail_chat_title_main {
|
||||
&.o_mail_discuss_title_main {
|
||||
padding: $padding-base-vertical $o-horizontal-padding;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_chat_channel_item + .o_mail_sidebar_title {
|
||||
.o_mail_discuss_item + .o_mail_sidebar_title {
|
||||
margin-top: $padding-base-vertical;
|
||||
}
|
||||
|
||||
.o_mail_add_channel {
|
||||
.o_mail_add_thread {
|
||||
width: 100%;
|
||||
padding: 5px ($o-horizontal-padding + 23px); // compensate icon
|
||||
position: relative;
|
||||
@@ -110,7 +110,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_chat_content {
|
||||
.o_mail_discuss_content {
|
||||
@include o-flex-display;
|
||||
@include o-flex-flow(column, nowrap);
|
||||
height: 100%;
|
||||
@@ -151,12 +151,12 @@
|
||||
// Style
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_mail_chat {
|
||||
.o_mail_chat_sidebar {
|
||||
.o_mail_discuss {
|
||||
.o_mail_discuss_sidebar {
|
||||
background-color: $gray-dark;
|
||||
color: $gray-lighter-dark;
|
||||
|
||||
.o_mail_chat_channel_item {
|
||||
.o_mail_discuss_item {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@@ -192,10 +192,10 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&.o_mail_chat_title_main {
|
||||
&.o_mail_discuss_title_main {
|
||||
font-size: $font-size-base;
|
||||
|
||||
&.o_mail_chat_title_starred > .badge {
|
||||
&.o_mail_mailbox_title_starred > .badge {
|
||||
background-color: $gray-light;
|
||||
border-color: $gray-light;
|
||||
}
|
||||
@@ -206,7 +206,7 @@
|
||||
border-color: $gray;
|
||||
}
|
||||
|
||||
.o_mail_add_channel {
|
||||
.o_mail_add_thread {
|
||||
display: none;
|
||||
> input {
|
||||
color: black;
|
||||
@@ -234,7 +234,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_chat_content {
|
||||
.o_mail_discuss_content {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@@ -266,18 +266,18 @@
|
||||
// Mobile
|
||||
// ------------------------------------------------------------------
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.o_mail_chat {
|
||||
.o_mail_discuss {
|
||||
@include o-flex-display;
|
||||
@include o-flex-flow(column, nowrap);
|
||||
background: white;
|
||||
|
||||
.o_mail_chat_mobile_inbox_buttons {
|
||||
.o_mail_discuss_mobile_mailboxes_buttons {
|
||||
@include o-flex-display;
|
||||
@include o-flex(0, 0, auto);
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 4px $gray-lighter-darker;
|
||||
|
||||
.o_channel_inbox_item {
|
||||
.o_mailbox_inbox_item {
|
||||
@include o-flex(1, 1, auto);
|
||||
width: 50%;
|
||||
padding: 2%;
|
||||
@@ -288,16 +288,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_chat_content {
|
||||
.o_mail_discuss_content {
|
||||
@include o-flex(1, 0, 0);
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.o_mail_chat_tab_pane {
|
||||
.o_mail_discuss_tab_pane {
|
||||
overflow-x: scroll;
|
||||
.o_mail_add_channel {
|
||||
.o_mail_add_thread {
|
||||
display: none;
|
||||
position: relative;
|
||||
padding: 5px 0px;
|
||||
@@ -321,10 +321,10 @@
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Channel preview: shared between discuss (mobile) and systray
|
||||
// Thread preview: shared between discuss (mobile) and systray
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_mail_channel_preview {
|
||||
.o_mail_preview {
|
||||
@include o-flex-display;
|
||||
background-color: $gray-lighter;
|
||||
color: $o-main-text-color;
|
||||
@@ -333,7 +333,7 @@
|
||||
position: relative;
|
||||
&:hover {
|
||||
background-color: $gray-lighter-dark;
|
||||
.o_channel_name {
|
||||
.o_preview_name {
|
||||
color: $headings-color;
|
||||
}
|
||||
.o_discuss_icon {
|
||||
@@ -343,30 +343,30 @@
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $gray-lighter-darker;
|
||||
}
|
||||
.o_mail_channel_image {
|
||||
.o_mail_preview_image {
|
||||
@include o-flex(0, 0, auto);
|
||||
position: relative;
|
||||
> img {
|
||||
@include square(100%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.o_mail_channel_app > img {
|
||||
&.o_mail_preview_app > img {
|
||||
border-radius: 2px;
|
||||
}
|
||||
.o_mail_user_status {
|
||||
@include o-position-absolute($bottom: 0px, $right: 0px);
|
||||
}
|
||||
}
|
||||
.o_channel_info {
|
||||
.o_preview_info {
|
||||
@include o-flex(1, 1, 100%);
|
||||
overflow: hidden;
|
||||
.o_channel_title {
|
||||
.o_preview_title {
|
||||
@include o-flex-display;
|
||||
.o_channel_name {
|
||||
.o_preview_name {
|
||||
@include o-flex(0, 1, auto);
|
||||
@include o-text-overflow;
|
||||
}
|
||||
.o_channel_counter {
|
||||
.o_preview_counter {
|
||||
@include o-flex(1, 1, auto);
|
||||
}
|
||||
.o_last_message_date {
|
||||
@@ -385,14 +385,14 @@
|
||||
.o_discuss_icon {
|
||||
opacity: 0;
|
||||
}
|
||||
&.o_channel_unread {
|
||||
&.o_preview_unread {
|
||||
background-color: transparent;
|
||||
&:hover {
|
||||
background-color: $gray-lighter;
|
||||
}
|
||||
.o_channel_info {
|
||||
.o_channel_title {
|
||||
.o_channel_name, .o_channel_counter {
|
||||
.o_preview_info {
|
||||
.o_preview_title {
|
||||
.o_preview_name, .o_preview_counter {
|
||||
font-weight: 700;
|
||||
}
|
||||
.o_last_message_date {
|
||||
@@ -409,18 +409,18 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.o_mail_channel_preview {
|
||||
.o_mail_preview {
|
||||
padding: $o-mail-chatter-mobile-gap;
|
||||
|
||||
.o_mail_channel_image:not(img) {
|
||||
.o_mail_preview_image:not(img) {
|
||||
width: $o-mail-chatter-mobile-gap*6;
|
||||
}
|
||||
|
||||
.o_channel_info, .o_last_message_date {
|
||||
.o_preview_info, .o_last_message_date {
|
||||
margin-left: $o-mail-chatter-mobile-gap;
|
||||
}
|
||||
|
||||
.o_channel_name {
|
||||
.o_preview_name {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Navbar icon and dropdown
|
||||
.o_mail_navbar_item {
|
||||
// Systray icon and dropdown
|
||||
.o_mail_systray_item {
|
||||
> a {
|
||||
opacity: 1;
|
||||
> i {
|
||||
@@ -15,7 +15,7 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
&.open .o_mail_navbar_dropdown {
|
||||
&.open .o_mail_systray_dropdown {
|
||||
@include o-flex-display;
|
||||
@include o-flex-flow(column, nowrap);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
padding: 0em 0.3em;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.o_mail_navbar_dropdown {
|
||||
.o_mail_systray_dropdown {
|
||||
width: 350px;
|
||||
padding: 0;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.o_mail_navbar_dropdown_top {
|
||||
.o_mail_systray_dropdown_top {
|
||||
@include o-flex-display;
|
||||
@include o-flex(0, 0, auto);
|
||||
justify-content: space-between;
|
||||
@@ -59,23 +59,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_navbar_dropdown_channels {
|
||||
.o_mail_systray_dropdown_items {
|
||||
@include o-flex(0, 1, auto);
|
||||
max-height: 400px;
|
||||
min-height: 50px;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
.o_mail_channel_preview {
|
||||
.o_mail_preview {
|
||||
min-height: 50px;
|
||||
padding: 5px;
|
||||
.o_mail_channel_image {
|
||||
.o_mail_preview_image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.o_channel_info {
|
||||
.o_preview_info {
|
||||
margin-left: 10px;
|
||||
.o_channel_title {
|
||||
.o_preview_title {
|
||||
.o_last_message_date {
|
||||
padding-top: 2px;
|
||||
font-size: x-small;
|
||||
@@ -99,29 +99,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_no_chat_window .o_mail_navbar_dropdown .o_new_message {
|
||||
.o_no_thread_window .o_mail_systray_dropdown .o_new_message {
|
||||
display: none; // hide 'new message' button if chat windows are disabled
|
||||
}
|
||||
|
||||
// Mobile rules
|
||||
// Goal: mock the design of Discuss in mobile
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.o_mail_navbar_item {
|
||||
.o_mail_systray_item {
|
||||
.o_notification_counter {
|
||||
top: 10%;
|
||||
}
|
||||
.o_mail_navbar_dropdown {
|
||||
.o_mail_systray_dropdown {
|
||||
position: relative;
|
||||
.o_mail_navbar_dropdown_top {
|
||||
.o_mail_systray_dropdown_top {
|
||||
padding: 5px;
|
||||
}
|
||||
.o_mail_navbar_mobile_header {
|
||||
.o_mail_systray_mobile_header {
|
||||
padding: 5px;
|
||||
height: 44px;
|
||||
border-bottom: 1px solid #ebebeb;
|
||||
box-shadow: 0 0 2px $gray-lighter-darker;
|
||||
}
|
||||
.o_mail_navbar_dropdown_channels {
|
||||
.o_mail_systray_dropdown_items {
|
||||
max-height: none;
|
||||
padding-bottom: 52px; // leave space for tabs
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
.o_chat_window {
|
||||
.o_thread_window {
|
||||
&.o_thread_less {
|
||||
.o_chat_search_input {
|
||||
.o_thread_search_input {
|
||||
@include o-flex-display;
|
||||
@include o-align-items(baseline);
|
||||
padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
|
||||
@@ -20,12 +20,12 @@
|
||||
background-color: $o-webclient-background-color;
|
||||
}
|
||||
|
||||
.o_chat_composer {
|
||||
.o_thread_composer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_chat_composer {
|
||||
.o_thread_composer {
|
||||
.o_composer {
|
||||
.o_composer_input {
|
||||
width: 80%;
|
||||
@@ -1,8 +1,8 @@
|
||||
$o-mail-thread-avatar-size: 36px !default;
|
||||
$o-mail-thread-icon-opacity: 0.6 !default;
|
||||
$o-mail-thread-side-date-opacity: 0.6 !default;
|
||||
$o-mail-chat-window-bg: #FAFAFA !default;
|
||||
$o-mail-chat-window-width: 325px !default;
|
||||
$o-mail-thread-window-bg: #FAFAFA !default;
|
||||
$o-mail-thread-window-width: 325px !default;
|
||||
$o-mail-chatter-gap: 10px !default;
|
||||
$o-mail-chatter-mobile-gap: 2% !default;
|
||||
$o-mail-chat-header-height: 46px !default;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!--
|
||||
@param {mail.AbstractThreadWindow} widget
|
||||
-->
|
||||
<t t-name="mail.AbstractThreadWindow">
|
||||
<div class="o_thread_window o_in_home_menu">
|
||||
<div class="o_thread_window_header">
|
||||
<t t-call="mail.AbstractThreadWindowHeaderContent">
|
||||
<t t-set="status" t-value="widget.getStatus()"/>
|
||||
<t t-set="title" t-value="widget.getTitle()"/>
|
||||
<t t-set="unreadCounter" t-value="widget.getUnreadCounter()"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_chat_content">
|
||||
</div>
|
||||
<div t-if='widget.hasThread()' class="o_thread_composer o_chat_mini_composer">
|
||||
<input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {boolean} [open=false] tell whether the hidden thread window
|
||||
dropdown menu is open or not.
|
||||
@param {mail.ThreadWindow[]} threadWindows list of thread windows
|
||||
that are hidden, i.e. put in the hidden window dropdown menu.
|
||||
@param {integer} [unreadCounter] the total unread counter of all
|
||||
threads which are linked to the thread windows that are put in the
|
||||
hidden thread window dropdown menu.
|
||||
-->
|
||||
<t t-name="mail.HiddenThreadWindowsDropdown">
|
||||
<div t-attf-class="o_thread_window o_in_home_menu o_thread_window_dropdown dropup #{open ? 'open' : ''}">
|
||||
<span role="alert" class="o_thread_window_dropdown_toggler dropdown-toggle" data-toggle="dropdown" title="Unread messages">
|
||||
<i class="fa fa-comments-o" role="img" aria-label="Messages"/> <t t-esc="threadWindows.length"/>
|
||||
<span class="o_total_unread_counter">
|
||||
<t t-if="unreadCounter"><t t-esc="unreadCounter"/></t>
|
||||
</span>
|
||||
</span>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<t t-foreach="threadWindows" t-as="threadWindow">
|
||||
<li role="menuitem" class="o_thread_window_header" t-att-data-thread-id="threadWindow.getID()">
|
||||
<t t-call="mail.AbstractThreadWindowHeaderContent">
|
||||
<t t-set="status" t-value="threadWindow.getStatus()"/>
|
||||
<t t-set="title" t-value="threadWindow.getTitle()"/>
|
||||
<t t-set="unreadCounter" t-value="threadWindow.getUnreadCounter()"/>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} [status] e.g. 'online', 'offline'.
|
||||
@param {string} title the title of the thread window, e.g. the record
|
||||
name of the document.
|
||||
@param {integer} [unreadCounter] the number of unread messages on the
|
||||
thread.
|
||||
@param {Object} widget
|
||||
@param {function} widget.isMobile function without any param that
|
||||
states whether it should render for desktop or mobile screen.
|
||||
-->
|
||||
<t t-name="mail.AbstractThreadWindowHeaderContent">
|
||||
<span t-if="widget.isMobile()">
|
||||
<a href="#" class="o_thread_window_close fa fa-1x fa-arrow-left mr4" aria-label="Close chat window" title="Close chat window"/>
|
||||
</span>
|
||||
<span class="o_thread_window_title">
|
||||
<t t-if="status" t-call="mail.UserStatus"/>
|
||||
<t t-esc="title"/>
|
||||
<span t-if="unreadCounter"> (<t t-esc="unreadCounter"/>)</span>
|
||||
</span>
|
||||
<span t-if="!widget.isMobile()" class="o_thread_window_buttons">
|
||||
<a href="#" class="o_thread_window_close fa fa-close"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -40,13 +40,13 @@
|
||||
</dd>
|
||||
<dt>Created on</dt>
|
||||
<dd class="mb8">
|
||||
<t t-esc="activity.create_date.format(datetime_format)"/>
|
||||
<t t-esc="activity.create_date.format(datetimeFormat)"/>
|
||||
</dd>
|
||||
<dt>Assigned to</dt>
|
||||
<dd class="mb8">
|
||||
<img t-attf-src="/web/image#{activity.user_id[0] >= 0 ? ('/res.users/' + activity.user_id[0] + '/image_small') : ''}" height="18" width="18" class="img-circle mr4" t-att-title="activity.user_id[1]" t-att-alt="activity.user_id[1]"/>
|
||||
<b><t t-esc="activity.user_id[1]"/></b>
|
||||
<em>, due on </em><span t-attf-class="o_activity_color_#{activity.state}"><t t-esc="activity.date_deadline.format(date_format)"/></span>
|
||||
<em>, due on </em><span t-attf-class="o_activity_color_#{activity.state}"><t t-esc="activity.date_deadline.format(dateFormat)"/></span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ChatWindow">
|
||||
<div class="o_chat_window o_in_home_menu">
|
||||
<div class="o_chat_header">
|
||||
<t t-call="mail.ChatWindowHeaderContent">
|
||||
<t t-set="status" t-value="widget.status"/>
|
||||
<t t-set="title" t-value="widget.title"/>
|
||||
<t t-set="unread_counter" t-value="widget.unread_msgs"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_chat_content">
|
||||
</div>
|
||||
<div t-if='!widget.options.input_less' class="o_chat_composer o_chat_mini_composer">
|
||||
<input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatWindowsDropdown">
|
||||
<div t-attf-class="o_chat_window o_in_home_menu o_chat_window_dropdown dropup #{open ? 'open' : ''}">
|
||||
<span role="alert" class="o_chat_window_dropdown_toggler dropdown-toggle" data-toggle="dropdown" title="Unread messages">
|
||||
<i class="fa fa-comments-o" role="img" aria-label="Messages"/> <t t-esc="sessions.length"/>
|
||||
<span class="o_total_unread_counter">
|
||||
<t t-if="unread_counter"><t t-esc="unread_counter"/></t>
|
||||
</span>
|
||||
</span>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<t t-foreach="sessions" t-as="session">
|
||||
<li role="menuitem" class="o_chat_header" t-att-data-session-id="session.id">
|
||||
<t t-call="mail.ChatWindowHeaderContent">
|
||||
<t t-set="status" t-value="session.window.status"/>
|
||||
<t t-set="title" t-value="session.window.title"/>
|
||||
<t t-set="unread_counter" t-value="session.window.unread_msgs"/>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatWindowHeaderContent">
|
||||
<span t-if="widget.isMobile">
|
||||
<a href="#" class="o_chat_window_close fa fa-1x fa-arrow-left mr4" aria-label="Close chat window" title="Close chat window"/>
|
||||
</span>
|
||||
<span class="o_chat_title">
|
||||
<t t-if="status" t-call="mail.chat.UserStatus"/>
|
||||
<t t-esc="title"/>
|
||||
<span t-if="unread_counter"> (<t t-esc="unread_counter"/>)</span>
|
||||
</span>
|
||||
<span t-if="!widget.isMobile" class="o_chat_window_buttons">
|
||||
<a href="#" class="o_chat_window_close fa fa-close" aria-label="Close chat window" title="Close chat window"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -3,28 +3,30 @@
|
||||
|
||||
<!--
|
||||
Chatter composer
|
||||
|
||||
@param {mail.composer.Chatter} widget
|
||||
-->
|
||||
<t t-name="mail.chatter.ChatComposer" t-extend="mail.ChatComposer">
|
||||
<t t-name="mail.chatter.Composer" t-extend="mail.Composer">
|
||||
<!-- Insert information before the composer -->
|
||||
<t t-jquery=".o_composer_container" t-operation="before">
|
||||
<!-- New message composer -->
|
||||
<t t-if="!widget.options.is_log">
|
||||
<t t-if="!widget.options.isLog">
|
||||
<!-- Text for followers -->
|
||||
<small class="o_chatter_composer_info">
|
||||
<b class="text-muted">To: </b>
|
||||
<em class="text-muted">Followers of </em>
|
||||
<b>
|
||||
<t t-if="widget.options.record_name">
|
||||
 "<t t-esc="widget.options.record_name"/>"
|
||||
<t t-if="widget.options.recordName">
|
||||
 "<t t-esc="widget.options.recordName"/>"
|
||||
</t>
|
||||
<t t-if="!widget.options.record_name">
|
||||
<t t-if="!widget.options.recordName">
|
||||
this document
|
||||
</t>
|
||||
</b>
|
||||
</small>
|
||||
<!-- List of followers -->
|
||||
<div class="o_composer_suggested_partners">
|
||||
<t t-foreach='widget.suggested_partners' t-as='recipient'>
|
||||
<div class="o_composer_suggestedPartners">
|
||||
<t t-foreach='widget.suggestedPartners' t-as='recipient'>
|
||||
<div t-attf-title="Add as recipient and follower (reason: #{recipient.reason})">
|
||||
<div class="o_checkbox">
|
||||
<input type="checkbox"
|
||||
@@ -48,15 +50,19 @@
|
||||
|
||||
<!--
|
||||
Chatter (mail_thread widget) buttons
|
||||
|
||||
@param {boolean} [logNoteButton]
|
||||
@param {boolean} [newMessageButton]
|
||||
@param {boolean} [scheduleActivityButton]
|
||||
-->
|
||||
<t t-name="mail.Chatter.Buttons">
|
||||
<button t-if="new_message_btn" type="button" class="btn btn-sm btn-link o_chatter_button_new_message" title="Send a message" disabled="disabled">
|
||||
<t t-name="mail.chatter.Buttons">
|
||||
<button t-if="newMessageButton" type="button" class="btn btn-sm btn-link o_chatter_button_new_message" title="Send a message" disabled="disabled">
|
||||
Send message
|
||||
</button>
|
||||
<button t-if="log_note_btn" class="btn btn-sm btn-link o_chatter_button_log_note" title="Log a note. Followers will not be notified." disabled="disabled">
|
||||
<button t-if="logNoteButton" class="btn btn-sm btn-link o_chatter_button_log_note" title="Log a note. Followers will not be notified." disabled="disabled">
|
||||
Log note
|
||||
</button>
|
||||
<button t-if="schedule_activity_btn" class="btn btn-sm btn-link o_chatter_button_schedule_activity" title="Log or schedule an activity" disabled="disabled">
|
||||
<button t-if="scheduleActivityButton" class="btn btn-sm btn-link o_chatter_button_schedule_activity" title="Log or schedule an activity" disabled="disabled">
|
||||
<i class="fa fa-clock-o" role="img" aria-label="Dates" title="Dates"/> <t t-if="isMobile">Activity</t><t t-else="">Schedule activity</t>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<div t-name="mail.ChatComposer" t-attf-class="o_chat_composer #{widget.extended ? 'o_chat_composer_extended' : (widget.notInline ? '' : 'o_chat_inline_composer')} #{widget.isMini ? 'o_chat_mini_composer' : ''}">
|
||||
<div t-name="mail.Composer" t-attf-class="o_thread_composer #{widget.extended ? 'o_thread_composer_extended' : (widget.notInline ? '' : 'o_chat_inline_composer')} #{widget.isMini ? 'o_chat_mini_composer' : ''}">
|
||||
<div class="o_composer_container">
|
||||
<img t-if="widget.avatarURL" height="36" width="36" class="o_thread_message_sidebar o_chatter_avatar img-circle" t-att-src="widget.avatarURL" alt="Avatar"/>
|
||||
<div t-if="widget.extended" class="o_composer_subject">
|
||||
@@ -20,11 +20,11 @@
|
||||
<div class="o_composer_attachments_list"/>
|
||||
</div>
|
||||
<div class="o_composer_send">
|
||||
<button tabindex="3" class="btn btn-sm btn-primary o_composer_button_send hidden-xs" type="button"><t t-esc="widget.options.send_text"/></button>
|
||||
<button tabindex="3" class="btn btn-sm btn-primary o_composer_button_send hidden-xs" type="button"><t t-esc="widget.options.sendText"/></button>
|
||||
</div>
|
||||
<span class="hide">
|
||||
<t t-call="HiddenInputFile">
|
||||
<t t-set="fileupload_id" t-value="widget.fileupload_id"/>
|
||||
<t t-set="fileupload_id" t-value="widget.fileuploadID"/>
|
||||
<t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
|
||||
<t t-set="multi_upload" t-value="true"/>
|
||||
<input type="hidden" name="model" value="mail.compose.message"/>
|
||||
@@ -34,15 +34,19 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div t-name="mail.ChatComposer.emojis" class="o_mail_emoji_container">
|
||||
<div t-name="mail.Composer.emojis" class="o_mail_emoji_container">
|
||||
<t t-foreach="emojis" t-as="emoji">
|
||||
<button t-att-data-emoji="emoji.source" class="btn btn-link o_mail_emoji" t-att-title="emoji.description" t-att-aria-label="emoji.description">
|
||||
<t t-raw="emoji.unicode_source"/>
|
||||
<button t-att-data-emoji="emoji.sources[0]" class="btn btn-link o_mail_emoji" t-att-title="emoji.description" t-att-aria-label="emoji.description">
|
||||
<t t-raw="emoji.unicode"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<t t-name="mail.AbstractMentionSuggestions">
|
||||
<!--
|
||||
@param {Object[]} suggestions
|
||||
@param {boolean} [suggestions.divider=false]
|
||||
-->
|
||||
<t t-name="mail.MentionSuggestions">
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<t t-foreach="suggestions" t-as="suggestion">
|
||||
<li role="separator" t-if="suggestion.divider" class="divider"/>
|
||||
@@ -52,7 +56,7 @@
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="mail.MentionPartnerSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-name="mail.MentionSuggestions.Partner" t-extend="mail.MentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
|
||||
<t t-if="suggestion.email">
|
||||
@@ -60,18 +64,18 @@
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="mail.MentionChannelSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-name="mail.MentionSuggestions.Channel" t-extend="mail.MentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="mail.MentionCannedResponseSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-name="mail.MentionSuggestions.CannedResponse" t-extend="mail.MentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name"><t t-esc="suggestion.source"/></span>
|
||||
<span class="o_mention_info"><t t-esc="suggestion.substitution"/></span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="mail.MentionCommandSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-name="mail.MentionSuggestions.Command" t-extend="mail.MentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name">/<t t-esc="suggestion.name"/></span>
|
||||
<span class="o_mention_info"><t t-esc="suggestion.help"/></span>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Discuss -->
|
||||
<!--
|
||||
Discuss Main template
|
||||
|
||||
@param {mail.discuss} widget
|
||||
-->
|
||||
<t t-name="mail.discuss">
|
||||
<div class="o_mail_chat">
|
||||
<div class="o_mail_chat_sidebar"/>
|
||||
<div class="o_mail_chat_content">
|
||||
<t t-if="widget.notification_bar">
|
||||
<div class="o_mail_discuss">
|
||||
<div class="o_mail_discuss_sidebar"/>
|
||||
<div class="o_mail_discuss_content">
|
||||
<t t-if="widget.displayNotificationBar">
|
||||
<div class="o_mail_annoying_notification_bar">
|
||||
<span class="o_mail_request_permission">Odoo needs your permission to <a href="#"> enable desktop notifications</a>.</span>
|
||||
<span class="fa fa-close" role="button" aria-label="Close" title="Close"></span>
|
||||
@@ -15,145 +19,184 @@
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="mail.chat.Sidebar">
|
||||
<div class="o_mail_chat_sidebar">
|
||||
<div t-attf-class="o_mail_chat_title_main o_mail_chat_channel_item #{(active_channel_id === 'channel_inbox') ? 'o_active': ''}"
|
||||
data-channel-id="channel_inbox">
|
||||
<span class="o_channel_name fa fa-inbox mr8">Inbox</span>
|
||||
<t t-set="counter" t-value="needaction_counter"/>
|
||||
<t t-call="mail.chat.SidebarNeedaction"/>
|
||||
|
||||
<!--
|
||||
@param {string|integer} activeThreadID
|
||||
@param {integer} moderationCounter
|
||||
@param {integer} needactionCounter
|
||||
@param {integer} starredCounter
|
||||
-->
|
||||
<t t-name="mail.discuss.Sidebar">
|
||||
<div class="o_mail_discuss_sidebar">
|
||||
<div t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID === 'mailbox_inbox') ? 'o_active': ''}"
|
||||
data-thread-id="mailbox_inbox">
|
||||
<span class="o_thread_name fa fa-inbox mr8">Inbox</span>
|
||||
<t t-set="counter" t-value="needactionCounter"/>
|
||||
<t t-call="mail.discuss.SidebarCounter"/>
|
||||
</div>
|
||||
<div t-attf-class="o_mail_chat_title_main o_mail_chat_title_starred o_mail_chat_channel_item #{(active_channel_id === 'channel_starred') ? 'o_active': ''}"
|
||||
data-channel-id="channel_starred">
|
||||
<span class="o_channel_name fa fa-star-o mr8">Starred</span>
|
||||
<t t-set="counter" t-value="starred_counter"/>
|
||||
<t t-call="mail.chat.SidebarNeedaction"/>
|
||||
<div t-attf-class="o_mail_discuss_title_main o_mail_mailbox_title_starred o_mail_discuss_item #{(activeThreadID === 'mailbox_starred') ? 'o_active': ''}"
|
||||
data-thread-id="mailbox_starred">
|
||||
<span class="o_thread_name fa fa-star-o mr8">Starred</span>
|
||||
<t t-set="counter" t-value="starredCounter"/>
|
||||
<t t-call="mail.discuss.SidebarCounter"/>
|
||||
</div>
|
||||
<div t-if="isModerator" t-attf-class="o_mail_chat_title_main o_mail_chat_channel_item #{(active_channel_id == 'channel_moderation') ? 'o_active': ''}"
|
||||
data-channel-id="channel_moderation">
|
||||
<span class="o_channel_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span>
|
||||
<div t-if="isModerator" t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID == 'mailbox_moderation') ? 'o_active': ''}"
|
||||
data-thread-id="mailbox_moderation">
|
||||
<span class="o_thread_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span>
|
||||
<t t-set="counter" t-value="moderationCounter"/>
|
||||
<t t-call="mail.chat.SidebarNeedaction"/>
|
||||
<t t-call="mail.discuss.SidebarCounter"/>
|
||||
</div>
|
||||
<hr class="mb8"/>
|
||||
|
||||
<t t-set="channel_type" t-value="'public'"/>
|
||||
<t t-call="mail.chat.SidebarTitle">
|
||||
<t t-set="channel_title">Channels</t>
|
||||
<t t-set="channel_icon" t-value="fa-users"/>
|
||||
<t t-set="type" t-value="'public'"/>
|
||||
<t t-call="mail.discuss.SidebarTitle">
|
||||
<t t-set="title">Channels</t>
|
||||
<t t-set="icon" t-value="fa-users"/>
|
||||
</t>
|
||||
<t t-call="mail.chat.SidebarItems">
|
||||
<t t-set="display_hash" t-value="true"/>
|
||||
<t t-set="input_placeholder">Add a channel</t>
|
||||
<t t-call="mail.discuss.SidebarItems">
|
||||
<t t-set="displayHash" t-value="true"/>
|
||||
<t t-set="inputPlaceholder">Add a channel</t>
|
||||
</t>
|
||||
|
||||
<t t-set="channel_type" t-value="'dm'"/>
|
||||
<t t-call="mail.chat.SidebarTitle">
|
||||
<t t-set="channel_title">Direct Messages</t>
|
||||
<t t-set="channel_icon" t-value="fa-user"/>
|
||||
<t t-set="type" t-value="'dm'"/>
|
||||
<t t-call="mail.discuss.SidebarTitle">
|
||||
<t t-set="title">Direct Messages</t>
|
||||
<t t-set="icon" t-value="fa-user"/>
|
||||
</t>
|
||||
<t t-call="mail.chat.SidebarItems">
|
||||
<t t-set="display_status" t-value="true"/>
|
||||
<t t-set="input_placeholder">User name</t>
|
||||
<t t-call="mail.discuss.SidebarItems">
|
||||
<t t-set="displayStatus" t-value="true"/>
|
||||
<t t-set="inputPlaceholder">User name</t>
|
||||
</t>
|
||||
|
||||
<t t-set="channel_type" t-value="'private'"/>
|
||||
<t t-call="mail.chat.SidebarTitle">
|
||||
<t t-set="channel_title">Private Channels</t>
|
||||
<t t-set="channel_icon" t-value="fa-eye-slash"/>
|
||||
<t t-set="type" t-value="'private'"/>
|
||||
<t t-call="mail.discuss.SidebarTitle">
|
||||
<t t-set="title">Private Channels</t>
|
||||
<t t-set="icon" t-value="fa-eye-slash"/>
|
||||
</t>
|
||||
<t t-call="mail.chat.SidebarItems">
|
||||
<t t-set="input_placeholder">Add a private channel</t>
|
||||
<t t-set="display_hash" t-value="true"/>
|
||||
<t t-call="mail.discuss.SidebarItems">
|
||||
<t t-set="inputPlaceholder">Add a private channel</t>
|
||||
<t t-set="displayHash" t-value="true"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="mail.chat.SidebarNeedaction">
|
||||
|
||||
<!--
|
||||
@param {integer} counter
|
||||
-->
|
||||
<t t-name="mail.discuss.SidebarCounter">
|
||||
<span t-attf-class="o_mail_sidebar_needaction badge #{(!counter ? 'hide' : '')}">
|
||||
<t t-esc="counter"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-name="mail.chat.SidebarTitle">
|
||||
<!-- Don't display the channel type if there is no channel of such type
|
||||
and the 'add a channel' option is disabled for this type -->
|
||||
<t t-if="disable_add_channel">
|
||||
|
||||
<!--
|
||||
Don't display the thread type if there is no thread of such type
|
||||
and the 'add a channel' option is disabled for this type
|
||||
|
||||
@param {mail.model.Thread[]} threads
|
||||
@param {boolean} [disableAddThread] whether the 'Add thread' action should disabled or not for the considered discuss item category
|
||||
@param {string} [icon] classname to add on title to display icon
|
||||
@param {string} title the displayed type of the discuss items (e.g. "Private Channels")
|
||||
@param {string} type the type of the thread
|
||||
-->
|
||||
<t t-name="mail.discuss.SidebarTitle">
|
||||
<t t-if="disableAddThread">
|
||||
<t t-set="empty" t-value="true"/>
|
||||
<t t-foreach="channels" t-as="channel">
|
||||
<t t-if="channel.type === channel_type">
|
||||
<t t-foreach="threads" t-as="thread">
|
||||
<t t-if="thread.getType() === type">
|
||||
<t t-set="empty" t-value="false"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="!disable_add_channel || !empty">
|
||||
<t t-if="!disableAddThread || !empty">
|
||||
<div class="o_mail_sidebar_title">
|
||||
<h4 t-att-class="channel_type == 'public' ? 'o_mail_open_channels' : ''">
|
||||
<i t-if="channel_icon" t-attf-class="mr4 fa-fw fa #{channel_icon}" role="img" aria-label="Channel" title="Channel"></i>
|
||||
<b><t t-esc="channel_title"/></b>
|
||||
<h4 t-att-class="type == 'public' ? 'o_mail_open_channels' : ''">
|
||||
<i t-if="icon" t-attf-class="mr4 fa-fw fa #{icon}" role="img" aria-label="Channel" title="Channel"></i>
|
||||
<b><t t-esc="title"/></b>
|
||||
</h4>
|
||||
<span t-if="!disable_add_channel" class="fa fa-plus o_add" title="Add" t-attf-data-type="#{channel_type}" role="img" aria-label="Add"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="mail.chat.SidebarItems">
|
||||
<div t-if="!disable_add_channel" class="o_mail_add_channel" t-attf-data-type="#{channel_type}">
|
||||
<span t-if="display_hash">#</span>
|
||||
<input type="text" class="o_input" t-attf-placeholder="#{input_placeholder}"/>
|
||||
</div>
|
||||
<t t-foreach="channels" t-as="channel">
|
||||
<t t-set="counter" t-value="channel.is_chat ? channel.unread_counter : channel.needaction_counter"/>
|
||||
<div t-if="channel.type === channel_type" t-att-data-channel-id="channel.id" t-att-title="channel.name"
|
||||
t-attf-class="o_mail_chat_channel_item #{channel.unread_counter ? ' o_unread_message' : ''} #{(active_channel_id == channel.id) ? 'o_active': ''}">
|
||||
<span class="o_channel_name">
|
||||
<span t-if="display_status">
|
||||
<t t-call="mail.chat.UserStatus">
|
||||
<t t-set="status" t-value="channel.status"/>
|
||||
</t>
|
||||
</span>
|
||||
<span t-if="display_hash" class="o_mail_hash">#</span>
|
||||
<t t-esc="channel.name"/>
|
||||
<i t-if="channel.mass_mailing" class="fa fa-envelope-o" title="Sends messages by email" role="img" aria-label="Send by mail"/>
|
||||
</span>
|
||||
<span t-attf-class="fa fa-cog o_mail_channel_settings"
|
||||
title="Channel settings" role="img" aria-label="Channel settings" t-att-data-channel-id="channel.id"/>
|
||||
<t t-call="mail.chat.SidebarNeedaction"/>
|
||||
<span t-if="! channel.group_based_subscription" t-attf-class="fa fa-times o_mail_partner_unpin #{counter ? 'hide' : ''}"
|
||||
title="Leave this channel" role="img" aria-label="Leave this channel" t-att-data-channel-id="channel.id"/>
|
||||
<span t-if="!disableAddThread" class="fa fa-plus o_add" title="Add" t-attf-data-type="#{type}" role="img" aria-label="Add"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.chat.UserStatus">
|
||||
<!--
|
||||
@param {string|integer} activeThreadID
|
||||
@param {mail.model.Thread[]} threads
|
||||
@param {boolean} disableAddThread whether the 'Add thread' action should be disable or not for the considered discuss item category
|
||||
@param {boolean} [displayHash] whether the hash ('#') character should be displayed as a prefix for item's name
|
||||
@param {boolean} [displayStatus] whether the item should display its status (should be the case only for chats)
|
||||
@param {string} inputPlaceholder text placeholder on showing input in a sidebar category
|
||||
-->
|
||||
<t t-name="mail.discuss.SidebarItems">
|
||||
<div t-if="!disableAddThread" class="o_mail_add_thread" t-attf-data-type="#{type}">
|
||||
<span t-if="displayHash">#</span>
|
||||
<input type="text" class="o_input" t-attf-placeholder="#{inputPlaceholder}"/>
|
||||
</div>
|
||||
<t t-foreach="threads" t-as="thread">
|
||||
<t t-set="counter" t-value="thread.getType() === 'mailbox' ? thread.getMailboxCounter() : thread.isChat() ? thread.getUnreadCounter() : thread.getNeedactionCounter()"/>
|
||||
<div t-if="thread.getType() === type" t-att-data-thread-id="thread.getID()" t-att-title="thread.getName()"
|
||||
t-attf-class="o_mail_discuss_item #{thread.hasUnreadMessages() ? ' o_unread_message' : ''} #{(activeThreadID == thread.getID()) ? 'o_active': ''}">
|
||||
<span class="o_thread_name">
|
||||
<span t-if="displayStatus">
|
||||
<t t-call="mail.UserStatus">
|
||||
<t t-set="status" t-value="thread.getStatus()"/>
|
||||
</t>
|
||||
</span>
|
||||
<span t-if="displayHash" class="o_mail_hash">#</span>
|
||||
<t t-esc="thread.getName()"/>
|
||||
<i t-if="thread.isMassMailing()" class="fa fa-envelope-o" title="Sends messages by email" role="img" aria-label="Send by mail"/>
|
||||
</span>
|
||||
<span t-attf-class="fa fa-cog o_mail_channel_settings"
|
||||
title="Channel settings" role="img" aria-label="Channel settings" t-att-data-thread-id="thread.getID()"/>
|
||||
<t t-call="mail.discuss.SidebarCounter"/>
|
||||
<span t-if="!thread.isGroupBasedSubscription()" t-attf-class="fa fa-times o_mail_partner_unpin #{counter ? 'hide' : ''}"
|
||||
title="Leave this channel" role="img" aria-label="Leave this channel" t-att-data-thread-id="thread.getID()"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} status
|
||||
-->
|
||||
<t t-name="mail.UserStatus">
|
||||
<i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.chat.MessageSentSnackbar">
|
||||
<!--
|
||||
@param {string} documentName
|
||||
-->
|
||||
<t t-name="mail.discuss.MessageSentSnackbar">
|
||||
<div class="alert o_mail_snackbar" data-dismiss="alert" role="alert">
|
||||
Message sent in "<t t-esc="record_name"/>".
|
||||
Message sent in "<t t-esc="documentName"/>".
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
<!-- Buttons of the Control Panel -->
|
||||
<t t-name="mail.chat.ControlButtons">
|
||||
<!--
|
||||
Buttons of the Control Panel
|
||||
|
||||
@param {boolean} [disableAddThread] whether the 'Add channel' action should be disabled or not for the considered discuss item category
|
||||
-->
|
||||
<t t-name="mail.discuss.ControlButtons">
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary btn-sm o_mail_chat_button_invite hidden-xs" title="Invite people">Invite</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_mark_read hidden-xs" title="Mark all as read">Mark all read</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_unstar_all" title="Unstar all messages">Unstar all</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_dm visible-xs" title="New Message">New Message</button>
|
||||
<button t-if="!disable_add_channel" type="button" class="btn btn-default btn-sm o_mail_chat_button_public o_mail_chat_button_private visible-xs" title="New Channel">New Channel</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_hidden o_mail_chat_button_select_all" title="Select all messages to moderate">Select All</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_hidden o_mail_chat_button_unselect_all" title="Unselect all messages to moderate">Unselect All</button>
|
||||
<button type="button" data-decision="accept" class="btn btn-default btn-sm o_mail_chat_button_moderate_all" title="Accept selected messages">Accept</button>
|
||||
<button type="button" data-decision="reject" class="btn btn-default btn-sm o_mail_chat_button_moderate_all" title="Reject selected messages">Reject</button>
|
||||
<button type="button" data-decision="discard" class="btn btn-default btn-sm o_mail_chat_button_moderate_all" title="Discard selected messages">Discard</button>
|
||||
<button type="button" class="btn btn-primary btn-sm o_mail_discuss_button_invite hidden-xs" title="Invite people">Invite</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_mail_discuss_button_mark_all_read hidden-xs" title="Mark all as read">Mark all read</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_mail_discuss_button_unstar_all" title="Unstar all messages">Unstar all</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_mail_discuss_button_dm visible-xs" title="New Message">New Message</button>
|
||||
<button t-if="!disableAddThread" type="button" class="btn btn-default btn-sm o_mail_discuss_button_public o_mail_discuss_button_private visible-xs" title="New Channel">New Channel</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_hidden o_mail_discuss_button_select_all" title="Select all messages to moderate">Select All</button>
|
||||
<button type="button" class="btn btn-default btn-sm o_hidden o_mail_discuss_button_unselect_all" title="Unselect all messages to moderate">Unselect All</button>
|
||||
<button type="button" data-decision="accept" class="btn btn-default btn-sm o_mail_discuss_button_moderate_all" title="Accept selected messages">Accept</button>
|
||||
<button type="button" data-decision="reject" class="btn btn-default btn-sm o_mail_discuss_button_moderate_all" title="Reject selected messages">Reject</button>
|
||||
<button type="button" data-decision="discard" class="btn btn-default btn-sm o_mail_discuss_button_moderate_all" title="Discard selected messages">Discard</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Invite dialog's content -->
|
||||
<div t-name="mail.PartnerInviteDialog">
|
||||
<input type="text" class="o_input o_mail_chat_partner_invite_input" id="mail_search_partners"/>
|
||||
<input type="text" class="o_input o_mail_discuss_partner_invite_input" id="mail_search_partners"/>
|
||||
</div>
|
||||
|
||||
<div t-name="mail.ModeratorRejectMessageDialog">
|
||||
@@ -163,17 +206,18 @@
|
||||
|
||||
<!-- Mobile templates -->
|
||||
<t t-name="mail.discuss_mobile">
|
||||
<div class="o_mail_chat">
|
||||
<div class="o_mail_chat_mobile_inbox_buttons">
|
||||
<button type="button" class="btn btn-primary btn-sm visible-xs-inline o_channel_inbox_item" title="Inbox" data-type="channel_inbox">
|
||||
<div class="o_mail_discuss">
|
||||
<div class="o_mail_discuss_mobile_mailboxes_buttons">
|
||||
<button type="button" class="btn btn-primary btn-sm visible-xs-inline o_mailbox_inbox_item" title="Inbox" data-type="mailbox_inbox">
|
||||
Inbox
|
||||
</button><button type="button" class="btn btn-default btn-sm visible-xs-inline o_channel_inbox_item" title="Starred" data-type="channel_starred">
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm visible-xs-inline o_mailbox_inbox_item" title="Starred" data-type="mailbox_starred">
|
||||
Starred
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_mail_chat_content"/>
|
||||
<div class="o_mail_discuss_content"/>
|
||||
<div class="o_mail_mobile_tabs">
|
||||
<div class="o_mail_mobile_tab" data-type="channel_inbox">
|
||||
<div class="o_mail_mobile_tab" data-type="mailbox_inbox">
|
||||
<span class="o_tab_title fa fa-inbox">Inbox</span>
|
||||
</div>
|
||||
<div class="o_mail_mobile_tab" data-type="dm">
|
||||
@@ -189,51 +233,71 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.chat.MobileTabPane">
|
||||
<div class="o_mail_chat_tab_pane" t-att-data-type="type">
|
||||
<div t-if="!disable_add_channel" class="o_mail_add_channel" t-att-data-type="type">
|
||||
<!--
|
||||
@param {boolean} [disableAddThread] whether the 'Add thread' action should be disabled or not for the considered discuss item category
|
||||
@param {Object[]} previews valid objects for preview rendering (see mail.Preview template)
|
||||
-->
|
||||
<t t-name="mail.discuss.MobileTabPane">
|
||||
<div class="o_mail_discuss_tab_pane" t-att-data-type="type">
|
||||
<div t-if="!disableAddThread" class="o_mail_add_thread" t-att-data-type="type">
|
||||
<span t-if="type == 'private' || type == 'public'">#</span>
|
||||
<t t-set="input_placeholder" t-if="type == 'private' || type == 'public'">Add a channel</t>
|
||||
<t t-set="input_placeholder" t-if="type == 'dm'">Open chat</t>
|
||||
<input type="text" t-attf-placeholder="#{input_placeholder}"/>
|
||||
<t t-set="inputPlaceholder" t-if="type == 'private' || type == 'public'">Add a channel</t>
|
||||
<t t-set="inputPlaceholder" t-if="type == 'dm'">Open chat</t>
|
||||
<input type="text" t-attf-placeholder="#{inputPlaceholder}"/>
|
||||
</div>
|
||||
<t t-foreach="channels" t-as="channel">
|
||||
<t t-call="mail.chat.ChannelPreview"/>
|
||||
<t t-foreach="previews" t-as="preview">
|
||||
<t t-call="mail.Preview"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- The ChannelPreview template is used by the discuss in mobile, and by the systray menu -->
|
||||
<t t-name="mail.chat.ChannelPreview">
|
||||
<div t-attf-class="o_mail_channel_preview #{channel.unread_counter ? 'o_channel_unread' : ''}"
|
||||
t-att-data-channel_id="channel.id" t-att-data-res_id="channel.res_id" t-att-data-res_model="channel.model">
|
||||
<div t-attf-class="o_mail_channel_image #{channel.model? 'o_mail_channel_app' : ''}">
|
||||
<img class="o_mail_channel_image" t-att-src="channel.image_src" alt="Channel"/>
|
||||
<i t-if="channel.status === 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="channel.status === 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<!--
|
||||
The preview template is used by the discuss in mobile, and by the systray menu
|
||||
In order to show preview of threads
|
||||
|
||||
FIXME: it is shared with systray, so it should not be in discuss.xml
|
||||
|
||||
@param {Object} preview a valid object for preview rendering
|
||||
@param {integer} [preview.documentID]
|
||||
@param {string} [preview.documentModel]
|
||||
@param {string|integer} preview.id
|
||||
@param {string} [preview.imageSRC]
|
||||
@param {boolean} [preview.isAuthor]
|
||||
@param {moment} [preview.date]
|
||||
@param {string} [preview.author]
|
||||
@param {string} [preview.body]
|
||||
@param {string} [preview.title]
|
||||
@param {string} [preview.status]
|
||||
@param {integer} [preview.unreadCounter]
|
||||
-->
|
||||
<t t-name="mail.Preview">
|
||||
<div t-attf-class="o_mail_preview #{preview.unreadCounter > 0 ? 'o_preview_unread' : ''}"
|
||||
t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel">
|
||||
<div t-attf-class="o_mail_preview_image #{preview.documentModel ? 'o_mail_preview_app' : ''}">
|
||||
<img class="o_mail_preview_image" t-att-src="preview.imageSRC" alt="Channel"/>
|
||||
<i t-if="preview.status === 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="preview.status === 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
</div>
|
||||
<div class="o_channel_info">
|
||||
<div class="o_channel_title">
|
||||
<span class="o_channel_name">
|
||||
<t t-esc="channel.name"/>
|
||||
<div class="o_preview_info">
|
||||
<div class="o_preview_title">
|
||||
<span class="o_preview_name">
|
||||
<t t-esc="preview.title"/>
|
||||
</span>
|
||||
<span class="o_channel_counter">
|
||||
<t t-if="channel.unread_counter">&nbsp;(<t t-esc="channel.unread_counter"/>)</t>
|
||||
<span class="o_preview_counter">
|
||||
<t t-if="preview.unreadCounter > 0">&nbsp;(<t t-esc="preview.unreadCounter"/>)</t>
|
||||
</span>
|
||||
<span class="o_last_message_date"> <t t-esc="channel.last_message_date"/> </span>
|
||||
<span class="o_last_message_date"> <t t-esc="preview.date ? preview.date.fromNow() : ''"/> </span>
|
||||
</div>
|
||||
<div t-if="channel.last_message" class="o_last_message_preview">
|
||||
<t t-if="channel.last_message.is_author">
|
||||
<div t-if="preview.body" class="o_last_message_preview">
|
||||
<t t-if="preview.isAuthor">
|
||||
<span class="fa fa-mail-reply" role="img" aria-label="Reply"/> You:
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="channel.last_message.displayed_author">
|
||||
<t t-esc="channel.last_message.displayed_author"/>:
|
||||
</t>
|
||||
<t t-elif="preview.author">
|
||||
<t t-esc="preview.author"/>:
|
||||
</t>
|
||||
<t t-raw="channel.last_message_preview"/>
|
||||
<t t-raw="preview.body"/>
|
||||
</div>
|
||||
<span t-att-data-channel_id="channel.id" t-att-data-res_id="channel.res_id" t-att-data-model="channel.model" title="Mark as Read" class="o_discuss_icon o_mail_channel_mark_read fa fa-check" t-if="channel.unread_counter"/>
|
||||
<span title="Mark as Read" class="o_discuss_icon o_mail_preview_mark_as_read fa fa-check" t-if="preview.unreadCounter"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ExtendedChatWindow" t-extend="mail.ChatWindow">
|
||||
<t t-jquery=".o_chat_header" t-operation="after">
|
||||
<div t-if="widget.options.thread_less" class="o_chat_search_input">
|
||||
<span> To: </span>
|
||||
<input type="text" placeholder="User name"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-extend="mail.ChatWindowHeaderContent">
|
||||
<t t-jquery=".o_chat_window_buttons" t-operation="prepend">
|
||||
<a href="#" class="o_chat_window_expand fa fa-expand" title="Open in Discuss" aria-label="Open in discuss"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,18 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="mail.chat.MessagingMenu">
|
||||
<li class="o_mail_navbar_item">
|
||||
<!--
|
||||
@param {mail.systray.MessagingMenu} widget
|
||||
-->
|
||||
<t t-name="mail.systray.MessagingMenu">
|
||||
<li class="o_mail_systray_item">
|
||||
<a class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" title="Conversations" href="#">
|
||||
<i class="fa fa-comments" role="img" aria-label="Messages"/> <span class="o_notification_counter badge"/>
|
||||
</a>
|
||||
<ul class="o_mail_navbar_dropdown dropdown-menu" role="menu">
|
||||
<li role="menuitem" t-if="widget.isMobile">
|
||||
<div class="o_mail_navbar_mobile_header">
|
||||
<ul class="o_mail_systray_dropdown dropdown-menu" role="menu">
|
||||
<li role="menuitem" t-if="widget.isMobile()">
|
||||
<div class="o_mail_systray_mobile_header">
|
||||
<button type="button" class="btn btn-default btn-sm o_new_message"> New message </button>
|
||||
</div>
|
||||
</li>
|
||||
<li role="menuitem" t-if="!widget.isMobile" class="o_mail_navbar_dropdown_top">
|
||||
<li role="menuitem" t-if="!widget.isMobile()" class="o_mail_systray_dropdown_top">
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-link o_filter_button active"> All </button>
|
||||
<button type="button" class="btn btn-sm btn-link o_filter_button" data-filter='chat'> Chat </button>
|
||||
@@ -20,8 +23,8 @@
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-link o_new_message"> New message </button>
|
||||
</li>
|
||||
<li role="menuitem" class="o_mail_navbar_dropdown_channels"/>
|
||||
<li role="menuitem" t-if="widget.isMobile">
|
||||
<li role="menuitem" class="o_mail_systray_dropdown_items"/>
|
||||
<li role="menuitem" t-if="widget.isMobile()">
|
||||
<div class="o_mail_mobile_tabs">
|
||||
<div class="o_mail_mobile_tab o_filter_button active">
|
||||
<span class="o_tab_title fa fa-envelope">All</span>
|
||||
@@ -38,31 +41,38 @@
|
||||
</li>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.chat.ChannelsPreview">
|
||||
<t t-if="_.isEmpty(channels)">
|
||||
<!--
|
||||
@param {Object} previews valid list of objects for preview rendering
|
||||
(see mail.Preview template)
|
||||
-->
|
||||
<t t-name="mail.systray.MessagingMenu.Previews">
|
||||
<t t-if="_.isEmpty(previews)">
|
||||
<li class="text-center o_no_activity mt16">
|
||||
<span>No discussion yet...</span>
|
||||
<span>No conversation yet...</span>
|
||||
</li>
|
||||
</t>
|
||||
<t t-foreach="channels" t-as="channel">
|
||||
<t t-call="mail.chat.ChannelPreview"/>
|
||||
<t t-foreach="previews" t-as="preview">
|
||||
<t t-call="mail.Preview"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.chat.ActivityMenuPreview">
|
||||
<!--
|
||||
@param {Object[]} activities
|
||||
-->
|
||||
<t t-name="mail.systray.ActivityMenu.Previews">
|
||||
<t t-if="_.isEmpty(activities)">
|
||||
<li class="text-center o_no_activity">
|
||||
<span>No activities planned.</span>
|
||||
</li>
|
||||
</t>
|
||||
<t t-foreach="activities" t-as="activity">
|
||||
<div class="o_mail_channel_preview" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='my'>
|
||||
<div class="o_mail_channel_image o_mail_channel_app">
|
||||
<div class="o_mail_preview" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='my'>
|
||||
<div class="o_mail_preview_image o_mail_preview_app">
|
||||
<img t-att-src="activity.icon" alt="Activity"/>
|
||||
</div>
|
||||
<div class="o_channel_info">
|
||||
<div class="o_channel_title">
|
||||
<span class="o_channel_name">
|
||||
<div class="o_preview_info">
|
||||
<div class="o_preview_title">
|
||||
<span class="o_preview_name">
|
||||
<t t-esc="activity.name"/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -79,13 +89,13 @@
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.chat.ActivityMenu">
|
||||
<li class="o_mail_navbar_item">
|
||||
<t t-name="mail.systray.ActivityMenu">
|
||||
<li class="o_mail_systray_item">
|
||||
<a class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" title="Activities" href="#">
|
||||
<i class="fa fa-clock-o" role="img" aria-label="Activities"/> <span class="o_notification_counter badge"/>
|
||||
</a>
|
||||
<ul class="o_mail_navbar_dropdown dropdown-menu" role="menu">
|
||||
<li role="menuitem" class="o_mail_navbar_dropdown_channels"/>
|
||||
<ul class="o_mail_systray_dropdown dropdown-menu" role="menu">
|
||||
<li role="menuitem" class="o_mail_systray_dropdown_items"/>
|
||||
</ul>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ChatThread">
|
||||
<!--
|
||||
@param {mail.model.AbstractMessage[]} messages
|
||||
-->
|
||||
<t t-name="mail.widget.Thread">
|
||||
<t t-if="messages.length">
|
||||
<t t-if="options.display_load_more and options.display_order == ORDER.ASC"
|
||||
t-call="mail.ChatThread.LoadMore"/>
|
||||
<t t-call="mail.ChatThread.Content"/>
|
||||
<t t-if="options.display_load_more and options.display_order == ORDER.DESC"
|
||||
t-call="mail.ChatThread.LoadMore"/>
|
||||
<t t-if="options.displayLoadMore and options.displayOrder == ORDER.ASC"
|
||||
t-call="mail.widget.Thread.LoadMore"/>
|
||||
<t t-call="mail.widget.Thread.Content"/>
|
||||
<t t-if="options.displayLoadMore and options.displayOrder == ORDER.DESC"
|
||||
t-call="mail.widget.Thread.LoadMore"/>
|
||||
</t>
|
||||
<t t-if="options.display_empty_channel">
|
||||
<t t-if="options.displayEmptyChannel">
|
||||
<t t-call="mail.EmptyChannel" />
|
||||
</t>
|
||||
<div t-if="options.display_no_match" class="o_mail_no_content">
|
||||
<div t-if="options.displayNoMatchFound" class="o_mail_no_content">
|
||||
<div class="o_thread_title">No matches found</div>
|
||||
<div>No message matches your search. Try to change your search filters.</div>
|
||||
</div>
|
||||
@@ -64,7 +67,7 @@
|
||||
<div class="modal o_modal_fullscreen" tabindex="-1" data-keyboard="false" role="dialog">
|
||||
<t t-call="DocumentViewer.Content"/>
|
||||
|
||||
<t t-if="widget.attachment.length != 1">
|
||||
<t t-if="widget.attachment.length !== 1">
|
||||
<a class="arrow arrow-left move_previous" href="#">
|
||||
<span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
|
||||
</a>
|
||||
@@ -93,149 +96,159 @@
|
||||
</html>
|
||||
</t>
|
||||
|
||||
<!-- When a channel is empty-->
|
||||
<!--
|
||||
When a channel is empty
|
||||
|
||||
@param {mail.widget.Thread} widget
|
||||
-->
|
||||
<div t-name="mail.EmptyChannel" class="o_mail_no_content">
|
||||
<t t-if="options.channel_id==='channel_inbox'">
|
||||
<t t-if="options.threadID === 'mailbox_inbox'">
|
||||
<div class="o_thread_title">Congratulations, your inbox is empty</div>
|
||||
<div>New messages appear here.</div>
|
||||
</t>
|
||||
<t t-if="options.channel_id==='channel_starred'">
|
||||
<t t-if="options.threadID === 'mailbox_starred'">
|
||||
<div class="o_thread_title">No starred message</div>
|
||||
<div>You can mark any message as 'starred', and it shows up in this channel.</div>
|
||||
<div>You can mark any message as 'starred', and it shows up in this mailbox.</div>
|
||||
</t>
|
||||
<t t-if="options.channel_id==='channel_moderation'">
|
||||
<t t-if="options.threadID === 'mailbox_moderation'">
|
||||
<div class="o_thread_title">You have no message to moderate</div>
|
||||
<div>Pending moderation messages appear here.</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<t t-name="mail.ChatThread.Content">
|
||||
<t t-if="options.messages_separator_position == 'top'" t-call="mail.MessagesSeparator"/>
|
||||
<!--
|
||||
@param {mail.model.AbstractMessage[]} messages
|
||||
-->
|
||||
<t t-name="mail.widget.Thread.Content">
|
||||
<t t-if="options.messagesSeparatorPosition == 'top'" t-call="mail.MessagesSeparator"/>
|
||||
<t t-set="current_day" t-value="0"/>
|
||||
<t t-foreach="messages" t-as="message">
|
||||
<div t-if="current_day != message.day" class="o_thread_date_separator">
|
||||
<div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator">
|
||||
<span class="o_thread_date">
|
||||
<t t-esc="message.day"/>
|
||||
<t t-esc="message.getDateDay()"/>
|
||||
</span>
|
||||
<t t-set="current_day" t-value="message.day"/>
|
||||
<t t-set="current_day" t-value="message.getDateDay()"/>
|
||||
</div>
|
||||
|
||||
<t t-call="mail.ChatThread.Message"/>
|
||||
<t t-call="mail.widget.Thread.Message"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatThread.Message">
|
||||
<div t-att-class="'o_thread_message' + (message.expanded ? ' o_message_expanded ' : ' ') + (message.is_selected ? 'o_thread_selected_message' : '') + (message.is_discussion or message.is_notification ? ' o_mail_discussion' : ' o_mail_not_discussion')" t-att-data-message-id="message.id">
|
||||
<div t-if="options.display_avatar" class="o_thread_message_sidebar">
|
||||
<t t-if="!message.mailto and message.author_id[0]">
|
||||
<img t-if="message.avatar_src and message.display_author"
|
||||
<!--
|
||||
@param {mail.model.AbstractMessage} message
|
||||
-->
|
||||
<t t-name="mail.widget.Thread.Message">
|
||||
<div t-att-class="'o_thread_message' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion' : ' o_mail_not_discussion')" t-att-data-message-id="message.getID()">
|
||||
<div t-if="options.displayAvatars" class="o_thread_message_sidebar">
|
||||
<t t-if="message.hasAuthor()">
|
||||
<img t-if="message.shouldDisplayAuthor()"
|
||||
alt=""
|
||||
t-att-src="message.avatar_src"
|
||||
t-att-src="message.getAvatarSource()"
|
||||
data-oe-model="res.partner"
|
||||
t-att-data-oe-id="message.author_redirect ? message.author_id[0] : ''"
|
||||
t-attf-class="o_thread_message_avatar img-circle #{message.author_redirect ? 'o_mail_redirect' : ''}"/>
|
||||
t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_message_avatar img-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
|
||||
</t>
|
||||
<t t-if="message.mailto || !message.author_id[0]">
|
||||
<img t-if="message.avatar_src and message.display_author"
|
||||
<t t-else="">
|
||||
<img t-if="message.shouldDisplayAuthor()"
|
||||
alt=""
|
||||
t-att-src="message.avatar_src"
|
||||
t-att-src="message.getAvatarSource()"
|
||||
class="o_thread_message_avatar img-circle"/>
|
||||
</t>
|
||||
<span t-if="!message.display_author" t-att-title="message.date.format(date_format)" class="o_thread_message_side_date">
|
||||
<t t-esc="message.date.format('hh:mm')"/>
|
||||
<span t-if="!message.shouldDisplayAuthor()" t-att-title="message.getDate().format(dateFormat)" class="o_thread_message_side_date">
|
||||
<t t-esc="message.getDate().format('hh:mm')"/>
|
||||
</span>
|
||||
<i t-if="!message.display_author and options.display_stars and message.message_type != 'notification'"
|
||||
t-att-class="'fa o_thread_message_star o_thread_icon ' + (message.is_starred ? 'fa-star' : 'fa-star-o')"
|
||||
t-att-data-message-id="message.id" title="Mark as Todo" role="img" aria-label="Mark as todo"/>
|
||||
<i t-if="!message.shouldDisplayAuthor() and options.displayStars and message.getType() !== 'notification'"
|
||||
t-att-class="'fa o_thread_message_star o_thread_icon ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')"
|
||||
t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as todo"/>
|
||||
</div>
|
||||
<div class="o_thread_message_core">
|
||||
<p t-if="message.display_author" class="o_mail_info text-muted">
|
||||
<input t-if="message.needsModeration and message.isModerator" type="checkbox" class="moderation_checkbox" t-att-data-message-id="message.id"/>
|
||||
<t t-if="message.is_note">
|
||||
<p t-if="message.shouldDisplayAuthor()" class="o_mail_info text-muted">
|
||||
<input t-if="message.needsModeration() and message.needsModerationByUser()" type="checkbox" class="moderation_checkbox" t-att-data-message-id="message.getID()"/>
|
||||
<t t-if="message.isNote()">
|
||||
Note by
|
||||
</t>
|
||||
|
||||
<strong t-if="message.mailto">
|
||||
<a class="text-muted" t-attf-href="mailto:#{message.mailto}?subject=Re: #{message.subject}">
|
||||
<t t-esc="message.mailto"/>
|
||||
<strong t-if="message.hasAuthor()"
|
||||
data-oe-model="res.partner" t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_author #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}">
|
||||
<t t-esc="message.getDisplayedAuthor()"/>
|
||||
</strong>
|
||||
<strong t-elif="message.hasEmailFrom()">
|
||||
<a class="text-muted" t-attf-href="mailto:#{message.getEmailFrom()}?subject=Re: #{message.hasSubject() ? message.getSubject() : ''}">
|
||||
<t t-esc="message.getEmailFrom()"/>
|
||||
</a>
|
||||
</strong>
|
||||
<strong t-if="!message.mailto and message.author_id[0]"
|
||||
data-oe-model="res.partner" t-att-data-oe-id="message.author_redirect ? message.author_id[0] : ''"
|
||||
t-attf-class="o_thread_author #{message.author_redirect ? 'o_mail_redirect' : ''}">
|
||||
<t t-esc="message.displayed_author"/>
|
||||
</strong>
|
||||
<strong t-if="!message.mailto and !message.author_id[0]" class="o_thread_author">
|
||||
<t t-esc="message.displayed_author"/>
|
||||
<strong t-else="" class="o_thread_author">
|
||||
<t t-esc="message.getDisplayedAuthor()"/>
|
||||
</strong>
|
||||
|
||||
- <small class="o_mail_timestamp" t-att-title="message.date.format(date_format)"><t t-esc="message.hour"/></small>
|
||||
<t t-if="message.model and (message.model != 'mail.channel') and options.display_document_link">
|
||||
on <a t-att-href="message.url" t-att-data-oe-model="message.model" t-att-data-oe-id="message.res_id" class="o_document_link"><t t-esc="message.record_name"/></a>
|
||||
- <small class="o_mail_timestamp" t-att-title="message.getDate().format(dateFormat)"><t t-esc="message.getTimeElapsed()"/></small>
|
||||
<t t-if="message.isLinkedToDocumentThread() and options.displayDocumentLinks">
|
||||
on <a t-att-href="message.getURL()" t-att-data-oe-model="message.getDocumentModel()" t-att-data-oe-id="message.getDocumentID()" class="o_document_link"><t t-esc="message.getDocumentName()"/></a>
|
||||
</t>
|
||||
<t t-if="message.origin_id and (message.origin_id !== options.channel_id)">
|
||||
(from <a t-att-data-oe-id="message.origin_id" href="#">#<t t-esc="message.origin_name"/></a>)
|
||||
<t t-if="message.originatesFromChannel() and (message.getOriginChannelID() !== options.threadID)">
|
||||
(from <a t-att-data-oe-id="message.getOriginChannelID()" href="#">#<t t-esc="message.getOriginChannelName()"/></a>)
|
||||
</t>
|
||||
<span t-if="options.display_email_icon and message.customer_email_data and message.customer_email_data.length" class="o_thread_tooltip_container">
|
||||
<span t-if="options.displayEmailIcons and message.hasCustomerEmailData()" class="o_thread_tooltip_container">
|
||||
<t t-set="fatype" t-value="''"/>
|
||||
<t t-if="message.customer_email_status === 'sent' or message.customer_email_status === 'ready'">
|
||||
<t t-if="message.getCustomerEmailStatus() === 'sent' or message.getCustomerEmailStatus() === 'ready'">
|
||||
<t t-set="fatype" t-value="'-o'"/>
|
||||
</t>
|
||||
<i t-att-class="'o_thread_tooltip o_thread_message_email o_thread_message_email_' + message.customer_email_status + ' fa fa-envelope'+ fatype" t-att-data-message-id="message.id"/>
|
||||
<i t-att-class="'o_thread_tooltip o_thread_message_email o_thread_message_email_' + message.getCustomerEmailStatus() + ' fa fa-envelope-o'" t-att-data-message-id="message.getID()"/>
|
||||
<span class="o_thread_tooltip_content">
|
||||
<t t-foreach="message.customer_email_data" t-as="customer">
|
||||
<t t-foreach="message.getCustomerEmailData()" t-as="customerEmailData">
|
||||
<span>
|
||||
<t t-if="customer[2] === 'sent'"><i class='fa fa-check' title="Sent" role="img" aria-label="Sent"/></t>
|
||||
<t t-if="customer[2] === 'bounce'"><i class='fa fa-exclamation' title="Bounced" role="img" aria-label="Bounced"/></t>
|
||||
<t t-if="customer[2] === 'exception'"><i class='fa fa-exclamation' title="Error" role="img" aria-label="Error"/></t>
|
||||
<t t-if="customer[2] === 'ready'"><i class='fa fa-send-o' title="Ready" role="img" aria-label="Ready"/></t>
|
||||
<t t-if="customer[2] === 'canceled'"><i class='fa fa-trash-o' title="Canceled" role="img" aria-label="Canceled"/></t>
|
||||
<t t-esc="customer[1]"/>
|
||||
<t t-if="customerEmailData[2] === 'sent'"><i class='fa fa-check' title="Sent" role="img" aria-label="Sent"/></t>
|
||||
<t t-if="customerEmailData[2] === 'bounce'"><i class='fa fa-exclamation' title="Bounced" role="img" aria-label="Bounced"/></t>
|
||||
<t t-if="customerEmailData[2] === 'exception'"><i class='fa fa-exclamation' title="Error" role="img" aria-label="Error"/></t>
|
||||
<t t-if="customerEmailData[2] === 'ready'"><i class='fa fa-send-o' title="Ready" role="img" aria-label="Ready"/></t>
|
||||
<t t-if="customerEmailData[2] === 'canceled'"><i class='fa fa-trash-o' title="Canceled" role="img" aria-label="Canceled"/></t>
|
||||
<t t-esc="customerEmailData[1]"/>
|
||||
</span>
|
||||
<br />
|
||||
</t>
|
||||
</span>
|
||||
</span>
|
||||
<span t-attf-class="o_thread_icons">
|
||||
<i t-if="options.display_stars and !message.is_system_notification and !message.needsModeration"
|
||||
t-att-class="'fa fa-lg o_thread_icon o_thread_message_star ' + (message.is_starred ? 'fa-star' : 'fa-star-o')"
|
||||
t-att-data-message-id="message.id" title="Mark as Todo" role="img" aria-label="Mark as Todo"/>
|
||||
<i t-if="message.record_name && message.model != 'mail.channel' && options.display_reply_icon"
|
||||
<i t-if="options.displayStars and !message.isSystemNotification() and !message.needsModeration()"
|
||||
t-att-class="'fa fa-lg o_thread_icon o_thread_message_star ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')"
|
||||
t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as Todo"/>
|
||||
<i t-if="message.originatesFromChannel() and options.displayReplyIcons"
|
||||
class="fa fa-reply o_thread_icon o_thread_message_reply"
|
||||
t-att-data-message-id="message.id" title="Reply" role="img" aria-label="Reply"/>
|
||||
<i t-if="message.is_needaction && options.display_needactions"
|
||||
t-att-data-message-id="message.getID()" title="Reply" role="img" aria-label="Reply"/>
|
||||
<i t-if="message.isNeedaction() and options.displayMarkAsRead"
|
||||
class="fa fa-check o_thread_icon o_thread_message_needaction"
|
||||
t-att-data-message-id="message.id" title="Mark as Read" role="img" aria-label="Mark as Read"/>
|
||||
t-att-data-message-id="message.getID()" title="Mark as Read" role="img" aria-label="Mark as Read"/>
|
||||
|
||||
<t t-if="message.needsModeration and message.model === 'mail.channel'">
|
||||
<t t-if="message.isModerator">
|
||||
<t t-if="message.needsModeration() and message.originatesFromChannel()">
|
||||
<t t-if="message.needsModerationByUser()">
|
||||
<t t-call="mail.MessageModerationDecision"/>
|
||||
</t>
|
||||
<t t-elif="message.is_author">
|
||||
<t t-elif="message.isAuthor()">
|
||||
<t t-call="mail.MessageModerationFeedback"/>
|
||||
</t>
|
||||
</t>
|
||||
</span>
|
||||
</p>
|
||||
<div class="o_thread_message_content">
|
||||
<t t-if="message.tracking_value_ids and message.tracking_value_ids.length > 0">
|
||||
<t t-if="message.subtype_description">
|
||||
<p><t t-esc="message.subtype_description"/></p>
|
||||
<t t-if="message.hasTrackingValues()">
|
||||
<t t-if="message.hasSubtypeDescription()">
|
||||
<p><t t-esc="message.getSubtypeDescription()"/></p>
|
||||
</t>
|
||||
<t t-call="mail.ChatThread.MessageTracking"/>
|
||||
<t t-call="mail.widget.Thread.MessageTracking"/>
|
||||
</t>
|
||||
<p t-if="options.display_subject and message.display_subject" class="o_mail_subject">Subject: <t t-esc="message.subject"/></p>
|
||||
<t t-if="!(message.tracking_value_ids and message.tracking_value_ids.length > 0)">
|
||||
<t t-raw="message.body"/>
|
||||
<p t-if="options.displaySubjectOnMessages and message.shouldDisplaySubject()" class="o_mail_subject">Subject: <t t-esc="message.getSubject()"/></p>
|
||||
<t t-if="!(message.hasTrackingValues())">
|
||||
<t t-raw="message.getBody()"/>
|
||||
</t>
|
||||
<t t-if="message.attachment_ids.length > 0">
|
||||
<div t-if="message.images.length > 0" class="o_attachments_previews">
|
||||
<t t-foreach="message.images" t-as="attachment">
|
||||
<t t-if="message.hasAttachments()">
|
||||
<div t-if="message.hasImageAttachments()" class="o_attachments_previews">
|
||||
<t t-foreach="message.getImageAttachments()" t-as="attachment">
|
||||
<t t-call="mail.AttachmentPreview"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="message.others.length > 0" class="o_attachments_list">
|
||||
<t t-foreach="message.others" t-as="attachment">
|
||||
<div t-if="message.hasNonImageAttachments()" class="o_attachments_list">
|
||||
<t t-foreach="message.getNonImageAttachments()" t-as="attachment">
|
||||
<t t-call="mail.Attachment"/>
|
||||
</t>
|
||||
</div>
|
||||
@@ -243,47 +256,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="options.messages_separator_position == message.id">
|
||||
<t t-if="options.messagesSeparatorPosition == message.getID()">
|
||||
<t t-call="mail.MessagesSeparator"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.Message} message
|
||||
-->
|
||||
<t t-name="mail.MessageModerationDecision">
|
||||
<i class="o_thread_icon ml4 o_thread_message_moderation text-success"
|
||||
t-att-data-message-id="message.id" data-decision="accept" title="Accept"><b>Accept |</b></i>
|
||||
t-att-data-message-id="message.getID()" data-decision="accept" title="Accept"><b>Accept |</b></i>
|
||||
<i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
|
||||
t-att-data-message-id="message.id" data-decision="reject" title="Remove message with explanation"><b>Reject |</b></i>
|
||||
t-att-data-message-id="message.getID()" data-decision="reject" title="Remove message with explanation"><b>Reject |</b></i>
|
||||
<i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
|
||||
t-att-data-message-id="message.id" data-decision="discard" title="Remove message without explanation"><b>Discard |</b></i>
|
||||
t-att-data-message-id="message.getID()" data-decision="discard" title="Remove message without explanation"><b>Discard |</b></i>
|
||||
<i class="o_thread_icon ml4 o_thread_message_moderation text-success"
|
||||
t-att-data-message-id="message.id" data-decision="allow" title="Add this email address to white list of people"><b>Always Allow |</b></i>
|
||||
t-att-data-message-id="message.getID()" data-decision="allow" title="Add this email address to white list of people"><b>Always Allow |</b></i>
|
||||
<i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
|
||||
t-att-data-message-id="message.id" data-decision="ban" title="Ban this email address"><b>Ban</b></i>
|
||||
t-att-data-message-id="message.getID()" data-decision="ban" title="Ban this email address"><b>Ban</b></i>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.MessageModerationFeedback">
|
||||
<i class="text-danger" title="Your message is pending moderation"><b>Pending moderation</b></i>
|
||||
</t>
|
||||
|
||||
|
||||
|
||||
|
||||
<t t-name="mail.MessagesSeparator">
|
||||
<div class="o_thread_new_messages_separator">
|
||||
<span class="o_thread_separator_label">New messages</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatThread.MessageTracking">
|
||||
<t t-name="mail.widget.Thread.MessageTracking">
|
||||
<ul class="o_mail_thread_message_tracking">
|
||||
<t t-foreach='message.tracking_value_ids' t-as='value'>
|
||||
<t t-foreach='message.getTrackingValues()' t-as='value'>
|
||||
<li>
|
||||
<t t-esc="value.changed_field"/>:
|
||||
<t t-if="value.old_value">
|
||||
<span> <t t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> </span>
|
||||
<span t-if="value.old_value != value.new_value" class="fa fa-long-arrow-right" role="img" aria-label="Changed" title="Changed"/>
|
||||
<span t-if="value.old_value !== value.new_value" class="fa fa-long-arrow-right" role="img" aria-label="Changed" title="Changed"/>
|
||||
</t>
|
||||
<span t-if="value.old_value != value.new_value">
|
||||
<span t-if="value.old_value !== value.new_value">
|
||||
<t t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/>
|
||||
</span>
|
||||
</li>
|
||||
@@ -291,7 +304,7 @@
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatComposer.Attachments">
|
||||
<t t-name="mail.composer.Attachments">
|
||||
<div t-if="attachments.length > 0" class="o_attachments o_attachments_list">
|
||||
<t t-foreach="attachments" t-as='attachment'>
|
||||
<t t-call="mail.Attachment">
|
||||
@@ -357,7 +370,7 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatThread.LoadMore">
|
||||
<t t-name="mail.widget.Thread.LoadMore">
|
||||
<div class="o_thread_show_more">
|
||||
<t t-if="options.loadMoreOnScroll">
|
||||
<span><i class="fa fa-spinner fa-spin" role="img" aria-label="Please wait" title="Please wait"/> Loading older messages... </span>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!--
|
||||
@param {mail.ThreadWindow} widget
|
||||
-->
|
||||
<t t-name="mail.ThreadWindow" t-extend="mail.AbstractThreadWindow">
|
||||
<t t-jquery=".o_thread_window_header" t-operation="after">
|
||||
<div t-if="!widget.hasThread()" class="o_thread_search_input">
|
||||
<span> To: </span>
|
||||
<input type="text" placeholder="User name"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} expandTitle text to display on mouse hover (title attribute)
|
||||
-->
|
||||
<t t-extend="mail.AbstractThreadWindowHeaderContent">
|
||||
<t t-jquery=".o_thread_window_buttons" t-operation="prepend">
|
||||
<a href="#" class="o_thread_window_expand fa fa-expand" t-att-title="expandTitle" t-att-aira-label="expandTitle"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
<t t-name="mail.KanbanActivity">
|
||||
<div class="o_kanban_inline_block dropdown o_kanban_selection o_mail_activity">
|
||||
<a role="button" class="dropdown-toggle o_activity_btn" data-toggle="dropdown">
|
||||
<span class="fa fa-clock-o fa-lg fa-fw" t-att-title="widget.selection[widget.activity_state]" role="img" t-att-aria-label="widget.selection[widget.activity_state]"/>
|
||||
<a class="dropdown-toggle o_activity_btn" data-toggle="dropdown">
|
||||
<span class="fa fa-clock-o fa-lg fa-fw" t-att-title="widget.selection[widget.activityState]" role="img" t-att-aria-label="widget.selection[widget.activity_state]"/>
|
||||
</a>
|
||||
<ul class="dropdown-menu o_activity" role="menu">
|
||||
</ul>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
odoo.define('mail.chat_window_test', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var ChatWindow = require('mail.ExtendedChatWindow');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var framework = require('web.framework');
|
||||
var testUtils = require('web.test_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var createBusService = mailTestUtils.createBusService;
|
||||
|
||||
QUnit.module('mail', {
|
||||
beforeEach: function () {
|
||||
this.services = [ChatManager, createBusService()];
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module('chat_window');
|
||||
|
||||
QUnit.test('close chat window using ESCAPE key', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
function createParent(params) {
|
||||
var widget = new Widget();
|
||||
|
||||
testUtils.addMockEnvironment(widget, params);
|
||||
return widget;
|
||||
}
|
||||
var messages = [{
|
||||
attachment_ids: [],
|
||||
author_id: ["1", "John Doe"],
|
||||
body: "A message",
|
||||
date: moment("2016-12-20 09:35:40"),
|
||||
displayed_author: "John Doe",
|
||||
id: 1,
|
||||
is_note: false,
|
||||
is_discussion: true,
|
||||
is_notification: false,
|
||||
is_starred: false,
|
||||
model: 'partner',
|
||||
res_id: 2
|
||||
}];
|
||||
var parent = createParent({
|
||||
data: {},
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
var chatWindow = new ChatWindow(parent, 1, "user", false, messages, {});
|
||||
chatWindow.appendTo($('#qunit-fixture'));
|
||||
|
||||
chatWindow.on('close_chat_session', null, function () {
|
||||
assert.ok(true, "chat window should trigger a close event");
|
||||
});
|
||||
chatWindow.thread.$el.trigger("click");
|
||||
assert.strictEqual(document.activeElement, chatWindow.$input[0],
|
||||
"input should be focused");
|
||||
var upKeyEvent = jQuery.Event( "keyup", {which: 27});
|
||||
chatWindow.$('.o_composer_input').trigger(upKeyEvent);
|
||||
assert.strictEqual(chatWindow.folded, false, "Closed chat Window");
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('open document viewer and close using ESCAPE key should reset focus to chat window', function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
function createParent(params) {
|
||||
var widget = new Widget();
|
||||
|
||||
testUtils.addMockEnvironment(widget, params);
|
||||
return widget;
|
||||
}
|
||||
var messages = [{
|
||||
attachment_ids: [{
|
||||
filename: 'image1.jpg',
|
||||
id:1,
|
||||
mimetype: 'image/jpeg',
|
||||
name: 'Test Image 1',
|
||||
url: '/web/content/1?download=true'
|
||||
}],
|
||||
author_id: ["1", "John Doe"],
|
||||
body: "A message",
|
||||
date: moment("2016-12-20 09:35:40"),
|
||||
displayed_author: "John Doe",
|
||||
id: 1,
|
||||
is_note: false,
|
||||
is_starred: false,
|
||||
model: 'partner',
|
||||
res_id: 2
|
||||
}];
|
||||
var parent = createParent({
|
||||
mockRPC: function (route, args) {
|
||||
if(_.str.contains(route, '/mail/attachment/preview/') ||
|
||||
_.str.contains(route, '/web/static/lib/pdfjs/web/viewer.html')){
|
||||
var canvas = document.createElement('canvas');
|
||||
return $.when(canvas.toDataURL());
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
var chatWindow = new ChatWindow(parent, 1, "user", false, messages.length, {});
|
||||
chatWindow.appendTo($('#qunit-fixture'));
|
||||
chatWindow.render(messages);
|
||||
|
||||
testUtils.intercept(chatWindow, 'get_messages', function(event) {
|
||||
event.stopPropagation();
|
||||
var requested_msgs = _.filter(messages, function (msg) {
|
||||
return _.contains(event.data.options.ids, msg.id);
|
||||
});
|
||||
event.data.callback($.when(requested_msgs));
|
||||
}, true);
|
||||
|
||||
testUtils.intercept(chatWindow, 'get_bus', function(event) {
|
||||
event.stopPropagation();
|
||||
event.data.callback(new Bus());
|
||||
}, true);
|
||||
|
||||
chatWindow.on('document_viewer_closed', null, function () {
|
||||
assert.ok(true, "chat window should trigger a close document viewer event");
|
||||
});
|
||||
assert.strictEqual(chatWindow.$('.o_thread_message .o_attachment').length, 1,
|
||||
"there should be three attachment on message");
|
||||
// click on first image attachement
|
||||
chatWindow.$('.o_thread_message .o_attachment .o_image_box .o_image_overlay').first().click();
|
||||
// check focus is on document viewer popup and then press escape to close it
|
||||
assert.strictEqual(document.activeElement, $('.o_modal_fullscreen')[0], "Modal popup should have focus");
|
||||
assert.strictEqual($('.o_modal_fullscreen img.o_viewer_img[data-src*="/web/image/1?unique=1"]').length, 1,
|
||||
"Modal popup should open with first image src");
|
||||
// trigger ESCAPE keyup on document viewer popup
|
||||
var upKeyEvent = jQuery.Event("keyup", {which: 27});
|
||||
$('.o_modal_fullscreen').trigger(upKeyEvent);
|
||||
assert.strictEqual(document.activeElement, chatWindow.$input[0],
|
||||
"input should be focused");
|
||||
var upKeyEvent = jQuery.Event( "keyup", {which: 27});
|
||||
chatWindow.$('.o_composer_input').trigger(upKeyEvent);
|
||||
assert.strictEqual(chatWindow.folded, false, "Closed chat Window");
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('chat window\'s input can still be focused when the UI is blocked', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
function createParent(params) {
|
||||
var widget = new Widget();
|
||||
|
||||
testUtils.addMockEnvironment(widget, params);
|
||||
return widget;
|
||||
}
|
||||
var parent = createParent({
|
||||
data: {},
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
var $dom = $('#qunit-fixture');
|
||||
|
||||
var chatWindow = new ChatWindow(parent, 1, "user", false, [], {});
|
||||
chatWindow.appendTo($dom);
|
||||
var $input = $('<input/>', {type: 'text'}).appendTo($dom);
|
||||
$input.focus().click();
|
||||
assert.strictEqual(document.activeElement, $input[0],
|
||||
"fake input should be focused");
|
||||
|
||||
framework.blockUI();
|
||||
chatWindow.$input.click(); // cannot force focus here otherwise the test
|
||||
// makes no sense, this test is just about
|
||||
// making sure that the code which forces the
|
||||
// focus on click is not removed
|
||||
assert.strictEqual(document.activeElement, chatWindow.$input[0],
|
||||
"chat window's input should now be focused");
|
||||
|
||||
framework.unblockUI();
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('emoji popover should open correctly in chat windows', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
function createParent(params) {
|
||||
var widget = new Widget();
|
||||
|
||||
widget.on('get_emojis', widget, function (ev) {
|
||||
ev.data.callback([]);
|
||||
});
|
||||
|
||||
testUtils.addMockEnvironment(widget, params);
|
||||
return widget;
|
||||
}
|
||||
var parent = createParent({
|
||||
data: {},
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
var $dom = $('#qunit-fixture');
|
||||
|
||||
var chatWindow = new ChatWindow(parent, 1, "user", false, [], {});
|
||||
chatWindow.appendTo($dom);
|
||||
|
||||
var $emojiButton = chatWindow.$('.o_composer_button_emoji');
|
||||
$emojiButton.trigger('focusin').focus().click();
|
||||
var $popover = chatWindow.$('.o_mail_emoji_container');
|
||||
|
||||
var done = assert.async();
|
||||
// Async is needed as the popover focusout hiding is deferred
|
||||
setTimeout(function () {
|
||||
assert.ok($popover.is(':visible'), "emoji popover should have stayed opened");
|
||||
parent.destroy();
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,13 @@
|
||||
odoo.define('mail.chatter_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Composers = require('mail.composer');
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var Bus = require('web.Bus');
|
||||
var concurrency = require('web.concurrency');
|
||||
var FormView = require('web.FormView');
|
||||
var KanbanView = require('web.KanbanView');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
var BasicComposer = Composers.BasicComposer;
|
||||
|
||||
var createBusService = mailTestUtils.createBusService;
|
||||
var createAsyncView = testUtils.createAsyncView;
|
||||
var createView = testUtils.createView;
|
||||
|
||||
@@ -21,7 +15,13 @@ QUnit.module('mail', {}, function () {
|
||||
|
||||
QUnit.module('Chatter', {
|
||||
beforeEach: function () {
|
||||
this.services = [ChatManager, createBusService()];
|
||||
// patch _.debounce and _.throttle to be fast and synchronous
|
||||
this.underscoreDebounce = _.debounce;
|
||||
this.underscoreThrottle = _.throttle;
|
||||
_.debounce = _.identity;
|
||||
_.throttle = _.identity;
|
||||
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
this.data = {
|
||||
partner: {
|
||||
fields: {
|
||||
@@ -82,8 +82,60 @@ QUnit.module('Chatter', {
|
||||
{ id: 1, name: "Type 1" },
|
||||
{ id: 2, name: "Type 2" },
|
||||
],
|
||||
}
|
||||
},
|
||||
'mail.message': {
|
||||
fields: {
|
||||
attachment_ids: {
|
||||
string: "Attachments",
|
||||
type: 'many2many',
|
||||
relation: 'ir.attachment',
|
||||
default: [],
|
||||
},
|
||||
author_id: {
|
||||
string: "Author",
|
||||
relation: 'res.partner',
|
||||
},
|
||||
body: {
|
||||
string: "Contents",
|
||||
type: 'html',
|
||||
},
|
||||
date: {
|
||||
string: "Date",
|
||||
type: 'datetime',
|
||||
},
|
||||
is_note: {
|
||||
string: "Note",
|
||||
type: 'boolean',
|
||||
},
|
||||
is_discussion: {
|
||||
string: "Discussion",
|
||||
type: 'boolean',
|
||||
},
|
||||
is_notification: {
|
||||
string: "Notification",
|
||||
type: 'boolean',
|
||||
},
|
||||
is_starred: {
|
||||
string: "Starred",
|
||||
type: 'boolean',
|
||||
},
|
||||
model: {
|
||||
string: "Related Document Model",
|
||||
type: 'char',
|
||||
},
|
||||
res_id: {
|
||||
string: "Related Document ID",
|
||||
type: 'integer',
|
||||
}
|
||||
},
|
||||
records: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
afterEach: function () {
|
||||
// unpatch _.debounce and _.throttle
|
||||
_.debounce = this.underscoreDebounce;
|
||||
_.throttle = this.underscoreThrottle;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,7 +144,6 @@ QUnit.test('basic rendering', function (assert) {
|
||||
|
||||
var count = 0;
|
||||
var unwanted_read_count = 0;
|
||||
// var msgRpc = 0;
|
||||
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
@@ -134,10 +185,8 @@ QUnit.test('basic rendering', function (assert) {
|
||||
assert.ok(form.$('.o_mail_thread').length, "there should be a mail thread");
|
||||
assert.ok(!form.$('.o_chatter_topbar .o_chatter_button_log_note').length,
|
||||
"log note button should not be available");
|
||||
// assert.strictEqual(msgRpc, 1, "should have fetched messages once");
|
||||
|
||||
form.$buttons.find('.o_form_button_edit').click();
|
||||
// assert.strictEqual(msgRpc, 1, "should still have fetched messages only once");
|
||||
assert.strictEqual(count, 0, "should have done no read_followers rpc as there are no followers");
|
||||
assert.strictEqual(unwanted_read_count, 0, "followers should only be fetched with read_followers route");
|
||||
form.destroy();
|
||||
@@ -200,7 +249,7 @@ QUnit.test('chatter in create mode', function (assert) {
|
||||
|
||||
// check if chatter buttons still work
|
||||
form.$('.o_chatter_button_new_message').click();
|
||||
assert.strictEqual(form.$('.o_chat_composer:visible').length, 1,
|
||||
assert.strictEqual(form.$('.o_thread_composer:visible').length, 1,
|
||||
"chatter should be opened");
|
||||
|
||||
form.destroy();
|
||||
@@ -253,7 +302,7 @@ QUnit.test('chatter rendering inside the sheet', function (assert) {
|
||||
|
||||
// check if chatter buttons still work
|
||||
form.$('.o_chatter_button_new_message').click();
|
||||
assert.strictEqual(form.$('.o_chat_composer:visible').length, 1,
|
||||
assert.strictEqual(form.$('.o_thread_composer:visible').length, 1,
|
||||
"chatter should be opened");
|
||||
|
||||
form.destroy();
|
||||
@@ -370,20 +419,13 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
var done = assert.async();
|
||||
assert.expect(27);
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
|
||||
// Remove the mention throttle to speed up the test
|
||||
var mentionThrottle = BasicComposer.prototype.MENTION_THROTTLE;
|
||||
BasicComposer.prototype.MENTION_THROTTLE = 1;
|
||||
var bus = this.services[1].prototype.bus;
|
||||
|
||||
this.data.partner.records[0].message_ids = [1];
|
||||
var messages = [{
|
||||
attachment_ids: [],
|
||||
this.data['mail.message'].records = [{
|
||||
author_id: ["1", "John Doe"],
|
||||
body: "A message",
|
||||
date: "2016-12-20 09:35:40",
|
||||
displayed_author: "John Doe",
|
||||
id: 1,
|
||||
is_note: false,
|
||||
is_discussion: true,
|
||||
@@ -392,12 +434,13 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
}];
|
||||
|
||||
var getSuggestionsDef = $.Deferred();
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
arch: '<form string="Partners">' +
|
||||
'<sheet>' +
|
||||
'<field name="foo"/>' +
|
||||
@@ -408,12 +451,6 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
'</form>',
|
||||
res_id: 2,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_format') {
|
||||
var requested_msgs = _.filter(messages, function (msg) {
|
||||
return _.contains(args.args[0], msg.id);
|
||||
});
|
||||
return $.when(requested_msgs);
|
||||
}
|
||||
if (args.method === 'message_get_suggested_recipients') {
|
||||
return $.when({2: []});
|
||||
}
|
||||
@@ -422,14 +459,16 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
return $.when([{email: "test@odoo.com", id: 1, name: "Test User"}]);
|
||||
}
|
||||
if (args.method === 'message_post') {
|
||||
var msg_id = messages[messages.length-1].id + 1;
|
||||
messages.push({
|
||||
var lastMessageData = _.max(this.data['mail.message'].records, function (messageData) {
|
||||
return messageData.id;
|
||||
});
|
||||
var messageID = lastMessageData.id + 1;
|
||||
this.data['mail.message'].records.push({
|
||||
attachment_ids: args.kwargs.attachment_ids,
|
||||
author_id: ["42", "Me"],
|
||||
body: args.kwargs.body,
|
||||
date: "2016-12-20 10:35:40",
|
||||
displayed_author: "Me",
|
||||
id: msg_id,
|
||||
id: messageID,
|
||||
is_note: args.kwargs.subtype === 'mail.mt_note',
|
||||
is_discussion: args.kwargs.subtype === 'mail.mt_comment',
|
||||
is_notification: false,
|
||||
@@ -437,18 +476,21 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
});
|
||||
return $.when(msg_id);
|
||||
return $.when(messageID);
|
||||
}
|
||||
if (args.method === 'toggle_message_starred') {
|
||||
assert.ok(_.contains(args.args[0], 2),
|
||||
"toggle_star_status should have been triggered for message 2 (twice)");
|
||||
var msg = _.findWhere(messages, {id: args.args[0][0]});
|
||||
msg.is_starred = !msg.is_starred;
|
||||
// simulate notification received by chat_manager from longpoll
|
||||
var messageData = _.findWhere(
|
||||
this.data['mail.message'].records,
|
||||
{ id: args.args[0][0] }
|
||||
);
|
||||
messageData.is_starred = !messageData.is_starred;
|
||||
// simulate notification received by mail_service from longpoll
|
||||
var data = {
|
||||
info: false,
|
||||
message_ids: [msg.id],
|
||||
starred: msg.is_starred,
|
||||
message_ids: [messageData.id],
|
||||
starred: messageData.is_starred,
|
||||
type: 'toggle_star',
|
||||
};
|
||||
var notification = [[false, 'res.partner'], data];
|
||||
@@ -472,10 +514,10 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
|
||||
// send a message
|
||||
form.$('.o_chatter_button_new_message').click();
|
||||
assert.ok(!$('.oe_chatter .o_chat_composer').hasClass('o_hidden'), "chatter should be opened");
|
||||
assert.ok(!$('.oe_chatter .o_thread_composer').hasClass('o_hidden'), "chatter should be opened");
|
||||
form.$('.oe_chatter .o_composer_text_field:first()').val("My first message");
|
||||
form.$('.oe_chatter .o_composer_button_send').click();
|
||||
assert.ok($('.oe_chatter .o_chat_composer').hasClass('o_hidden'), "chatter should be closed");
|
||||
assert.ok($('.oe_chatter .o_thread_composer').hasClass('o_hidden'), "chatter should be closed");
|
||||
assert.strictEqual(form.$('.o_thread_message').length, 2, "thread should contain two messages");
|
||||
assert.ok(form.$('.o_thread_message:first().o_mail_discussion').length,
|
||||
"the last message should be a discussion");
|
||||
@@ -486,10 +528,10 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
|
||||
// log a note
|
||||
form.$('.o_chatter_button_log_note').click();
|
||||
assert.ok(!$('.oe_chatter .o_chat_composer').hasClass('o_hidden'), "chatter should be opened");
|
||||
assert.ok(!$('.oe_chatter .o_thread_composer').hasClass('o_hidden'), "chatter should be opened");
|
||||
form.$('.oe_chatter .o_composer_text_field:first()').val("My first note");
|
||||
form.$('.oe_chatter .o_composer_button_send').click();
|
||||
assert.ok($('.oe_chatter .o_chat_composer').hasClass('o_hidden'), "chatter should be closed");
|
||||
assert.ok($('.oe_chatter .o_thread_composer').hasClass('o_hidden'), "chatter should be closed");
|
||||
assert.strictEqual(form.$('.o_thread_message').length, 3, "thread should contain three messages");
|
||||
assert.ok(!form.$('.o_thread_message:first().o_mail_discussion').length,
|
||||
"the last message should not be a discussion");
|
||||
@@ -534,8 +576,6 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
assert.strictEqual(form.$('.o_mention_proposition .o_mention_info').text(), '(test@odoo.com)',
|
||||
"suggestion should be displayed correctly");
|
||||
|
||||
BasicComposer.prototype.MENTION_THROTTLE = mentionThrottle;
|
||||
|
||||
//cleanup
|
||||
form.destroy();
|
||||
done();
|
||||
@@ -545,7 +585,6 @@ QUnit.test('chatter: post, receive and star messages', function (assert) {
|
||||
QUnit.test('chatter: post a message and switch in edit mode', function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var messages = [];
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
@@ -562,22 +601,15 @@ QUnit.test('chatter: post a message and switch in edit mode', function (assert)
|
||||
res_id: 2,
|
||||
session: {},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_format') {
|
||||
var requested_msgs = _.filter(messages, function (msg) {
|
||||
return _.contains(args.args[0], msg.id);
|
||||
});
|
||||
return $.when(requested_msgs);
|
||||
}
|
||||
if (args.method === 'message_get_suggested_recipients') {
|
||||
return $.when({2: []});
|
||||
}
|
||||
if (args.method === 'message_post') {
|
||||
messages.push({
|
||||
this.data['mail.message'].records.push({
|
||||
attachment_ids: args.kwargs.attachment_ids,
|
||||
author_id: ["42", "Me"],
|
||||
body: args.kwargs.body,
|
||||
date: "2016-12-20 10:35:40",
|
||||
displayed_author: "Me",
|
||||
id: 42,
|
||||
is_note: args.kwargs.subtype === 'mail.mt_note',
|
||||
is_discussion: args.kwargs.subtype === 'mail.mt_comment',
|
||||
@@ -747,7 +779,7 @@ QUnit.test('chatter: discard changes on message post with post_refresh "recipien
|
||||
var getSuggestionsDef = $.Deferred();
|
||||
|
||||
var messages = [];
|
||||
var bus = new Bus();
|
||||
var bus = this.services[1].prototype.bus;
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
@@ -893,7 +925,7 @@ QUnit.test('chatter: discard changes on opening full-composer', function (assert
|
||||
QUnit.test('chatter: Attachment viewer', function (assert) {
|
||||
assert.expect(6);
|
||||
this.data.partner.records[0].message_ids = [1];
|
||||
var messages = [{
|
||||
this.data['mail.message'].records = [{
|
||||
attachment_ids: [{
|
||||
filename: 'image1.jpg',
|
||||
id:1,
|
||||
@@ -922,7 +954,6 @@ QUnit.test('chatter: Attachment viewer', function (assert) {
|
||||
author_id: ["1", "John Doe"],
|
||||
body: "Attachement viewer test",
|
||||
date: "2016-12-20 09:35:40",
|
||||
displayed_author: "John Doe",
|
||||
id: 1,
|
||||
is_note: false,
|
||||
is_discussion: true,
|
||||
@@ -946,12 +977,6 @@ QUnit.test('chatter: Attachment viewer', function (assert) {
|
||||
'</form>',
|
||||
res_id: 2,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_format') {
|
||||
var requested_msgs = _.filter(messages, function (msg) {
|
||||
return _.contains(args.args[0], msg.id);
|
||||
});
|
||||
return $.when(requested_msgs);
|
||||
}
|
||||
if (_.str.contains(route, '/mail/attachment/preview/') ||
|
||||
_.str.contains(route, '/web/static/lib/pdfjs/web/viewer.html')){
|
||||
var canvas = document.createElement('canvas');
|
||||
@@ -1137,9 +1162,6 @@ QUnit.test('form activity widget: schedule next activity', function (assert) {
|
||||
'</form>',
|
||||
res_id: 2,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_format') {
|
||||
return $.when([]);
|
||||
}
|
||||
if (route === '/web/dataset/call_kw/mail.activity/action_feedback') {
|
||||
assert.ok(_.isEqual(args.args[0], [1]), "should call 'action_feedback' for id 1");
|
||||
assert.strictEqual(args.kwargs.feedback, 'everything is ok',
|
||||
@@ -1234,7 +1256,6 @@ QUnit.test('form activity widget: mark as done and remove', function (assert) {
|
||||
var self = this;
|
||||
|
||||
var nbReads = 0;
|
||||
var messages = [];
|
||||
this.data.partner.records[0].activity_ids = [1, 2];
|
||||
this.data.partner.records[0].activity_state = 'today';
|
||||
this.data['mail.activity'].records = [{
|
||||
@@ -1269,12 +1290,6 @@ QUnit.test('form activity widget: mark as done and remove', function (assert) {
|
||||
'</form>',
|
||||
res_id: 2,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_format') {
|
||||
var requested_msgs = _.filter(messages, function (msg) {
|
||||
return _.contains(args.args[0], msg.id);
|
||||
});
|
||||
return $.when(requested_msgs);
|
||||
}
|
||||
if (route === '/web/dataset/call_kw/mail.activity/unlink') {
|
||||
assert.ok(_.isEqual(args.args[0], [1]), "should call 'unlink' for id 1");
|
||||
} else if (route === '/web/dataset/call_kw/mail.activity/action_feedback') {
|
||||
@@ -1283,15 +1298,16 @@ QUnit.test('form activity widget: mark as done and remove', function (assert) {
|
||||
"the feedback should be sent correctly");
|
||||
// should generate a message and unlink the activity
|
||||
self.data.partner.records[0].message_ids = [1];
|
||||
messages.push({
|
||||
this.data['mail.message'].records.push({
|
||||
attachment_ids: [],
|
||||
author_id: ["1", "John Doe"],
|
||||
body: "The activity has been done",
|
||||
date: "2016-12-20 09:35:40",
|
||||
displayed_author: "John Doe",
|
||||
id: 1,
|
||||
is_note: true,
|
||||
is_discussion: false,
|
||||
model: 'partner',
|
||||
res_id: 2,
|
||||
});
|
||||
route = '/web/dataset/call_kw/mail.activity/unlink';
|
||||
args.method = 'unlink';
|
||||
@@ -1576,7 +1592,7 @@ QUnit.test('does not render and crash when destroyed before chat system is ready
|
||||
});
|
||||
|
||||
form.destroy();
|
||||
// here, the chatManager system is ready, and the chatter can try to render
|
||||
// here, the chat service system is ready, and the chatter can try to render
|
||||
// itself. We simply make sure here that no crashes occur (since the form
|
||||
// view is destroyed, all rpcs will be dropped, and many other mechanisms
|
||||
// relying on events will not work, such as the chat bus)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
odoo.define('mail.discuss_mobile_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var createBusService = mailTestUtils.createBusService;
|
||||
var createDiscuss = mailTestUtils.createDiscuss;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
|
||||
QUnit.module('Discuss client action in mobile', {
|
||||
QUnit.module('Discuss in mobile', {
|
||||
beforeEach: function () {
|
||||
this.services = [ChatManager, createBusService()];
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
this.data = {
|
||||
'mail.message': {
|
||||
fields: {},
|
||||
@@ -22,7 +20,7 @@ QUnit.module('Discuss client action in mobile', {
|
||||
|
||||
QUnit.test('mobile basic rendering', function (assert) {
|
||||
// This is a very basic first test for the client action. However, with
|
||||
// the chat_manager, it is hard to override RPCs (for instance, the
|
||||
// the chat_service, it is hard to override RPCs (for instance, the
|
||||
// /mail/client_action route is always called when the test suite is
|
||||
// launched), and we must wait for this RPC to be done before starting to
|
||||
// test the interface. This should be refactored to facilitate the testing.
|
||||
@@ -37,25 +35,25 @@ QUnit.test('mobile basic rendering', function (assert) {
|
||||
services: this.services,
|
||||
}).then(function (discuss) {
|
||||
// test basic rendering in mobile
|
||||
assert.strictEqual(discuss.$('.o_mail_chat_content .o_mail_no_content').length, 1,
|
||||
assert.strictEqual(discuss.$('.o_mail_discuss_content .o_mail_no_content').length, 1,
|
||||
"should display the no content message");
|
||||
assert.strictEqual(discuss.$('.o_mail_mobile_tabs').length, 1,
|
||||
"should have rendered the tabs");
|
||||
assert.ok(discuss.$('.o_mail_mobile_tab[data-type=channel_inbox]').hasClass('active'),
|
||||
assert.ok(discuss.$('.o_mail_mobile_tab[data-type=mailbox_inbox]').hasClass('active'),
|
||||
"should be in inbox tab");
|
||||
assert.strictEqual(discuss.$('.o_mail_chat_mobile_inbox_buttons:visible').length, 1,
|
||||
assert.strictEqual(discuss.$('.o_mail_discuss_mobile_mailboxes_buttons:visible').length, 1,
|
||||
"inbox/starred buttons should be visible");
|
||||
assert.ok(discuss.$('.o_mail_chat_mobile_inbox_buttons .o_channel_inbox_item[data-type=channel_inbox]').hasClass('btn-primary'),
|
||||
assert.ok(discuss.$('.o_mail_discuss_mobile_mailboxes_buttons .o_mailbox_inbox_item[data-type=mailbox_inbox]').hasClass('btn-primary'),
|
||||
"should be in inbox");
|
||||
|
||||
// move to DMs tab
|
||||
discuss.$('.o_mail_mobile_tab[data-type=dm]').click();
|
||||
assert.ok(discuss.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'),
|
||||
"should be in DMs tab");
|
||||
assert.strictEqual(discuss.$('.o_mail_chat_content .o_mail_no_content').length, 0,
|
||||
assert.strictEqual(discuss.$('.o_mail_discuss_content .o_mail_no_content').length, 0,
|
||||
"should display the no content message");
|
||||
$('.o_mail_chat_button_dm').click(); // click to add a channel
|
||||
assert.strictEqual(discuss.$('.o_mail_add_channel input:visible').length, 1,
|
||||
$('.o_mail_discuss_button_dm').click(); // click to add a channel
|
||||
assert.strictEqual(discuss.$('.o_mail_add_thread input:visible').length, 1,
|
||||
"should display the input to add a channel");
|
||||
|
||||
discuss.destroy();
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
odoo.define('mail.discuss_moderation_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var Bus = require('web.Bus');
|
||||
|
||||
var createBusService = mailTestUtils.createBusService;
|
||||
var createDiscuss = mailTestUtils.createDiscuss;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
|
||||
QUnit.module('Discuss moderation', {
|
||||
beforeEach: function () {
|
||||
// patch _.debounce and _.throttle to be fast and synchronous
|
||||
@@ -71,7 +66,7 @@ QUnit.module('Discuss moderation', {
|
||||
},
|
||||
},
|
||||
};
|
||||
this.services = [ChatManager, createBusService()];
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
},
|
||||
afterEach: function () {
|
||||
// unpatch _.debounce and _.throttle
|
||||
@@ -104,8 +99,8 @@ QUnit.test('moderator: display moderation box', function (assert) {
|
||||
services: this.services,
|
||||
})
|
||||
.then(function (discuss) {
|
||||
var moderationBoxSelector = '.o_mail_chat_channel_item' +
|
||||
'[data-channel-id="channel_moderation"]';
|
||||
var moderationBoxSelector = '.o_mail_discuss_item' +
|
||||
'[data-thread-id="mailbox_moderation"]';
|
||||
assert.strictEqual(discuss.$(moderationBoxSelector).length, 1,
|
||||
"there should be a moderation mailbox");
|
||||
discuss.destroy();
|
||||
@@ -149,8 +144,8 @@ QUnit.test('moderator: moderated channel with pending moderation message', funct
|
||||
})
|
||||
.then(function (discuss) {
|
||||
var $moderationBox = discuss.$(
|
||||
'.o_mail_chat_channel_item' +
|
||||
'[data-channel-id="channel_moderation"]');
|
||||
'.o_mail_discuss_item' +
|
||||
'[data-thread-id="mailbox_moderation"]');
|
||||
var $mailboxCounter = $moderationBox.find('.o_mail_sidebar_needaction.badge');
|
||||
assert.strictEqual($mailboxCounter.length, 1,
|
||||
"there should be a counter next to the moderation mailbox in the sidebar");
|
||||
@@ -186,16 +181,16 @@ QUnit.test('moderator: moderated channel with pending moderation message', funct
|
||||
"there should be a contextual moderation decision to ban the user of the message");
|
||||
|
||||
// check select all (enabled) / unselect all (disabled) buttons
|
||||
assert.strictEqual($('.o_mail_chat_button_select_all').length, 1,
|
||||
assert.strictEqual($('.o_mail_discuss_button_select_all').length, 1,
|
||||
"there should be a 'Select All' button in the control panel");
|
||||
assert.notOk($('.o_mail_chat_button_select_all').hasClass('disabled'),
|
||||
assert.notOk($('.o_mail_discuss_button_select_all').hasClass('disabled'),
|
||||
"the 'Select All' button should not be disabled");
|
||||
assert.strictEqual($('.o_mail_chat_button_unselect_all').length, 1,
|
||||
assert.strictEqual($('.o_mail_discuss_button_unselect_all').length, 1,
|
||||
"there should be a 'Unselect All' button in the control panel");
|
||||
assert.ok($('.o_mail_chat_button_unselect_all').hasClass('disabled'),
|
||||
assert.ok($('.o_mail_discuss_button_unselect_all').hasClass('disabled'),
|
||||
"the 'Unselect All' button should be disabled");
|
||||
// check moderate all buttons (invisible)
|
||||
var moderateAllSelector = '.o_mail_chat_button_moderate_all';
|
||||
var moderateAllSelector = '.o_mail_discuss_button_moderate_all';
|
||||
assert.strictEqual($(moderateAllSelector).length, 3,
|
||||
"there should be 3 buttons to moderate selected messages in the control panel");
|
||||
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').length, 1,
|
||||
@@ -216,9 +211,9 @@ QUnit.test('moderator: moderated channel with pending moderation message', funct
|
||||
assert.ok($message.find('.moderation_checkbox').prop('checked'),
|
||||
"the moderation checkbox should become checked after click");
|
||||
// check select all (disabled) / unselect all buttons (enabled)
|
||||
assert.ok($('.o_mail_chat_button_select_all').hasClass('disabled'),
|
||||
assert.ok($('.o_mail_discuss_button_select_all').hasClass('disabled'),
|
||||
"the 'Select All' button should be disabled");
|
||||
assert.notOk($('.o_mail_chat_button_unselect_all').hasClass('disabled'),
|
||||
assert.notOk($('.o_mail_discuss_button_unselect_all').hasClass('disabled'),
|
||||
"the 'Unselect All' button should not be disabled");
|
||||
// check moderate all buttons updated (visible)
|
||||
assert.strictEqual($(moderateAllSelector + '[data-decision="accept"]').attr('style'),
|
||||
@@ -229,7 +224,7 @@ QUnit.test('moderator: moderated channel with pending moderation message', funct
|
||||
'display: inline-block;', 'the moderate button "Discard" should become visible');
|
||||
|
||||
// 2. go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
$message = discuss.$('.o_thread_message');
|
||||
// check correct message
|
||||
assert.strictEqual($message.length, 1,
|
||||
@@ -249,9 +244,6 @@ QUnit.test('moderator: accept pending moderation message', function (assert) {
|
||||
assert.expect(12);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
@@ -281,7 +273,7 @@ QUnit.test('moderator: accept pending moderation message', function (assert) {
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'moderate') {
|
||||
assert.step('moderate');
|
||||
@@ -290,21 +282,6 @@ QUnit.test('moderator: accept pending moderation message', function (assert) {
|
||||
assert.strictEqual(messageIDs.length, 1, "should moderate one message");
|
||||
assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
|
||||
assert.strictEqual(decision, 'accept', "should accept the message");
|
||||
|
||||
// simulate notification back (new accepted message in channel)
|
||||
var dbName = undefined; // useless for tests
|
||||
var messageData = {
|
||||
author_id: [2, "Someone"],
|
||||
body: "<p>test</p>",
|
||||
channel_ids: [],
|
||||
id: 100,
|
||||
model: 'mail.channel',
|
||||
moderation_status: 'accepted'
|
||||
};
|
||||
var metaData = [dbName, 'mail.channel'];
|
||||
var notification = [metaData, messageData];
|
||||
bus.trigger('notification', [notification]);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
@@ -312,8 +289,8 @@ QUnit.test('moderator: accept pending moderation message', function (assert) {
|
||||
.then(function (discuss) {
|
||||
// 1. go to moderation box
|
||||
var $moderationBox = discuss.$(
|
||||
'.o_mail_chat_channel_item' +
|
||||
'[data-channel-id="channel_moderation"]');
|
||||
'.o_mail_discuss_item' +
|
||||
'[data-thread-id="mailbox_moderation"]');
|
||||
$moderationBox.click();
|
||||
// check there is a message to moderate
|
||||
var $message = discuss.$('.o_thread_message');
|
||||
@@ -333,7 +310,7 @@ QUnit.test('moderator: accept pending moderation message', function (assert) {
|
||||
"should now have no message displayed in moderation box");
|
||||
|
||||
// 2. go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
$message = discuss.$('.o_thread_message');
|
||||
// check message is there and has no moderate checkbox
|
||||
assert.strictEqual($message.length, 1,
|
||||
@@ -352,9 +329,6 @@ QUnit.test('moderator: reject pending moderation message (reject with explanatio
|
||||
assert.expect(21);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
@@ -384,7 +358,7 @@ QUnit.test('moderator: reject pending moderation message (reject with explanatio
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'moderate') {
|
||||
assert.step('moderate');
|
||||
@@ -398,17 +372,6 @@ QUnit.test('moderator: reject pending moderation message (reject with explanatio
|
||||
"should have correct reject message title");
|
||||
assert.strictEqual(kwargs.comment, "Your message was rejected by moderator.",
|
||||
"should have correct reject message body / comment");
|
||||
|
||||
// simulate notification back (deletion of rejected message in channel)
|
||||
var dbName = undefined; // useless for tests
|
||||
var notifData = {
|
||||
message_ids: messageIDs,
|
||||
type: "deletion",
|
||||
};
|
||||
var metaData = [dbName, 'res.partner'];
|
||||
var notification = [metaData, notifData];
|
||||
bus.trigger('notification', [notification]);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
@@ -416,8 +379,8 @@ QUnit.test('moderator: reject pending moderation message (reject with explanatio
|
||||
.then(function (discuss) {
|
||||
// 1. go to moderation box
|
||||
var $moderationBox = discuss.$(
|
||||
'.o_mail_chat_channel_item' +
|
||||
'[data-channel-id="channel_moderation"]');
|
||||
'.o_mail_discuss_item' +
|
||||
'[data-thread-id="mailbox_moderation"]');
|
||||
$moderationBox.click();
|
||||
// check there is a message to moderate
|
||||
var $message = discuss.$('.o_thread_message');
|
||||
@@ -456,13 +419,13 @@ QUnit.test('moderator: reject pending moderation message (reject with explanatio
|
||||
$('.modal-footer button').click();
|
||||
assert.verifySteps(['moderate']);
|
||||
|
||||
// stop the fadeout animation and immediately remove the element
|
||||
// // stop the fadeout animation and immediately remove the element
|
||||
discuss.$('.o_thread_message').stop(false, true);
|
||||
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
|
||||
"should now have no message displayed in moderation box");
|
||||
|
||||
// 2. go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
|
||||
"should now have no message displayed in moderated channel");
|
||||
|
||||
@@ -475,9 +438,6 @@ QUnit.test('moderator: discard pending moderation message (reject without explan
|
||||
assert.expect(15);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
@@ -507,7 +467,7 @@ QUnit.test('moderator: discard pending moderation message (reject without explan
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'moderate') {
|
||||
assert.step('moderate');
|
||||
@@ -516,17 +476,6 @@ QUnit.test('moderator: discard pending moderation message (reject without explan
|
||||
assert.strictEqual(messageIDs.length, 1, "should moderate one message");
|
||||
assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
|
||||
assert.strictEqual(decision, 'discard', "should discard the message");
|
||||
|
||||
// simulate notification back (deletion of rejected message in channel)
|
||||
var dbName = undefined; // useless for tests
|
||||
var notifData = {
|
||||
message_ids: messageIDs,
|
||||
type: 'deletion',
|
||||
};
|
||||
var metaData = [dbName, 'res.partner'];
|
||||
var notification = [metaData, notifData];
|
||||
bus.trigger('notification', [notification]);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
@@ -534,8 +483,8 @@ QUnit.test('moderator: discard pending moderation message (reject without explan
|
||||
.then(function (discuss) {
|
||||
// 1. go to moderation box
|
||||
var $moderationBox = discuss.$(
|
||||
'.o_mail_chat_channel_item' +
|
||||
'[data-channel-id="channel_moderation"]');
|
||||
'.o_mail_discuss_item' +
|
||||
'[data-thread-id="mailbox_moderation"]');
|
||||
$moderationBox.click();
|
||||
// check there is a message to moderate
|
||||
var $message = discuss.$('.o_thread_message');
|
||||
@@ -571,7 +520,7 @@ QUnit.test('moderator: discard pending moderation message (reject without explan
|
||||
"should now have no message displayed in moderation box");
|
||||
|
||||
// 2. go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
assert.strictEqual(discuss.$('.o_thread_message').length, 0,
|
||||
"should now have no message displayed in moderated channel");
|
||||
|
||||
@@ -584,8 +533,7 @@ QUnit.test('author: send message in moderated channel', function (assert) {
|
||||
assert.expect(4);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
var bus = this.services[1].prototype.bus;
|
||||
|
||||
var messagePostDef = $.Deferred();
|
||||
|
||||
@@ -605,7 +553,7 @@ QUnit.test('author: send message in moderated channel', function (assert) {
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_post') {
|
||||
var message = {
|
||||
@@ -637,7 +585,7 @@ QUnit.test('author: send message in moderated channel', function (assert) {
|
||||
.then(function (discuss) {
|
||||
|
||||
// go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
// post a message
|
||||
discuss.$('.o_composer_input textarea').first().val("some text");
|
||||
discuss.$('.o_composer_send button').click();
|
||||
@@ -665,8 +613,7 @@ QUnit.test('author: sent message accepted in moderated channel', function (asser
|
||||
assert.expect(8);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
var bus = this.services[1].prototype.bus;
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
@@ -695,7 +642,7 @@ QUnit.test('author: sent message accepted in moderated channel', function (asser
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
session: {
|
||||
partner_id: 2,
|
||||
},
|
||||
@@ -703,7 +650,7 @@ QUnit.test('author: sent message accepted in moderated channel', function (asser
|
||||
.then(function (discuss) {
|
||||
|
||||
// go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
// check message is pending
|
||||
var $message = discuss.$('.o_thread_message');
|
||||
assert.strictEqual($message.length, 1,
|
||||
@@ -729,7 +676,7 @@ QUnit.test('author: sent message accepted in moderated channel', function (asser
|
||||
var notification = [metaData, messageData];
|
||||
bus.trigger('notification', [notification]);
|
||||
|
||||
// // check message is accepted
|
||||
// check message is accepted
|
||||
$message = discuss.$('.o_thread_message');
|
||||
assert.strictEqual($message.length, 1,
|
||||
"should still have a message in the thread");
|
||||
@@ -749,8 +696,7 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser
|
||||
assert.expect(5);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
var bus = this.services[1].prototype.bus;
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
@@ -779,7 +725,7 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
session: {
|
||||
partner_id: 2,
|
||||
},
|
||||
@@ -787,7 +733,7 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser
|
||||
.then(function (discuss) {
|
||||
|
||||
// go to channel 'general'
|
||||
discuss.$('.o_mail_chat_channel_item[data-channel-id="1"]').click();
|
||||
discuss.$('.o_mail_discuss_item[data-thread-id="1"]').click();
|
||||
// check message is pending
|
||||
var $message = discuss.$('.o_thread_message');
|
||||
assert.strictEqual($message.length, 1,
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
odoo.define('mail.discuss_test', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Discuss = require('mail.chat_discuss');
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var Discuss = require('mail.Discuss');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var Bus = require('web.Bus');
|
||||
var concurrency = require('web.concurrency');
|
||||
var SearchView = require('web.SearchView');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
var createBusService = mailTestUtils.createBusService;
|
||||
var createDiscuss = mailTestUtils.createDiscuss;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
|
||||
QUnit.module('Discuss client action', {
|
||||
QUnit.module('Discuss', {
|
||||
beforeEach: function () {
|
||||
// patch _.debounce and _.throttle to be fast and synchronous
|
||||
this.underscoreDebounce = _.debounce;
|
||||
@@ -62,7 +59,7 @@ QUnit.module('Discuss client action', {
|
||||
},
|
||||
},
|
||||
};
|
||||
this.services = [ChatManager, createBusService()];
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
},
|
||||
afterEach: function () {
|
||||
// unpatch _.debounce and _.throttle
|
||||
@@ -84,22 +81,22 @@ QUnit.test('basic rendering', function (assert) {
|
||||
})
|
||||
.then(function (discuss) {
|
||||
// test basic rendering
|
||||
var $sidebar = discuss.$('.o_mail_chat_sidebar');
|
||||
var $sidebar = discuss.$('.o_mail_discuss_sidebar');
|
||||
assert.strictEqual($sidebar.length, 1,
|
||||
"should have rendered a sidebar");
|
||||
|
||||
assert.strictEqual(discuss.$('.o_mail_chat_content').length, 1,
|
||||
assert.strictEqual(discuss.$('.o_mail_discuss_content').length, 1,
|
||||
"should have rendered the content");
|
||||
assert.strictEqual(discuss.$('.o_mail_no_content').length, 1,
|
||||
"should display no content message");
|
||||
|
||||
var $inbox = $sidebar.find('.o_mail_chat_channel_item[data-channel-id=channel_inbox]');
|
||||
var $inbox = $sidebar.find('.o_mail_discuss_item[data-thread-id=mailbox_inbox]');
|
||||
assert.strictEqual($inbox.length, 1,
|
||||
"should have the channel item 'channel_inbox' in the sidebar");
|
||||
"should have the channel item 'mailbox_inbox' in the sidebar");
|
||||
|
||||
var $starred = $sidebar.find('.o_mail_chat_channel_item[data-channel-id=channel_starred]');
|
||||
var $starred = $sidebar.find('.o_mail_discuss_item[data-thread-id=mailbox_starred]');
|
||||
assert.strictEqual($starred.length, 1,
|
||||
"should have the channel item 'channel_starred' in the sidebar");
|
||||
"should have the channel item 'mailbox_starred' in the sidebar");
|
||||
discuss.destroy();
|
||||
done();
|
||||
});
|
||||
@@ -109,8 +106,7 @@ QUnit.test('@ mention in channel', function (assert) {
|
||||
assert.expect(34);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
var bus = this.services[1].prototype.bus;
|
||||
var fetchListenersDef = $.Deferred();
|
||||
var receiveMessageDef = $.Deferred();
|
||||
|
||||
@@ -129,7 +125,7 @@ QUnit.test('@ mention in channel', function (assert) {
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'channel_fetch_listeners') {
|
||||
fetchListenersDef.resolve();
|
||||
@@ -154,8 +150,8 @@ QUnit.test('@ mention in channel', function (assert) {
|
||||
},
|
||||
})
|
||||
.then(function (discuss) {
|
||||
var $general = discuss.$('.o_mail_chat_sidebar')
|
||||
.find('.o_mail_chat_channel_item[data-channel-id=1]');
|
||||
var $general = discuss.$('.o_mail_discuss_sidebar')
|
||||
.find('.o_mail_discuss_item[data-thread-id=1]');
|
||||
assert.strictEqual($general.length, 1,
|
||||
"should have the channel item with id 1");
|
||||
assert.strictEqual($general.attr('title'), 'general',
|
||||
@@ -301,8 +297,8 @@ QUnit.test('no crash focusout emoji button', function (assert) {
|
||||
services: this.services,
|
||||
})
|
||||
.then(function (discuss) {
|
||||
var $general = discuss.$('.o_mail_chat_sidebar')
|
||||
.find('.o_mail_chat_channel_item[data-channel-id=1]');
|
||||
var $general = discuss.$('.o_mail_discuss_sidebar')
|
||||
.find('.o_mail_discuss_item[data-thread-id=1]');
|
||||
assert.strictEqual($general.length, 1,
|
||||
"should have the channel item with id 1");
|
||||
assert.strictEqual($general.attr('title'), 'general',
|
||||
@@ -327,9 +323,9 @@ QUnit.test('older messages are loaded on scroll', function (assert) {
|
||||
|
||||
var fetchCount = 0;
|
||||
var loadMoreDef = $.Deferred();
|
||||
var msgData = [];
|
||||
var messageData = [];
|
||||
for (var i = 0; i < 35; i++) {
|
||||
msgData.push({
|
||||
messageData.push({
|
||||
author_id: ['1', 'Me'],
|
||||
body: '<p>test ' + i + '</p>',
|
||||
channel_ids: [1],
|
||||
@@ -347,13 +343,13 @@ QUnit.test('older messages are loaded on scroll', function (assert) {
|
||||
}],
|
||||
},
|
||||
};
|
||||
this.data['mail.message'].records = msgData;
|
||||
this.data['mail.message'].records = messageData;
|
||||
|
||||
createDiscuss({
|
||||
context: {},
|
||||
data: this.data,
|
||||
params: {},
|
||||
services: [ChatManager, createBusService()],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_fetch') {
|
||||
fetchCount++;
|
||||
@@ -367,7 +363,7 @@ QUnit.test('older messages are loaded on scroll', function (assert) {
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
}).then(function (discuss) {
|
||||
var $general = discuss.$('.o_mail_chat_channel_item[data-channel-id=1]');
|
||||
var $general = discuss.$('.o_mail_discuss_item[data-thread-id=1]');
|
||||
assert.strictEqual($general.length, 1,
|
||||
"should have a channel item with id 1");
|
||||
|
||||
@@ -396,11 +392,10 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
|
||||
assert.expect(2);
|
||||
var done = assert.async();
|
||||
|
||||
var bus = new Bus();
|
||||
var BusService = createBusService(bus);
|
||||
var msgData = [];
|
||||
var bus = this.services[1].prototype.bus;
|
||||
var messageData = [];
|
||||
_.each(_.range(1, 41), function (num) {
|
||||
msgData.push({
|
||||
messageData.push({
|
||||
id: num,
|
||||
body: "<p>test" + num + "</p>",
|
||||
author_id: ["1", "Me"],
|
||||
@@ -419,16 +414,16 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
|
||||
name: "general",
|
||||
}],
|
||||
},
|
||||
starred_counter: msgData.length,
|
||||
starred_counter: messageData.length,
|
||||
};
|
||||
this.data['mail.message'].records = msgData;
|
||||
this.data['mail.message'].records = messageData;
|
||||
|
||||
createDiscuss({
|
||||
id: 1,
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: [ChatManager, BusService],
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'unstar_all') {
|
||||
var data = {
|
||||
@@ -445,8 +440,8 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
|
||||
session: {partner_id: 1},
|
||||
})
|
||||
.then(function (discuss) {
|
||||
var $starred = discuss.$('.o_mail_chat_sidebar').find('.o_mail_chat_title_starred');
|
||||
var $starredCounter = $('.o_mail_chat_title_starred > .o_mail_sidebar_needaction');
|
||||
var $starred = discuss.$('.o_mail_discuss_sidebar').find('.o_mail_mailbox_title_starred');
|
||||
var $starredCounter = $('.o_mail_mailbox_title_starred > .o_mail_sidebar_needaction');
|
||||
|
||||
// Go to Starred channel
|
||||
$starred.click();
|
||||
@@ -454,8 +449,8 @@ QUnit.test('"Unstar all" button should reset the starred counter', function (ass
|
||||
assert.strictEqual($starredCounter.text().trim(), "40", "40 messages should be starred");
|
||||
|
||||
// Unstar all and wait 'update_starred'
|
||||
$('.o_control_panel .o_mail_chat_button_unstar_all').click();
|
||||
$starredCounter = $('.o_mail_chat_title_starred > .o_mail_sidebar_needaction');
|
||||
$('.o_control_panel .o_mail_discuss_button_unstar_all').click();
|
||||
$starredCounter = $('.o_mail_mailbox_title_starred > .o_mail_sidebar_needaction');
|
||||
assert.strictEqual($starredCounter.text().trim(), "0",
|
||||
"All messages should be unstarred");
|
||||
|
||||
|
||||
@@ -12,13 +12,22 @@ MockServer.include({
|
||||
* handled as a model definition.
|
||||
*
|
||||
* @override
|
||||
* @param {Object} [data.initMessaging]
|
||||
* @param {Object} [data.initMessaging] init messaging data
|
||||
* @param {Object} [options.services] list of extra services to load for
|
||||
* the tests (e.g. MailService or BusService)
|
||||
*/
|
||||
init: function (data, options) {
|
||||
if (data && data.initMessaging) {
|
||||
this.initMessagingData = data.initMessaging;
|
||||
delete data.initMessaging;
|
||||
}
|
||||
var BusService = _.find(options.services, function (Service) {
|
||||
return Service.prototype.name === 'bus_service';
|
||||
});
|
||||
if (BusService) {
|
||||
this.busBus = BusService.prototype.bus;
|
||||
}
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
@@ -26,20 +35,24 @@ MockServer.include({
|
||||
* Simulate the '/mail.channel/channel_fetch_preview' route
|
||||
*
|
||||
* @private
|
||||
* @return {$.Promise<Object[]>} resolved with list of channels previews
|
||||
* @return {Object[]} list of channels previews
|
||||
*/
|
||||
_mockChannelFetchPreview: function (args) {
|
||||
var self = this;
|
||||
var ids = args.args[0]; // list of channel ids to fetch preview
|
||||
var ids = args.args[0]; // list of channel IDs to fetch preview
|
||||
var model = args.model;
|
||||
var channels = this._getRecords(model, [['id', 'in', ids]]);
|
||||
var previews = _.map(channels, function (channel) {
|
||||
var maxMessageID = _.max(channel.channel_message_ids);
|
||||
var lastMessage = self._getRecords('mail.message', [['id', '=', maxMessageID]])[0];
|
||||
var channelMessages = _.filter(self.data['mail.message'].records, function (message) {
|
||||
return _.contains(message.channel_ids, channel.id);
|
||||
});
|
||||
var lastMessage = _.max(channelMessages, function (message) {
|
||||
return message.id;
|
||||
});
|
||||
channel.last_message = lastMessage;
|
||||
return channel;
|
||||
});
|
||||
return $.when(previews);
|
||||
return previews;
|
||||
},
|
||||
/**
|
||||
* Simulate the '/mail/init_messaging' route
|
||||
@@ -59,11 +72,11 @@ MockServer.include({
|
||||
'mail_failures': [],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Simulate the 'message_fetch' Python method
|
||||
*
|
||||
* @return {Object}
|
||||
* @private
|
||||
* @return {Object[]}
|
||||
*/
|
||||
_mockMessageFetch: function (args) {
|
||||
var domain = args.args[0];
|
||||
@@ -74,27 +87,99 @@ MockServer.include({
|
||||
return m1.id < m2.id ? 1 : -1;
|
||||
});
|
||||
// pick at most 'limit' messages
|
||||
return $.when(messages.slice(0, args.kwargs.limit));
|
||||
return messages.slice(0, args.kwargs.limit);
|
||||
},
|
||||
/**
|
||||
* Simulate the 'message_format' Python method
|
||||
*
|
||||
* @return {Object[]}
|
||||
*/
|
||||
_mockMessageFormat: function (args) {
|
||||
var messageIDs = args.args[0];
|
||||
var domain = [['id', 'in', messageIDs]];
|
||||
var model = args.model;
|
||||
var messages = this._getRecords(model, domain);
|
||||
// sorted from highest ID to lowest ID (i.e. from youngest to oldest)
|
||||
messages.sort(function (m1, m2) {
|
||||
return m1.id < m2.id ? 1 : -1;
|
||||
});
|
||||
return messages;
|
||||
},
|
||||
/**
|
||||
* Simulate the 'moderate' Python method
|
||||
*/
|
||||
_mockModerate: function (args) {
|
||||
var messageIDs = args.args[0];
|
||||
var decision = args.args[1];
|
||||
var model = this.data['mail.message'];
|
||||
if (decision === 'reject' || decision === 'discard') {
|
||||
model.records = _.reject(model.records, function (rec) {
|
||||
return _.contains(messageIDs, rec.id);
|
||||
});
|
||||
// simulate notification back (deletion of rejected/discarded
|
||||
// message in channel)
|
||||
if (this.busBus) {
|
||||
var dbName = undefined; // useless for tests
|
||||
var notifData = {
|
||||
message_ids: messageIDs,
|
||||
type: "deletion",
|
||||
};
|
||||
var metaData = [dbName, 'res.partner'];
|
||||
var notification = [metaData, notifData];
|
||||
this.busBus.trigger('notification', [notification]);
|
||||
}
|
||||
} else if (decision === 'accept') {
|
||||
// simulate notification back (new accepted message in channel)
|
||||
if (this.busBus) {
|
||||
var messages = _.filter(model.records, function (rec) {
|
||||
return _.contains(messageIDs, rec.id);
|
||||
});
|
||||
|
||||
var notifications = [];
|
||||
_.each(messages, function (message) {
|
||||
var dbName = undefined; // useless for tests
|
||||
var messageData = message;
|
||||
message.moderation_status = 'accepted';
|
||||
var metaData = [dbName, 'mail.channel'];
|
||||
var notification = [metaData, messageData];
|
||||
notifications.push(notification);
|
||||
});
|
||||
this.busBus.trigger('notification', notifications);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_performRpc: function (route, args) {
|
||||
// routes
|
||||
if (route === '/mail/init_messaging') {
|
||||
return $.when(this._mockInitMessaging(args));
|
||||
}
|
||||
if (args.method === 'message_fetch') {
|
||||
return $.when(this._mockMessageFetch(args));
|
||||
}
|
||||
// methods
|
||||
if (args.method === 'channel_fetch_listeners') {
|
||||
return $.when([]);
|
||||
}
|
||||
if (args.method === 'channel_fetch_preview') {
|
||||
return $.when(this._mockChannelFetchPreview(args));
|
||||
}
|
||||
if (args.method === 'channel_seen') {
|
||||
return $.when();
|
||||
}
|
||||
if (args.method === 'channel_fetch_preview') {
|
||||
return this._mockChannelFetchPreview(args);
|
||||
if (args.method === 'message_fetch') {
|
||||
return $.when(this._mockMessageFetch(args));
|
||||
}
|
||||
if (args.method === 'message_format') {
|
||||
return $.when(this._mockMessageFormat(args));
|
||||
}
|
||||
if (args.method === 'set_message_done') {
|
||||
return $.when();
|
||||
}
|
||||
if (args.method === 'moderate') {
|
||||
return $.when(this._mockModerate(args));
|
||||
}
|
||||
if (args.method === 'channel_minimize') {
|
||||
return $.when();
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
odoo.define('mail.testUtils', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Discuss = require('mail.chat_discuss');
|
||||
var Discuss = require('mail.Discuss');
|
||||
var MailService = require('mail.Service');
|
||||
|
||||
var AbstractService = require('web.AbstractService');
|
||||
var AbstractStorageService = require('web.AbstractStorageService');
|
||||
var Bus = require('web.Bus');
|
||||
var ControlPanel = require('web.ControlPanel');
|
||||
var RamStorage = require('web.RamStorage');
|
||||
var testUtils = require('web.test_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
@@ -16,52 +19,8 @@ var Widget = require('web.Widget');
|
||||
* in the mail module, such as the BusService or Discuss.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a mock bus_service, using 'bus' instead of bus.bus
|
||||
*
|
||||
* @param {web.bus} bus
|
||||
* @return {AbstractService} the mock bus_service
|
||||
*/
|
||||
function createBusService(bus) {
|
||||
var BusService = AbstractService.extend({
|
||||
name: 'bus_service',
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
if (!bus) {
|
||||
bus = new Bus();
|
||||
}
|
||||
this.bus = new _.extend(bus, {
|
||||
/**
|
||||
* Do nothing
|
||||
*/
|
||||
start_polling: function () {},
|
||||
is_odoo_focused: function () {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the bus
|
||||
*/
|
||||
getBus: function () {
|
||||
return this.bus;
|
||||
},
|
||||
});
|
||||
|
||||
return BusService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create asynchronously a discuss widget.
|
||||
* This is async due to chat_manager service that needs to be ready.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @return {$.Promise} resolved with the discuss widget
|
||||
@@ -99,9 +58,46 @@ function createDiscuss(params) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of mail services required by the mail components: a
|
||||
* mail_service, and its two dependencies bus_service and local_storage.
|
||||
*
|
||||
* @return {AbstractService[]} an array of 3 services: mail_service, bus_service
|
||||
* and local_storage, in that order
|
||||
*/
|
||||
function getMailServices() {
|
||||
var MockBus = Bus.extend({
|
||||
/**
|
||||
* Do nothing
|
||||
*/
|
||||
start_polling: function () {},
|
||||
is_odoo_focused: function () { return true; },
|
||||
});
|
||||
var BusService = AbstractService.extend({
|
||||
name: 'bus_service',
|
||||
bus: new MockBus(),
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Bus}
|
||||
*/
|
||||
getBus: function () {
|
||||
return this.bus;
|
||||
}
|
||||
});
|
||||
var LocalStorageService = AbstractStorageService.extend({
|
||||
name: 'local_storage',
|
||||
storage: new RamStorage(),
|
||||
});
|
||||
return [MailService, BusService, LocalStorageService];
|
||||
}
|
||||
|
||||
return {
|
||||
createBusService: createBusService,
|
||||
createDiscuss: createDiscuss,
|
||||
getMailServices: getMailServices,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ QUnit.test('add_link utility function', function (assert) {
|
||||
};
|
||||
|
||||
_.each(testInputs, function (willLinkify, content) {
|
||||
var output = utils.parse_and_transform(content, utils.add_link);
|
||||
var output = utils.parseAndTransform(content, utils.addLink);
|
||||
if (willLinkify) {
|
||||
assert.strictEqual(output.indexOf('<a '), 0, "There should be a link");
|
||||
assert.strictEqual(output.indexOf('</a>'), (output.length - 4), "Link should match the whole text");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
odoo.define('mail.systray.ActivityMenuTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ActivityMenu = require('mail.systray.ActivityMenu');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('ActivityMenu', {
|
||||
beforeEach: function () {
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
this.data = {
|
||||
'mail.activity.menu': {
|
||||
fields: {
|
||||
name: { type: "char" },
|
||||
model: { type: "char" },
|
||||
type: { type: "char" },
|
||||
planned_count: { type: "integer"},
|
||||
today_count: { type: "integer"},
|
||||
overdue_count: { type: "integer"},
|
||||
total_count: { type: "integer"},
|
||||
},
|
||||
records: [{
|
||||
name: "Contact",
|
||||
model: "res.partner",
|
||||
type: "activity",
|
||||
planned_count: 0,
|
||||
today_count: 1,
|
||||
overdue_count: 0,
|
||||
total_count: 1,
|
||||
},
|
||||
{
|
||||
name: "Task",
|
||||
type: "activity",
|
||||
model: "project.task",
|
||||
planned_count: 1,
|
||||
today_count: 0,
|
||||
overdue_count: 0,
|
||||
total_count: 1,
|
||||
},
|
||||
{
|
||||
name: "Issue",
|
||||
type: "activity",
|
||||
model: "project.issue",
|
||||
planned_count: 1,
|
||||
today_count: 1,
|
||||
overdue_count: 1,
|
||||
total_count: 3,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('activity menu widget: menu with no records', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var activityMenu = new ActivityMenu();
|
||||
testUtils.addMockEnvironment(activityMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'systray_get_activities') {
|
||||
return $.when([]);
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
activityMenu.appendTo($('#qunit-fixture'));
|
||||
assert.ok(activityMenu.$('.o_no_activity').hasClass('o_no_activity'), "should not have instance of widget");
|
||||
activityMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('activity menu widget: activity menu with 3 records', function (assert) {
|
||||
assert.expect(10);
|
||||
var self = this;
|
||||
var activityMenu = new ActivityMenu();
|
||||
testUtils.addMockEnvironment(activityMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'systray_get_activities') {
|
||||
return $.when(self.data['mail.activity.menu']['records']);
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
activityMenu.appendTo($('#qunit-fixture'));
|
||||
assert.ok(activityMenu.$el.hasClass('o_mail_systray_item'), 'should be the instance of widget');
|
||||
assert.ok(activityMenu.$('.o_mail_preview').hasClass('o_mail_preview'), "should instance of widget");
|
||||
assert.ok(activityMenu.$('.o_notification_counter').hasClass('o_notification_counter'), "widget should have notification counter");
|
||||
assert.strictEqual(parseInt(activityMenu.el.innerText), 5, "widget should have 5 notification counter");
|
||||
|
||||
var context = {};
|
||||
testUtils.intercept(activityMenu, 'do_action', function (event) {
|
||||
assert.deepEqual(event.data.action.context, context, "wrong context value");
|
||||
}, true);
|
||||
|
||||
// case 1: click on "late"
|
||||
context = {
|
||||
search_default_activities_overdue: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
assert.strictEqual(activityMenu.$el.hasClass("open"), true, 'ActivityMenu should be open');
|
||||
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='overdue']").click();
|
||||
assert.strictEqual(activityMenu.$el.hasClass("open"), false, 'ActivityMenu should be closed');
|
||||
// case 2: click on "today"
|
||||
context = {
|
||||
search_default_activities_today: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='today']").click();
|
||||
// case 3: click on "future"
|
||||
context = {
|
||||
search_default_activities_upcoming_all: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='upcoming_all']").click();
|
||||
// case 4: click anywere else
|
||||
context = {
|
||||
search_default_activities_overdue: 1,
|
||||
search_default_activities_today: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$(".o_mail_systray_dropdown_items > div[data-model_name='Issue']").click();
|
||||
|
||||
activityMenu.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
odoo.define('mail.systray.MessagingMenuTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var DocumentThread = require('mail.model.DocumentThread');
|
||||
var MessagingMenu = require('mail.systray.MessagingMenu');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('MessagingMenu', {
|
||||
beforeEach: function () {
|
||||
// patch _.debounce and _.throttle to be fast and synchronous
|
||||
this.underscoreDebounce = _.debounce;
|
||||
this.underscoreThrottle = _.throttle;
|
||||
_.debounce = _.identity;
|
||||
_.throttle = _.identity;
|
||||
|
||||
this.data = {
|
||||
'mail.channel': {
|
||||
fields: {
|
||||
name: {
|
||||
string: "Name",
|
||||
type: "char",
|
||||
required: true,
|
||||
},
|
||||
channel_type: {
|
||||
string: "Channel Type",
|
||||
type: "selection",
|
||||
},
|
||||
channel_message_ids: {
|
||||
string: "Messages",
|
||||
type: "many2many",
|
||||
relation: 'mail.message'
|
||||
},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
name: "general",
|
||||
channel_type: "channel",
|
||||
channel_message_ids: [1],
|
||||
}],
|
||||
},
|
||||
'mail.message': {
|
||||
fields: {
|
||||
body: {
|
||||
string: "Contents",
|
||||
type: 'html',
|
||||
},
|
||||
author_id: {
|
||||
string: "Author",
|
||||
type: 'many2one',
|
||||
relation: 'res.partner',
|
||||
},
|
||||
channel_ids: {
|
||||
string: "Channels",
|
||||
type: 'many2many',
|
||||
relation: 'mail.channel',
|
||||
},
|
||||
starred: {
|
||||
string: "Starred",
|
||||
type: 'boolean',
|
||||
},
|
||||
needaction: {
|
||||
string: "Need Action",
|
||||
type: 'boolean',
|
||||
},
|
||||
needaction_partner_ids: {
|
||||
string: "Needaction partner IDs",
|
||||
type: 'one2many',
|
||||
relation: 'res.partner',
|
||||
},
|
||||
starred_partner_ids: {
|
||||
string: "partner ids",
|
||||
type: 'integer',
|
||||
}
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
author_id: ['1', 'Me'],
|
||||
body: '<p>test</p>',
|
||||
channel_ids: [1],
|
||||
}],
|
||||
},
|
||||
initMessaging: {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
id: 1,
|
||||
channel_type: "channel",
|
||||
name: "general",
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
},
|
||||
afterEach: function () {
|
||||
// unpatch _.debounce and _.throttle
|
||||
_.debounce = this.underscoreDebounce;
|
||||
_.throttle = this.underscoreThrottle;
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('messaging menu widget: menu with no records', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_fetch') {
|
||||
return $.when([]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.ok(messagingMenu.$('.o_no_activity').hasClass('o_no_activity'), "should not have instance of widget");
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('messaging menu widget: messaging menu with 1 record', function (assert) {
|
||||
assert.expect(3);
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
|
||||
"should display a preview");
|
||||
assert.strictEqual(messagingMenu.$('.o_preview_name').text().trim(), "general",
|
||||
"should display correct name of channel in preview");
|
||||
|
||||
// remove any space-character inside text
|
||||
var lastMessagePreviewText =
|
||||
messagingMenu.$('.o_last_message_preview').text().replace(/\s/g, "");
|
||||
assert.strictEqual(lastMessagePreviewText,
|
||||
"Me:test",
|
||||
"should display correct last message preview in channel preview");
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('messaging menu widget: no crash when clicking on inbox notification not associated to a document', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var bus = this.services[1].prototype.bus;
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: 1,
|
||||
},
|
||||
intercepts: {
|
||||
/**
|
||||
* Simulate action 'mail.mail_channel_action_client_chat'
|
||||
* successfully performed.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @param {function} ev.data.on_success called when success action performed
|
||||
*/
|
||||
do_action: function (ev) {
|
||||
ev.data.on_success();
|
||||
},
|
||||
},
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
// Simulate received needaction message without associated document,
|
||||
// so that we have a message in inbox without a model and a resID
|
||||
var message = {
|
||||
id: 2,
|
||||
author_id: [1, "Me"],
|
||||
body: '<p>test</p>',
|
||||
channel_ids: [],
|
||||
needaction_partner_ids: [1],
|
||||
};
|
||||
var notifications = [
|
||||
[['myDB', 'ir.needaction'], message]
|
||||
];
|
||||
bus.trigger('notification', notifications);
|
||||
|
||||
// Open messaging menu
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
|
||||
var $firstChannelPreview =
|
||||
messagingMenu.$('.o_mail_preview').first();
|
||||
|
||||
assert.strictEqual($firstChannelPreview.length, 1,
|
||||
"should have at least one channel preview");
|
||||
assert.strictEqual($firstChannelPreview.data('preview-id'),
|
||||
'mailbox_inbox',
|
||||
"should be a preview from channel inbox");
|
||||
try {
|
||||
$firstChannelPreview.click();
|
||||
assert.ok(true, "should not have crashed when clicking on needaction preview message");
|
||||
} finally {
|
||||
messagingMenu.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test("messaging menu widget: mark as read on thread preview", function ( assert ) {
|
||||
assert.expect(8);
|
||||
|
||||
testUtils.patch(DocumentThread, {
|
||||
markAsRead: function () {
|
||||
if (
|
||||
this.getDocumentModel() === 'crm.lead' &&
|
||||
this.getDocumentID() === 126
|
||||
) {
|
||||
assert.step('markedAsRead');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.data['mail.message'].records = [{
|
||||
id: 10,
|
||||
channel_ids: ['mailbox_inbox'],
|
||||
res_id: 126,
|
||||
needaction: true,
|
||||
module_icon: "/crm/static/description/icon.png",
|
||||
date: "2018-04-05 06:37:26",
|
||||
subject: "Re: Interest in your Graphic Design Project",
|
||||
model: "crm.lead",
|
||||
body: "<span>Testing Messaging</span>"
|
||||
}];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.ok(messagingMenu.$el.hasClass('o_mail_systray_item'),
|
||||
'should be the instance of widget');
|
||||
assert.ok(messagingMenu.$el.hasClass('open'),
|
||||
'MessagingMenu should be open');
|
||||
|
||||
var $preview = messagingMenu.$('.o_mail_preview.o_preview_unread');
|
||||
assert.strictEqual($preview.length, 1,
|
||||
"should have one unread preview");
|
||||
assert.strictEqual($preview.data('document-model'), 'crm.lead',
|
||||
"should preview be linked to correct document model");
|
||||
assert.strictEqual($preview.data('document-id'), 126,
|
||||
"should preview be linked to correct document ID");
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview_mark_as_read').length, 1,
|
||||
"should have mark as read icon next to preview");
|
||||
|
||||
messagingMenu.$(".o_mail_preview_mark_as_read").click();
|
||||
|
||||
assert.verifySteps(['markedAsRead'],
|
||||
"the document thread should be marked as read");
|
||||
|
||||
testUtils.unpatch(DocumentThread);
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('needaction messages in channels should appear, in addition to channel preview', function (assert) {
|
||||
// Let's suppose a channel whose before-last message (msg1) is a needaction,
|
||||
// but not the last message (msg2). In that case, the systray messaging
|
||||
// menu should display the needaction message msg1 in the preview, and the
|
||||
// channel preview with the msg2.
|
||||
assert.expect(3);
|
||||
|
||||
// simulate a needaction (mention) message in channel 'general'
|
||||
var partnerID = 44;
|
||||
var needactionMessage = {
|
||||
author_id: [1, "Demo"],
|
||||
body: "<p>@Administrator: ping</p>",
|
||||
channel_ids: [1],
|
||||
id: 3,
|
||||
model: 'mail.channel',
|
||||
needaction: true,
|
||||
needaction_partner_ids: [partnerID],
|
||||
record_name: 'general',
|
||||
res_id: 1,
|
||||
};
|
||||
var lastMessage = {
|
||||
author_id: [2, "Other"],
|
||||
body: "<p>last message content</p>",
|
||||
channel_ids: [1],
|
||||
id: 4,
|
||||
model: 'mail.channel',
|
||||
record_name: 'general',
|
||||
res_id: 1,
|
||||
};
|
||||
this.data['mail.message'].records = [needactionMessage, lastMessage];
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: partnerID,
|
||||
},
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 2,
|
||||
"should display two previews");
|
||||
var $preview1 = messagingMenu.$('.o_mail_preview').eq(0);
|
||||
var $preview2 = messagingMenu.$('.o_mail_preview').eq(1);
|
||||
|
||||
assert.strictEqual($preview1.find('.o_last_message_preview').text().replace(/\s/g, ""),
|
||||
"Demo:@Administrator:ping",
|
||||
"1st preview (needaction preview) should display needaction message");
|
||||
assert.strictEqual($preview2.find('.o_last_message_preview').text().replace(/\s/g, ""),
|
||||
"Other:lastmessagecontent",
|
||||
"2nd preview (channel preview) should display last message preview");
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('preview of message on a document', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var partnerID = 44;
|
||||
var needactionMessage = {
|
||||
author_id: [1, "Demo"],
|
||||
body: "<p>*MessageOnDocument*</p>",
|
||||
id: 689,
|
||||
model: 'some.res.model',
|
||||
needaction: true,
|
||||
needaction_partner_ids: [partnerID],
|
||||
record_name: "Some Record",
|
||||
res_id: 1,
|
||||
};
|
||||
this.data['some.res.model'] = {
|
||||
fields: {
|
||||
message_ids: {string: 'Messages', type: 'one2many'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
message_ids: [689],
|
||||
}],
|
||||
};
|
||||
this.data['mail.message'].records.push(needactionMessage);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: partnerID,
|
||||
},
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 2,
|
||||
"should display two channel previews");
|
||||
assert.ok(messagingMenu.$('.o_mail_preview:first').hasClass('o_preview_unread'),
|
||||
"document thread preview should be marked as unread");
|
||||
assert.strictEqual(messagingMenu.$('.o_preview_unread .o_last_message_preview').text().replace(/\s/g, ''),
|
||||
"Demo:*MessageOnDocument*", "should correctly display the preview");
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,375 +0,0 @@
|
||||
odoo.define('mail.systray_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
var systray = require('mail.systray');
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var Bus = require('web.Bus');
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
var createBusService = mailTestUtils.createBusService;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
|
||||
QUnit.module('ActivityMenu', {
|
||||
beforeEach: function () {
|
||||
this.services = [ChatManager, createBusService()];
|
||||
this.data = {
|
||||
'mail.activity.menu': {
|
||||
fields: {
|
||||
name: { type: "char" },
|
||||
model: { type: "char" },
|
||||
type: { type: "char" },
|
||||
planned_count: { type: "integer"},
|
||||
today_count: { type: "integer"},
|
||||
overdue_count: { type: "integer"},
|
||||
total_count: { type: "integer"},
|
||||
},
|
||||
records: [{
|
||||
name: "Contact",
|
||||
model: "res.partner",
|
||||
type: "activity",
|
||||
planned_count: 0,
|
||||
today_count: 1,
|
||||
overdue_count: 0,
|
||||
total_count: 1,
|
||||
},
|
||||
{
|
||||
name: "Task",
|
||||
type: "activity",
|
||||
model: "project.task",
|
||||
planned_count: 1,
|
||||
today_count: 0,
|
||||
overdue_count: 0,
|
||||
total_count: 1,
|
||||
},
|
||||
{
|
||||
name: "Issue",
|
||||
type: "activity",
|
||||
model: "project.issue",
|
||||
planned_count: 1,
|
||||
today_count: 1,
|
||||
overdue_count: 1,
|
||||
total_count: 3,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('activity menu widget: menu with no records', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var activityMenu = new systray.ActivityMenu();
|
||||
testUtils.addMockEnvironment(activityMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'systray_get_activities') {
|
||||
return $.when([]);
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
activityMenu.appendTo($('#qunit-fixture'));
|
||||
assert.ok(activityMenu.$('.o_no_activity').hasClass('o_no_activity'), "should not have instance of widget");
|
||||
activityMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('activity menu widget: activity menu with 3 records', function (assert) {
|
||||
assert.expect(10);
|
||||
var self = this;
|
||||
var activityMenu = new systray.ActivityMenu();
|
||||
testUtils.addMockEnvironment(activityMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'systray_get_activities') {
|
||||
return $.when(self.data['mail.activity.menu']['records']);
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
activityMenu.appendTo($('#qunit-fixture'));
|
||||
assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget');
|
||||
assert.ok(activityMenu.$('.o_mail_channel_preview').hasClass('o_mail_channel_preview'), "should instance of widget");
|
||||
assert.ok(activityMenu.$('.o_notification_counter').hasClass('o_notification_counter'), "widget should have notification counter");
|
||||
assert.strictEqual(parseInt(activityMenu.el.innerText), 5, "widget should have 5 notification counter");
|
||||
|
||||
var context = {};
|
||||
testUtils.intercept(activityMenu, 'do_action', function(event) {
|
||||
assert.deepEqual(event.data.action.context, context, "wrong context value");
|
||||
}, true);
|
||||
|
||||
// case 1: click on "late"
|
||||
context = {
|
||||
search_default_activities_overdue: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
assert.strictEqual(activityMenu.$el.hasClass("open"), true, 'ActivityMenu should be open');
|
||||
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='overdue']").click();
|
||||
assert.strictEqual(activityMenu.$el.hasClass("open"), false, 'ActivityMenu should be closed');
|
||||
// case 2: click on "today"
|
||||
context = {
|
||||
search_default_activities_today: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='today']").click();
|
||||
// case 3: click on "future"
|
||||
context = {
|
||||
search_default_activities_upcoming_all: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='upcoming_all']").click();
|
||||
// case 4: click anywere else
|
||||
context = {
|
||||
search_default_activities_overdue: 1,
|
||||
search_default_activities_today: 1,
|
||||
};
|
||||
activityMenu.$('.dropdown-toggle').click();
|
||||
activityMenu.$(".o_mail_navbar_dropdown_channels > div[data-model_name='Issue']").click();
|
||||
|
||||
activityMenu.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.module('MessagingMenu', {
|
||||
beforeEach: function () {
|
||||
// patch _.debounce and _.throttle to be fast and synchronous
|
||||
this.underscoreDebounce = _.debounce;
|
||||
this.underscoreThrottle = _.throttle;
|
||||
_.debounce = _.identity;
|
||||
_.throttle = _.identity;
|
||||
|
||||
this.data = {
|
||||
'mail.channel': {
|
||||
fields: {
|
||||
name: {
|
||||
string: "Name",
|
||||
type: "char",
|
||||
required: true,
|
||||
},
|
||||
channel_type: {
|
||||
string: "Channel Type",
|
||||
type: "selection",
|
||||
},
|
||||
channel_message_ids: {
|
||||
string: "Messages",
|
||||
type: "many2many",
|
||||
relation: 'mail.message'
|
||||
},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
name: "general",
|
||||
channel_type: "channel",
|
||||
channel_message_ids: [1],
|
||||
}],
|
||||
},
|
||||
'mail.message': {
|
||||
fields: {
|
||||
body: {
|
||||
string: "Contents",
|
||||
type: 'html',
|
||||
},
|
||||
author_id: {
|
||||
string: "Author",
|
||||
relation: 'res.partner',
|
||||
},
|
||||
channel_ids: {
|
||||
string: "Channels",
|
||||
type: 'many2many',
|
||||
relation: 'mail.channel',
|
||||
},
|
||||
starred: {
|
||||
string: "Starred",
|
||||
type: 'boolean',
|
||||
},
|
||||
needaction: {
|
||||
string: "Need Action",
|
||||
type: 'boolean',
|
||||
},
|
||||
starred_partner_ids: {
|
||||
string: "partner ids",
|
||||
type: 'integer',
|
||||
}
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
author_id: ['1', 'Me'],
|
||||
body: '<p>test</p>',
|
||||
channel_ids: [1],
|
||||
}],
|
||||
},
|
||||
initMessaging: {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
id: 1,
|
||||
channel_type: "channel",
|
||||
name: "general",
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
this.services = [ChatManager, createBusService()];
|
||||
},
|
||||
afterEach: function () {
|
||||
// unpatch _.debounce and _.throttle
|
||||
_.debounce = this.underscoreDebounce;
|
||||
_.throttle = this.underscoreThrottle;
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('messaging menu widget: menu with no records', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var messagingMenu = new systray.MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_fetch') {
|
||||
return $.when([]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.ok(messagingMenu.$('.o_no_activity').hasClass('o_no_activity'), "should not have instance of widget");
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('messaging menu widget: messaging menu with 1 record', function (assert) {
|
||||
assert.expect(3);
|
||||
var messagingMenu = new systray.MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_channel_preview').length, 1,
|
||||
"should display a channel preview");
|
||||
assert.strictEqual(messagingMenu.$('.o_channel_name').text().trim(), "general",
|
||||
"should display correct name of channel in channel preview");
|
||||
|
||||
// remove any space-character inside text
|
||||
var lastMessagePreviewText =
|
||||
messagingMenu.$('.o_last_message_preview').text().replace(/\s/g, "");
|
||||
assert.strictEqual(lastMessagePreviewText,
|
||||
"Me:test",
|
||||
"should display correct last message preview in channel preview");
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('messaging menu widget: no crash when clicking on inbox notification not associated to a document', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var bus = new Bus();
|
||||
|
||||
var messagingMenu = new systray.MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: [ChatManager, createBusService(bus)],
|
||||
data: this.data,
|
||||
session: {
|
||||
partner_id: 1,
|
||||
},
|
||||
intercepts: {
|
||||
/**
|
||||
* Simulate action 'mail.mail_channel_action_client_chat'
|
||||
* successfully performed.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @param {function} ev.data.on_success called when success action performed
|
||||
*/
|
||||
do_action: function (ev) {
|
||||
ev.data.on_success();
|
||||
},
|
||||
},
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
// Simulate received needaction message without associated document,
|
||||
// so that we have a message in inbox without a model and a resID
|
||||
var message = {
|
||||
id: 2,
|
||||
author_id: [1, "Me"],
|
||||
body: '<p>test</p>',
|
||||
channel_ids: [],
|
||||
needaction_partner_ids: [1],
|
||||
};
|
||||
var notifications = [
|
||||
[['myDB', 'ir.needaction'], message]
|
||||
];
|
||||
bus.trigger('notification', notifications);
|
||||
|
||||
// Open messaging menu
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
|
||||
var $firstChannelPreview =
|
||||
messagingMenu.$('.o_mail_channel_preview').first();
|
||||
|
||||
assert.strictEqual($firstChannelPreview.length, 1,
|
||||
"should have at least one channel preview");
|
||||
assert.strictEqual($firstChannelPreview.data('channel_id'),
|
||||
'channel_inbox',
|
||||
"should be a preview from channel inbox");
|
||||
try {
|
||||
$firstChannelPreview.click();
|
||||
assert.ok(true, "should not have crashed when clicking on needaction preview message");
|
||||
} finally {
|
||||
messagingMenu.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test("messaging menu widget: messaging menu with 1 message", function ( assert ) {
|
||||
assert.expect(5);
|
||||
|
||||
var records = [{
|
||||
"channel_ids": ['channel_inbox'],
|
||||
"res_id": 126,
|
||||
'is_needaction': true,
|
||||
"module_icon": "/crm/static/description/icon.png",
|
||||
"date": "2018-04-05 06:37:26",
|
||||
"subject": "Re: Interest in your Graphic Design Project",
|
||||
"model": "crm.lead",
|
||||
"body": "<span>Testing Messaging</span>"
|
||||
}];
|
||||
|
||||
var messagingMenu = new systray.MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: [ChatManager, createBusService()],
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === "message_fetch") {
|
||||
return $.when(records);
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.ok(messagingMenu.$el.hasClass('o_mail_navbar_item'),
|
||||
'should be the instance of widget');
|
||||
assert.strictEqual(messagingMenu.$el.hasClass("open"), true,
|
||||
'MessagingMenu should be open');
|
||||
assert.strictEqual(messagingMenu.$('.o_channel_unread').length, 1,
|
||||
"should have one unread message for channel");
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_channel_mark_read').length, 1,
|
||||
"should have mark as read icon");
|
||||
testUtils.intercept(messagingMenu, 'call_service', function (event) {
|
||||
if (event.data.method === 'markAllAsRead') {
|
||||
assert.deepEqual(
|
||||
event.data.args[1],
|
||||
[["model", "=" , "crm.lead"], ["res_id", "=" ,126]],
|
||||
"The message has been read"
|
||||
);
|
||||
}
|
||||
});
|
||||
messagingMenu.$(".o_mail_channel_mark_read").click();
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
odoo.define('mail.thread_window_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var framework = require('web.framework');
|
||||
var testUtils = require('web.test_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
QUnit.module('mail', {
|
||||
beforeEach: function () {
|
||||
var self = this;
|
||||
|
||||
// define channel to link to chat window
|
||||
this.data = {
|
||||
'mail.message': {
|
||||
fields: {},
|
||||
records: [],
|
||||
},
|
||||
initMessaging: {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
id: 1,
|
||||
channel_type: "channel",
|
||||
name: "general",
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
|
||||
// backup original thread window append to selector
|
||||
this.MailService = _.find(this.services, function (Service) {
|
||||
return Service.prototype.name === 'mail_service';
|
||||
});
|
||||
this.ORIGINAL_THREAD_WINDOW_APPENDTO = this.MailService.prototype.THREAD_WINDOW_APPENDTO;
|
||||
|
||||
this.createParent = function (params) {
|
||||
var widget = new Widget();
|
||||
|
||||
// in non-debug mode, append thread windows in qunit-fixture
|
||||
if (params.debug) {
|
||||
self.MailService.prototype.THREAD_WINDOW_APPENDTO = 'body';
|
||||
} else {
|
||||
self.MailService.prototype.THREAD_WINDOW_APPENDTO = '#qunit-fixture';
|
||||
}
|
||||
|
||||
testUtils.addMockEnvironment(widget, params);
|
||||
return widget;
|
||||
};
|
||||
},
|
||||
afterEach: function () {
|
||||
// reset thread window append to body
|
||||
this.MailService.prototype.THREAD_WINDOW_APPENDTO = 'body';
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module('thread_window');
|
||||
|
||||
QUnit.test('close thread window using ESCAPE key', function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var parent = this.createParent({
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'channel_fold') {
|
||||
assert.ok(true, "should call channel_fold");
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
// get channel instance to link to thread window
|
||||
var channel = parent.call('mail_service', 'getChannel', 1);
|
||||
assert.ok(channel, "there should exist a channel locally with ID 1");
|
||||
|
||||
channel.detach();
|
||||
assert.strictEqual($('.o_thread_window').length, 1,
|
||||
"there should be a thread window that is opened");
|
||||
|
||||
// focus on the thread window and press ESCAPE
|
||||
$('.o_thread_window .o_composer_text_field').click();
|
||||
assert.strictEqual(document.activeElement,
|
||||
$('.o_thread_window .o_composer_text_field')[0],
|
||||
"thread window's input should now be focused");
|
||||
|
||||
var upKeyEvent = $.Event( "keyup", { which: 27 });
|
||||
$('.o_thread_window .o_composer_text_field').trigger(upKeyEvent);
|
||||
|
||||
assert.strictEqual($('.o_thread_window').length, 0,
|
||||
"the thread window should be closed");
|
||||
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('thread window\'s input can still be focused when the UI is blocked', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var parent = this.createParent({
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
var $dom = $('#qunit-fixture');
|
||||
|
||||
// get channel instance to link to thread window
|
||||
var channel = parent.call('mail_service', 'getChannel', 1);
|
||||
channel.detach();
|
||||
|
||||
var $input = $('<input/>', {type: 'text'}).appendTo($dom);
|
||||
$input.focus().click();
|
||||
assert.strictEqual(document.activeElement, $input[0],
|
||||
"fake input should be focused");
|
||||
|
||||
framework.blockUI();
|
||||
// cannot force focus here otherwise the test
|
||||
// makes no sense, this test is just about
|
||||
// making sure that the code which forces the
|
||||
// focus on click is not removed
|
||||
$('.o_thread_window .o_composer_text_field').click();
|
||||
assert.strictEqual(document.activeElement,
|
||||
$('.o_thread_window .o_composer_text_field')[0],
|
||||
"thread window's input should now be focused");
|
||||
|
||||
framework.unblockUI();
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('emoji popover should open correctly in thread windows', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var parent = this.createParent({
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
});
|
||||
|
||||
// get channel instance to link to thread window
|
||||
var channel = parent.call('mail_service', 'getChannel', 1);
|
||||
channel.detach();
|
||||
|
||||
var $emojiButton = $('.o_composer_button_emoji');
|
||||
$emojiButton.trigger('focusin').focus().click();
|
||||
var $popover = $('.o_mail_emoji_container');
|
||||
|
||||
var done = assert.async();
|
||||
// Async is needed as the popover focusout hiding is deferred
|
||||
setTimeout(function () {
|
||||
assert.ok($popover.is(':visible'), "emoji popover should have stayed opened");
|
||||
parent.destroy();
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -198,7 +198,7 @@
|
||||
<field name="tag">mail.chat.instant_messaging</field>
|
||||
<field name="res_model">mail.channel</field>
|
||||
<field name="params" eval=""{
|
||||
'default_active_id': 'channel_inbox'
|
||||
'default_active_id': 'mailbox_inbox'
|
||||
}""/>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -11,16 +11,36 @@
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/mail/static/src/js/many2many_tags_email.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/mail/static/src/js/chat_window.js"></script>
|
||||
<!-- Services -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_document_thread_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_notification_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_window_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_service.js"></script>
|
||||
<!-- Models -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/channel.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/dm.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/document_thread.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/mail_failure.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/mailbox.js"></script>
|
||||
<!-- messages -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/messages/abstract_message.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/messages/message.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/thread.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/thread_with_cache.js"></script>
|
||||
<!-- Widgets -->
|
||||
<!-- thread windows -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread_windows/thread_window.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/discuss.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/discuss_mobile.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/extended_chat_window.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/document_viewer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/composer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/chat_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/chat_window_manager.js"></script>
|
||||
<!-- composers -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/composers/basic_composer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/composers/chatter_composer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/composers/extended_composer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/composers/mention_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/chatter.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/chatter_composer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/followers.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/form_controller.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/form_renderer.js"></script>
|
||||
@@ -28,15 +48,19 @@
|
||||
<script type="text/javascript" src="/mail/static/src/js/basic_view.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread_field.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/systray.js"></script>
|
||||
<!-- systray -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/systray/systray_activity_menu.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/systray/systray_messaging_menu.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/tour.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/activity.js"></script>
|
||||
<!-- Others -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/emojis.js"></script>
|
||||
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/announcement.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/discuss.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/chat_window.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/extended_chat_window.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/abstract_thread_window.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/thread_window.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/composer.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/chatter.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/followers.scss"/>
|
||||
@@ -55,12 +79,18 @@
|
||||
|
||||
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
|
||||
<xpath expr="//t[@t-set='head']" position="inside">
|
||||
<script type="text/javascript" src="/mail/static/tests/chat_window_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/thread_window_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/document_thread_window_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/mail_utils_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/discuss_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/discuss_moderation_tests.js"></script>
|
||||
<!-- systray -->
|
||||
<script type="text/javascript" src="/mail/static/tests/systray/systray_activity_menu_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/systray/systray_messaging_menu_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/discuss_moderation_tests.js"></script>
|
||||
<!-- Helpers -->
|
||||
<script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
+8
-6
@@ -1,8 +1,9 @@
|
||||
odoo.define('note.systray', function (require) {
|
||||
odoo.define('note.systray.ActivityMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ActivityMenu = require('mail.systray.ActivityMenu');
|
||||
|
||||
var core = require('web.core');
|
||||
var ActivityMenu = require('mail.systray').ActivityMenu;
|
||||
var datepicker = require('web.datepicker');
|
||||
|
||||
var _t = core._t;
|
||||
@@ -24,8 +25,10 @@ ActivityMenu.include({
|
||||
*/
|
||||
_getActivityData: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function() {
|
||||
var reminderIndex = _.findIndex(self.activities, function (val) { return val.model == 'note.note'; });
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
var reminderIndex = _.findIndex(self.activities, function (val) {
|
||||
return val.model === 'note.note';
|
||||
});
|
||||
if (reminderIndex > 0) {
|
||||
self.activities.splice(0, 0, self.activities.splice(reminderIndex, 1)[0]);
|
||||
}
|
||||
@@ -60,7 +63,7 @@ ActivityMenu.include({
|
||||
*/
|
||||
_onActivityFilterClick: function (ev) {
|
||||
var $el = $(ev.currentTarget);
|
||||
if(!$el.hasClass("o_note")) {
|
||||
if (!$el.hasClass("o_note")) {
|
||||
var data = _.extend({}, $el.data(), $(ev.target).data());
|
||||
if (data.res_model === "note.note" && data.filter === "my") {
|
||||
this.do_action({
|
||||
@@ -90,7 +93,6 @@ ActivityMenu.include({
|
||||
this.noteDateTimeWidget.$input.attr('placeholder', _t("Today"));
|
||||
this.$('.o_note_show, .o_note').toggleClass('hidden');
|
||||
this.$('.o_note_input').val('').focus();
|
||||
|
||||
},
|
||||
/**
|
||||
* When focusing on input for new quick note systerm tray must be open.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user