From aa23a5a41de2a58e5d212539240508b4f94cb970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Wed, 30 Sep 2015 13:59:27 +0200 Subject: [PATCH 01/14] [IMP] bus: allow bus to restart polling on demand The problem was that the bus didn't listen to new channels when they were registered until the end of the current longpoll. This allows mail to manually restart the polling. --- addons/bus/static/src/js/bus.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js index 3c828668308..b89f66a7c33 100644 --- a/addons/bus/static/src/js/bus.js +++ b/addons/bus/static/src/js/bus.js @@ -1,4 +1,5 @@ odoo.define('bus.bus', function (require) { +"use strict"; var session = require('web.session'); var Widget = require('web.Widget'); @@ -40,11 +41,15 @@ bus.Bus = Widget.extend({ var self = this; self.activated = true; var data = {'channels': self.channels, 'last': self.last, 'options' : self.options}; - session.rpc('/longpolling/poll', data, {shadow : true}).then(function(result) { + this.current_poll = $.Deferred(); + this.current_poll.then(function (result) { _.each(result, _.bind(self.on_notification, self)); if(!self.stop){ self.poll(); } + }); + session.rpc('/longpolling/poll', data, {shadow : true}).then(function(result) { + self.current_poll.resolve(result); }, function(unused, e) { // no error popup if request is interrupted or fails for any reason e.preventDefault(); @@ -58,6 +63,12 @@ bus.Bus = Widget.extend({ } this.trigger("notification", [notification.channel, notification.message]); }, + restart_poll: function () { + if (this.current_poll) { + this.current_poll.fail(); + } + this.poll(); + }, add_channel: function(channel){ if(!_.contains(this.channels, channel)){ this.channels.push(channel); From 512287cac1fb7d4da632a4be0922679b203aff66 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 30 Sep 2015 14:09:20 +0200 Subject: [PATCH 02/14] [REF] mail: first part of the chat refactoring (client side) Joint work with ged --- addons/mail/static/src/js/chat_manager.js | 442 +++++++++++ addons/mail/static/src/js/chatter.js | 897 ++++++++++++++++++++++ addons/mail/static/src/js/composer.js | 486 ++++++++++++ addons/mail/static/src/js/thread.js | 126 +++ addons/mail/static/src/less/chatter.less | 128 +++ addons/mail/static/src/less/composer.less | 97 +++ addons/mail/static/src/less/thread.less | 71 ++ addons/mail/static/src/xml/chatter.xml | 165 ++++ addons/mail/static/src/xml/composer.xml | 57 ++ addons/mail/static/src/xml/thread.xml | 122 +++ 10 files changed, 2591 insertions(+) create mode 100644 addons/mail/static/src/js/chat_manager.js create mode 100644 addons/mail/static/src/js/chatter.js create mode 100644 addons/mail/static/src/js/composer.js create mode 100644 addons/mail/static/src/js/thread.js create mode 100644 addons/mail/static/src/less/chatter.less create mode 100644 addons/mail/static/src/less/composer.less create mode 100644 addons/mail/static/src/less/thread.less create mode 100644 addons/mail/static/src/xml/chatter.xml create mode 100644 addons/mail/static/src/xml/composer.xml create mode 100644 addons/mail/static/src/xml/thread.xml diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js new file mode 100644 index 00000000000..cb68b789791 --- /dev/null +++ b/addons/mail/static/src/js/chat_manager.js @@ -0,0 +1,442 @@ +odoo.define('mail.chat_manager', function (require) { +"use strict"; + +var bus = require('bus.bus').bus; +var core = require('web.core'); +var data = require('web.data'); +var Model = require('web.Model'); +var session = require('web.session'); +var web_client = require('web.web_client'); + +var _t = core._t; +var LIMIT = 20; + +var MessageModel = new Model('mail.message', session.context); +var ChannelModel = new Model('mail.channel', session.context); + +// Private model +//---------------------------------------------------------------------------------- +var messages = []; +var channels = []; +var emojis = []; +var emoji_substitutions = {}; +var needaction_counter = 0; + +// Message and channel manipulation helpers +//---------------------------------------------------------------------------------- + +// options: channel_id, silent +function add_message (data, options) { + options = options || {}; + var msg = _.findWhere(messages, { id: data.id }); + + if (!msg) { + msg = make_message(data, options.channel_id); + // Keep the array ordered by date when inserting the new message + messages.splice(_.sortedIndex(messages, msg, 'date'), 0, msg); + if (options.channel_id) { + var channel = _.findWhere(channels, {id: options.channel_id}); + if (channel.hidden) { + channel.hidden = false; + chat_manager.bus.trigger('new_channel', channel); + } + if ((channel.type === "dm") && (options.show_notification)) { + var query = { is_displayed: false }; + chat_manager.bus.trigger('anyone_listening', channel, query); + if (!query.is_displayed) { + web_client.do_notify(_t('New message'), _t("You received a message from ") + msg.author_id[1]); + } + } + } + if (!options.silent) { + chat_manager.bus.trigger('new_message', msg); + } + } else if (msg && options.channel_id !== undefined) { + add_channel_to_message(msg, options.channel_id); + } + return msg; +} + +function make_message (data, channel_id) { + var msg = { + id: data.id, + author_id: data.author_id, + body: data.body, + date: data.date, + message_type: data.message_type, + is_note: data.is_note, + attachment_ids: data.attachment_ids, + subject: data.subject, + email_from: data.email_from, + record_name: data.record_name, + tracking_value_ids: data.tracking_value_ids, + channel_ids: (channel_id !== undefined ? [channel_id] : []), + model: data.model, + res_id: data.res_id, + }; + + _.each(_.keys(emoji_substitutions), function(key){ + var escaped_key = String(key).replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1'); //] + var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + escaped_key + ")(?:\\s|$|)"); + msg.body = msg.body.replace(regexp, ' '+emoji_substitutions[key]+' '); + }); + + function property_descr(channel) { + return { + enumerable: true, + get: function () { + return _.contains(msg.channel_ids, channel); + }, + set: function (bool) { + if (bool) { + add_channel_to_message(msg, channel); + } else { + msg.channel_ids = _.without(msg.channel_ids, channel); + } + } + }; + } + + Object.defineProperties(msg, { + is_starred: property_descr("channel_starred"), + is_needaction: property_descr("channel_inbox"), + }); + + if (_.contains(data.needaction_partner_ids, session.partner_id)) { + msg.is_needaction = true; + } + if (_.contains(data.starred_partner_ids, session.partner_id)) { + msg.is_starred = true; + } + return msg; +} + +function add_channel_to_message (message, channel_id) { + message.channel_ids.push(channel_id); + message.channel_ids = _.uniq(message.channel_ids); +} + +function post_channel_message (data) { + return ChannelModel.call('message_post', [data.channel_id], { + message_type: 'comment', + content_subtype: 'html', + partner_ids: data.partner_ids, + body: data.content, + subtype: 'mail.mt_comment', + attachment_ids: data.attachment_ids, + }); +} + +function post_document_message (model_name, res_id, data) { + var values = { + attachment_ids: data.attachment_ids, + body: data.content, + content_subtype: data.content_subtype, + message_type: data.message_type, + partner_ids: data.partner_ids, + subtype: data.subtype, + subtype_id: data.subtype_id, + }; + + var model = new Model(model_name); + return model.call('message_post', [res_id], values).then(function (msg_id) { + return MessageModel.call('message_format', [msg_id]).then(function (msgs) { + msgs[0].model = model_name; + msgs[0].res_id = res_id; + add_message(msgs[0]); + }); + }); +} + +function add_channel (data, options) { + options = typeof options === "object" ? options : {}; + var channel = _.findWhere(channels, {id: data.id}); + if (channel) { + if (channel.is_folded !== (data.state === "folded")) { + channel.is_folded = (data.state === "folded"); + chat_manager.bus.trigger("channel_toggle_fold", channel); + } + } else { + channel = make_channel(data, options); + channels.push(channel); + if (!options.silent) { + chat_manager.bus.trigger("new_channel", channel); + } + if (channel.is_detached) { + chat_manager.bus.trigger("open_chat", channel); + } + } +} + +function make_channel (data, options) { + var channel = { + id: data.id, + name: data.name, + type: data.type || "public", + all_history_loaded: false, + uuid: data.uuid, + is_detached: data.is_minimized, + is_folded: data.state === "folded", + loaded: false, + autoswitch: 'autoswitch' in options ? options.autoswitch : true, + hidden: options.hidden, + }; + if (data.public === "private") { + channel.type = "private"; + } + if ('direct_partner' in data) { + channel.type = "dm"; + channel.name = data.direct_partner[0].name; + channel.status = data.direct_partner[0].im_status; + } + return channel; +} + +// options: domain, load_more +function fetch_from_channel (channel_id, options) { + var domain = + (channel_id === "channel_inbox") ? [['needaction', '=', true]] : + (channel_id === "channel_starred") ? [['starred', '=', true]] : + [['channel_ids', 'in', channel_id]]; + + options = options || {}; + if (options.domain) { + domain = new data.CompoundDomain(domain, options.domain || []); + } + if (options.load_more) { + var min_message_id = _.chain(messages) + .filter(function (msg) { return _.contains(msg.channel_ids, channel_id); }) + .pluck("id") + .min() + .value(); + + domain = new data.CompoundDomain([['id', '<', min_message_id]], domain); + } + + return MessageModel.call('message_fetch', [domain], {limit: LIMIT}).then(function (msgs) { + var channel = _.findWhere(channels, {id: channel_id}); + channel.loaded = true; + if (!channel.all_history_loaded) { + channel.all_history_loaded = msgs.length < LIMIT; + } + + _.each(msgs, function (msg) { + add_message(msg, {channel_id: channel_id, silent: true}); + }); + return _.filter(messages, function (m) { + return _.contains(m.channel_ids, channel_id); + }); + }); +} + +// options: force_fetch +function fetch_document_messages (ids, options) { + var loaded_msgs = _.filter(messages, function (message) { + return _.contains(ids, message.id); + }); + var loaded_msg_ids = _.pluck(loaded_msgs, 'id'); + + options = options || {}; + if (options.force_fetch || _.difference(ids.slice(0, LIMIT), loaded_msg_ids).length) { + var ids_to_load = _.difference(ids, loaded_msg_ids).slice(0, LIMIT); + + return MessageModel.call('message_format', [ids_to_load]).then(function (msgs) { + var processed_msgs = []; + _.each(msgs, function (msg) { + processed_msgs.push(add_message(msg, {silent: true})); + }); + return _.sortBy(loaded_msgs.concat(processed_msgs), function (msg) { + return msg.date; + }); + }); + } else { + return $.when(loaded_msgs); + } +} + +// Public interface +//---------------------------------------------------------------------------------- +var chat_manager = { + post_message: post_channel_message, + post_message_in_document: post_document_message, + + get_messages: function (options) { + if ('channel_id' in options) { // channel message + var channel = _.findWhere(channels, {id: options.channel_id}); + if (channel.loaded) { + return $.when(_.filter(messages, function (message) { + return _.contains(message.channel_ids, channel.id); + })); + } else { + return fetch_from_channel(options.channel_id); + } + } else { // chatter message + } + }, + fetch: function (channel_id, domain) { + return fetch_from_channel(channel_id, {domain: domain}); + }, + fetch_more: function (channel_id, domain) { + return fetch_from_channel(channel_id, {domain: domain, load_more: true}); + }, + /** + * Fetches chatter messages from their ids + */ + fetch_messages: function (message_ids, options) { + return fetch_document_messages(message_ids, options); + }, + toggle_star_status: function (message_id) { + var msg = _.findWhere(messages, { id: message_id }); + + return MessageModel.call('set_message_starred', [[message_id], !msg.is_starred]).then(function () { + msg.is_starred = !msg.is_starred; + chat_manager.bus.trigger('update_message', msg); + }); + }, + mark_as_read: function (message_id) { + return MessageModel.call('set_message_done', [[message_id]]).then(function () { + var message = _.findWhere(messages, { id: message_id }); + message.channel_ids = _.without(message.channel_ids, "channel_inbox"); + chat_manager.bus.trigger('update_message', message); + }); + }, + + get_channels: function () { + return _.clone(channels); + }, + + get_channel: function (id) { + return _.findWhere(channels, {id: id}) || channels[0]; + }, + + all_history_loaded: function (channel_id) { + return this.get_channel(channel_id).all_history_loaded; + }, + + get_emojis: function() { + return emojis; + }, + + get_needaction_counter: function () { + return needaction_counter; + }, + + detach_channel: function (channel_id) { + var channel = this.get_channel(channel_id); + return ChannelModel.call("channel_minimize", [channel.uuid, true]); + }, + remove_chatter_messages: function (model) { + messages = _.reject(messages, function (message) { + return message.channel_ids.length === 0 && message.model === model; + }); + }, + bus: new core.Bus(), + + create_channel: function (name, type) { + var method = type === "dm" ? "channel_get" : "channel_create"; + var args = type === "dm" ? [[name]] : [name, type]; + + return ChannelModel + .call(method, args) + .then(add_channel) + .then(function () { + bus.restart_poll(); + }); + }, + join_channel: function (channel_id) { + return ChannelModel + .call('channel_join_and_get_info', [[channel_id]]) + .then(add_channel); + }, + + unsubscribe: function (channel_id) { + var def; + var channel = chat_manager.get_channel(channel_id); + if (channel.type === "dm") { + def = ChannelModel.call('channel_pin', [channel.uuid, false]); + } else { + def = ChannelModel.call('action_unfollow', [[channel_id]]); + } + return def.then(function () { + channels = _.without(channels, channel); + }); + }, + close_chat_session: function (channel_id) { + var channel = _.findWhere(channels, {id: channel_id}); + ChannelModel.call("channel_fold", [], {uuid : channel.uuid, state : "closed"}); + }, + fold_channel: function (channel_id) { + var channel = _.findWhere(channels, {id: channel_id}); + return ChannelModel.call("channel_fold", [], {uuid : channel.uuid}).then(function () { + channel.is_folded = !channel.is_folded; + }); + }, +}; + +// Initialization +// --------------------------------------------------------------------------------- +function init () { + add_channel({ + id: "channel_inbox", + name: _t("Inbox"), + type: "static" + }); + + add_channel({ + id: "channel_starred", + name: _t("Starred"), + type: "static" + }); + + var load_channels = session.rpc('/mail/client_action').then(function (result) { + _.each(result.channel_slots.channel_channel, add_channel); + _.each(result.channel_slots.channel_private_group, add_channel); + _.each(result.channel_slots.channel_direct_message, add_channel); + needaction_counter = result.needaction_inbox_counter; + }); + + var load_emojis = session.rpc("/mail/chat_init").then(function (result) { + emojis = result.emoji; + _.each(emojis, function(emoji) { + emoji_substitutions[emoji.source] = emoji.substitution; + }); + }); + + bus.on('notification', null, function (notification) { + var model = notification[0][1]; + if (model === 'mail.channel') { + // new message in a channel + var message = notification[1]; + var channel_id = message.channel_ids[0]; + add_message(message, { channel_id: channel_id, show_notification: true} ); + } + if (model === 'res.partner') { + var chat_session = notification[1]; + if ((chat_session.channel_type === "channel") && (chat_session.public === "private") && (chat_session.state === "open")) { + add_channel(chat_session, {autoswitch: false}); + if (!chat_session.is_minimized) { + web_client.do_notify(_t("Private Channel"), _t("You have been invited to: ") + chat_session.name); + } + } + // partner specific change (open a detached window for example) + if ((chat_session.state === "open") || (chat_session.state === "folded")) { + add_channel(chat_session, {autoswitch: false, silent: true, hidden: true}); + if (chat_session.is_minimized) { + chat_manager.bus.trigger("open_chat", chat_session); + } + } + if (chat_session.state === "closed") { + chat_manager.bus.trigger("close_chat", chat_session); + } + } + }); + + return $.when(load_channels, load_emojis).then(function () { + bus.start_polling(); + return chat_manager; + }); +} + +return init(); + +}); diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js new file mode 100644 index 00000000000..3415789917d --- /dev/null +++ b/addons/mail/static/src/js/chatter.js @@ -0,0 +1,897 @@ +odoo.define('mail.Chatter', function (require) { +"use strict"; + +var chat_manager = require('mail.chat_manager'); +var ChatComposer = require('mail.ChatComposer'); +var ChatThread = require('mail.ChatThread'); + +var ajax = require('web.ajax'); +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var form_common = require('web.form_common'); +var session = require('web.session'); +var web_client = require('web.web_client'); + +var _t = core._t; +var qweb = core.qweb; + +// ----------------------------------------------------------------------------- +// Followers Widget ('mail_followers' widget) +// +// Since it is displayed on a form view, it extends 'AbstractField' widget. +// +// Note: the followers widget is moved inside the chatter by the chatter itself +// for layout purposes. +// ----------------------------------------------------------------------------- +var Followers = form_common.AbstractField.extend({ + template: 'mail.Followers', + + init: function () { + this._super.apply(this, arguments); + + this.image = this.node.attrs.image || 'image_small'; + this.comment = this.node.attrs.help || false; + this.ds_model = new data.DataSetSearch(this, this.view.model); + this.ds_users = new data.DataSetSearch(this, 'res.users'); + + this.value = []; + this.followers = []; + this.data_subtype = {}; + + this.view_is_editable = this.__parentedParent.is_action_enabled('edit'); + }, + + start: function () { + // use actual_mode property on view to know if the view is in create mode anymore + this.view.on("change:actual_mode", this, this.on_check_visibility_mode); + this.on_check_visibility_mode(); + this.reinit(); + this.bind_events(); + return this._super(); + }, + + on_check_visibility_mode: function () { + this.set({"force_invisible": this.view.get("actual_mode") === "create"}); + }, + + set_value: function (_value) { + this.value = _value; + this._super(_value); + }, + + reinit: function () { + this.data_subtype = {}; + this.message_is_follower = undefined; + this.display_buttons(); + }, + + bind_events: function () { + var self = this; + + // event: click on '(Un)Follow' button, that toggles the follow for uid + this.$el.on('click', '.o_followers_follow_button', function () { + if ($(this).hasClass('o_followers_notfollow')) { + self.do_follow(); + } else { + self.do_unfollow({user_ids: [session.uid]}); + } + }); + + // event: click on a subtype, that (un)subscribe for this subtype + this.$el.on('click', '.o_subtypes_list input', function(event) { + event.stopPropagation(); + self.do_update_subscription(event); + var $list = self.$('.o_subtypes_list'); + if (!$list.hasClass('open')) { + $list.addClass('open'); + } + if (self.$('.o_subtypes_list ul')[0].children.length < 1) { + $list.removeClass('open'); + } + }); + + // event: click on 'invite' button, that opens the invite wizard + this.$el.on('click', '.o_add_follower', function(event) { + event.preventDefault(); + self.on_invite_follower(false); + }); + this.$el.on('click', '.o_add_follower_channel', function(event) { + event.preventDefault(); + self.on_invite_follower(true); + }); + + // event: click on 'edit_subtype(pencil)' button to edit subscription + this.$el.on('click', '.o_edit_subtype', self.on_edit_subtype); + this.$el.on('click', '.o_remove_follower', self.on_remove_follower); + this.$el.on('click', 'a[data-partner]', self.on_follower_clicked); + }, + + on_edit_subtype: function (event) { + var self = this; + var $currentTarget = $(event.currentTarget); + var follower_id = $currentTarget.data('oe-id'); + var is_channel = $currentTarget.data('oe-model') === 'mail.channel'; + self.dialog = new Dialog(this, { + size: 'small', + title: _t('Edit Subscription of ') + $currentTarget.siblings('a').text(), + buttons: [ + { + text: _t("Apply"), + classes: 'btn-primary', + click: function () { + self.do_update_subscription(event, follower_id, is_channel); + }, + close: true + }, + { + text: _t("Cancel"), + close: true, + }, + ], + }).open(); + return self.fetch_subtypes($currentTarget.data('id')); + }, + + on_invite_follower: function (channel_only) { + var self = this; + var action = { + type: 'ir.actions.act_window', + res_model: 'mail.wizard.invite', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + name: _t('Invite Follower'), + target: 'new', + context: { + 'default_res_model': this.view.dataset.model, + 'default_res_id': this.view.datarecord.id, + 'mail_invite_follower_channel_only': channel_only, + }, + }; + this.do_action(action, { + on_close: function () { + self.read_value(); + }, + }); + }, + + on_remove_follower: 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.do_unfollow({partner_ids: [res_id]}); + } else { + return this.do_unfollow({channel_ids: [res_id]}); + } + }, + + on_follower_clicked: function (event) { + event.preventDefault(); + var partner_id = $(event.target).data('partner'); + var state = { + 'model': 'res.partner', + 'id': partner_id, + 'title': this.record_name + }; + web_client.action_manager.do_push_state(state); + var action = { + type:'ir.actions.act_window', + view_type: 'form', + view_mode: 'form', + res_model: 'res.partner', + views: [[false, 'form']], + res_id: partner_id, + }; + this.do_action(action); + }, + + read_value: function () { + var self = this; + return this.ds_model.read_ids([this.view.datarecord.id], ['message_follower_ids']) + .then(function (results) { + self.value = results[0].message_follower_ids; + self.render_value(); + }); + }, + + render_value: function () { + this.reinit(); + return this.fetch_followers(this.value); + }, + + fetch_followers: function (value_) { + this.value = value_ || []; + return ajax.jsonRpc('/mail/read_followers', 'call', {'follower_ids': this.value}) + .then(this.proxy('display_followers'), this.proxy('fetch_generic')) + .then(this.proxy('display_buttons')) + .then(this.proxy('fetch_subtypes')); + }, + + /** Read on res.partner failed: fall back on a generic case + - fetch current user partner_id (call because no other smart solution currently) FIXME + - then display a generic message about followers */ + fetch_generic: function () { + var self = this; + + return this.ds_users.call('read', [[session.uid], ['partner_id']]) + .then(function (results) { + var pid = results[0].partner_id[0]; + self.message_is_follower = (_.indexOf(self.value, pid) !== -1); + }) + .then(self.proxy('display_generic')); + }, + + _format_followers: function (count){ + var str = ''; + if(count <= 0){ + str = _t('No follower'); + }else if(count === 1){ + str = _t('One follower'); + }else{ + str = ''+count+' '+_t('followers'); + } + return str; + }, + + /* Display generic info about follower, for people not having access to res_partner */ + display_generic: function () { + this.$('.o_followers_list').empty(); + this.$('.o_followers_count').html(this._format_followers(this.value.length)); + }, + + /** Display the followers */ + display_followers: function (records) { + var self = this; + this.followers = records || this.followers; + + // clean and display title + var $followers_list = this.$('.o_followers_list').empty(); + this.$('.o_followers_count').html(this._format_followers(this.followers.length)); + var user_follower = _.filter(this.followers, function (rec) { return rec.is_uid;}); + this.message_is_follower = user_follower.length >= 1; + this.follower_id = this.message_is_follower ? user_follower[0].id : undefined; + + // render the dropdown content + $(qweb.render('mail.Followers.add_more', {'widget': self})).appendTo($followers_list); + var $follower_li; + _(this.followers).each(function (record) { + $follower_li = $(qweb.render('mail.Followers.partner', { + 'record': _.extend(record, {'avatar_url': '/web/image/' + record.res_model + '/' + record.res_id + '/image_small'}), + 'widget': self}) + ); + $follower_li.appendTo($followers_list); + + // On mouse-enter it will show the edit_subtype pencil. + if (record.is_editable) { + $follower_li.on('mouseenter mouseleave', function(e) { + $(e.currentTarget).find('.o_edit_subtype').toggleClass('hide', e.type === 'mouseleave'); + }); + } + }); + }, + + display_buttons: function () { + if (this.message_is_follower) { + this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following'); + } else { + this.$('button.o_followers_follow_button').removeClass('o_followers_following').addClass('o_followers_notfollow'); + } + }, + + /** Fetch subtypes, only if current user is follower */ + fetch_subtypes: function (follower_id) { + var self = this; + var dialog = false; + + if (follower_id) { + dialog = true; + } + else { + this.$('.o_subtypes_list ul').empty(); + if (! this.message_is_follower) { + this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', true); + return; + } + else { + this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false); + } + } + if (this.follower_id) { + return ajax.jsonRpc('/mail/read_subscription_data', 'call', { + res_model: this.view.model, + res_id: this.view.datarecord.id, + follower_id: follower_id, + }).then(function (data) { + self.display_subtypes(data, dialog); + }); + } else { + return $.Deferred().resolve(); + } + }, + + /** Display subtypes: {'name': default, followed} */ + display_subtypes:function (data, dialog) { + var self = this; + var old_parent_model; + var $list; + if (dialog) { + $list = this.dialog.$el; + } else { + $list = this.$('.o_subtypes_list ul'); + } + $list.empty(); + + this.data_subtype = data; + this.records_length = $.map(data, function(value, index) { return index; }).length; + + if (this.records_length > 1) { + self.display_followers(); + } + + _.each(data, function (record) { + if (old_parent_model !== record.parent_model && old_parent_model !== undefined) { + $list.append($('
  • ').addClass('divider')); + } + old_parent_model = record.parent_model; + record.followed = record.followed || undefined; + $(qweb.render('mail.Followers.subtype', {'record': record, + 'dialog': dialog})) + .appendTo($list); + }); + }, + + do_follow: function () { + var context = new data.CompoundContext(this.build_context(), {}); + this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false); + this.ds_model.call('message_subscribe_users', [[this.view.datarecord.id], + [session.uid], + undefined, + context]) + .then(this.proxy('read_value')); + + _.each(this.$('.o_subtypes_list input'), function (record) { + $(record).attr('checked', 'checked'); + }); + }, + + /** + * Remove users, partners, or channels from the followers + * @param {Array} [ids.user_ids] the user ids + * @param {Array} [ids.partner_ids] the partner ids + * @param {Array} [ids.channel_ids] the channel ids + */ + do_unfollow: function (ids) { + if (confirm(_t("Warning! \n If you remove a follower, he won't be notified of any email or discussion on this document. Do you really want to remove this follower ?"))) { + _(this.$('.o_subtype_checkbox')).each(function (record) { + $(record).attr('checked',false); + }); + + this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', true); + var context = new data.CompoundContext(this.build_context(), {}); + + if (ids.partner_ids || ids.channel_ids) { + return this.ds_model.call( + 'message_unsubscribe', [ + [this.view.datarecord.id], + ids.partner_ids, + ids.channel_ids, + context] + ).then(this.proxy('read_value')); + } else { + return this.ds_model.call( + 'message_unsubscribe_users', [ + [this.view.datarecord.id], + ids.user_ids, + context] + ).then(this.proxy('read_value')); + } + } + return false; + }, + + do_update_subscription: function (event, follower_id, is_channel) { + var self = this; + var kwargs = {}; + var ids = {}; + var action_subscribe; + var subtypes; + this.data_subtype = {}; + + if (follower_id !== undefined) { + // Subtypes edited from the modal + action_subscribe = 'message_subscribe'; + subtypes = this.dialog.$('input[type="checkbox"]'); + if (is_channel) { + ids.channel_ids = [follower_id]; + } else { + ids.partner_ids = [follower_id]; + } + } else { + action_subscribe = 'message_subscribe_users'; + subtypes = this.$('.o_followers_actions input[type="checkbox"]'); + ids.user_ids = [session.uid]; + } + kwargs = _.extend(kwargs, ids); + + // Get the subtype ids + var checklist = []; + _(subtypes).each(function (record) { + if ($(record).is(':checked')) { + checklist.push(parseInt($(record).data('id'))); + } + }); + kwargs.subtype_ids = checklist; + + // If no more subtype followed, unsubscribe the follower + if (!checklist.length) { + if (!this.do_unfollow(ids)) { + $(event.target).attr("checked", "checked"); + } else { + self.$('.o_subtypes_list ul').empty(); + } + } else { + kwargs.context = new data.CompoundContext(this.build_context(), {}); + return this.ds_model._model.call(action_subscribe, [[this.view.datarecord.id]], kwargs).then(this.proxy('read_value')); + } + }, +}); + +// ----------------------------------------------------------------------------- +// 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 = ChatComposer.extend({ + template: 'mail.chatter.ChatComposer', + + init: function (parent, dataset, options) { + this._super(parent, options); + this.thread_dataset = dataset; + this.suggested_partners = []; + this.options = _.defaults(this.options, { + display_mode: 'textarea', + record_name: false, + is_log: false, + internal_subtypes: [], + }); + this.events = _.extend(this.events, { + 'click .o_composer_button_full_composer': 'on_open_full_composer', + }); + }, + + willStart: function () { + if (this.options.is_log) { + return this._super.apply(this, arguments); + } + return $.when(this._super.apply(this, arguments), this.message_get_suggested_recipients()); + }, + + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + autosize(self.$input); + }); + }, + + preprocess_message: function () { + var self = this; + var def = $.Deferred(); + this._super().then(function (message) { + message = _.extend(message, { + subtype_id: false, + subtype: 'mail.mt_comment', + message_type: 'comment', + content_subtype: 'html', + }); + + // Subtype + if (self.options.is_log) { + var subtype_id = parseInt(self.$('.o_chatter_composer_subtype_select').val()); + if (_.indexOf(_.pluck(self.options.internal_subtypes, 'id'), subtype_id) === -1) { + message.subtype = 'mail.mt_note'; + } else { + message.subtype_id = subtype_id; + } + } + + // 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(self.context, { + mail_post_autofollow: true, + mail_post_autofollow_partner_ids: partner_ids, + }); + def.resolve(message); + }); + } else { + def.resolve(message); + } + + }); + + return def; + }, + + /** + * Override keydown event handler to go to next line on ENTER and send message on CTRL+ENTER + */ + on_keydown: function (event) { + if (!event.ctrlKey && (event.keyCode === $.ui.keyCode.ENTER) && !this.get('mention_partners').length) { + return; + } + this._super(event); + }, + + message_get_suggested_recipients: function () { + var self = this; + var email_addresses = _.pluck(this.suggested_partners, 'email_address'); + return this.thread_dataset + .call('message_get_suggested_recipients', [[this.context.default_res_id], this.context]) + .done(function (suggested_recipients) { + var thread_recipients = suggested_recipients[self.context.default_res_id]; + _.each(thread_recipients, function (recipient) { + var parsed_email = parse_email(recipient[1]); + if (_.indexOf(email_addresses, parsed_email[1]) === -1) { + self.suggested_partners.push({ + checked: true, + partner_id: recipient[0], + full_name: recipient[1], + name: parsed_email[0], + email_address: parsed_email[1], + reason: recipient[2], + }); + } + }); + }); + }, + + /** + * 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 additionnal partners (not necessary registered partners), and open a popup form view + * for the ones who informations is missing. + * @param Array : 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 find_done = $.Deferred(); + if (names_to_find.length > 0) { + find_done = self.thread_dataset.call('message_partner_info_from_emails', [[this.context.default_res_id], names_to_find]); + } else { + find_done.resolve([]); + } + + // for unknown names + incomplete partners -> open popup - cancel = remove from recipients + $.when(find_done).pipe(function (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 = parse_email(partner_name); + + var dialog = new form_common.FormViewDialog(self, { + res_model: 'res.partner', + res_id: partner_id, + context: { + force_email: true, + ref: "compound_context", + default_name: parsed_email[0], + default_email: parsed_email[1], + }, + title: _t("Please complete partner's informations"), + }).open(); + dialog.on('closed', self, function () { + deferred.resolve(); + }); + dialog.view_form.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); + find_done = $.Deferred(); + if (new_names_to_find.length > 0) { + find_done = self.thread_dataset.call('message_partner_info_from_emails', [[self.context.default_res_id], new_names_to_find, true]); + } else { + find_done.resolve([]); + } + $.when(find_done).pipe(function (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 recipient_done = $.Deferred(); + if (this.options.is_log) { + recipient_done.resolve([]); + } else { + var checked_suggested_partners = this.get_checked_suggested_partners(); + recipient_done = this.check_suggested_partners(checked_suggested_partners); + } + recipient_done.then(function (partner_ids) { + var context = { + default_parent_id: self.id, + default_body: 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, + mail_post_autofollow_partner_ids: partner_ids, + }; + + 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; + } + + self.do_action({ + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }, { + on_close: function() { + self.trigger('need_refresh'); + }, + }); + }); + } +}); + +// ----------------------------------------------------------------------------- +// Document Chatter ('mail_thread' widget) +// +// Since it is displayed on a form view, it extends 'AbstractField' widget. +// ----------------------------------------------------------------------------- +var Chatter = form_common.AbstractField.extend({ + template: 'mail.Chatter', + + events: { + "click .o_mail_redirect": "on_click_redirect", + "click .o_chatter_button_new_message": "on_open_composer_new_message", + "click .o_chatter_button_log_note": "on_open_composer_log_note", + }, + + init: function () { + this._super.apply(this, arguments); + this.messages = []; + this.model = this.view.dataset.model; + this.res_id = undefined; + this.context = this.options.context || {}; + }, + + start: function () { + var self = this; + + // Move the follower's widget (if any) inside the chatter + var followers = this.field_manager.fields.message_follower_ids; + if (followers) { + this.$('.o_chatter_topbar').append(followers.$el); + } + + this.thread = new ChatThread(this, { + display_order: ChatThread.ORDER.DESC, + display_document_link: false, + }); + this.thread.on('load_more_messages', this, this.load_more_messages); + this.thread.on('toggle_star_status', this, function (message_id) { + chat_manager.toggle_star_status(message_id); + }); + + var def1 = this._super.apply(this, arguments); + var def2 = this.thread.appendTo(this.$el); + + return $.when(def1, def2).then(function () { + chat_manager.bus.on('new_message', self, self.on_new_message); + chat_manager.bus.on('update_message', self, self.on_update_message); + }); + }, + + fetch_and_render_thread: function (ids, options) { + var self = this; + return chat_manager.fetch_messages(ids, options).then(function (raw_messages) { + self.thread.render(raw_messages, {display_load_more: raw_messages.length < ids.length}); + }); + }, + + on_post_message: function (message) { + var self = this; + chat_manager + .post_message_in_document(this.model, this.res_id, message) + .then(function () { + self.close_composer(); + }) + .fail(function () { + // todo: display notification + }); + }, + + /** + * When a message is correctly posted, fetch its data to render it + * @param {Number} message_id : the identifier of the new posted message + * @returns {Deferred} + */ + on_new_message: function (message) { + if (message.model === this.model && message.res_id === this.res_id) { + this.msg_ids.unshift(message.id); + this.fetch_and_render_thread(this.msg_ids); + } + }, + + on_update_message: function (message) { + if (message.model === this.model && message.res_id === this.res_id) { + this.fetch_and_render_thread(this.msg_ids); + } + }, + + on_click_redirect: function (event) { + event.preventDefault(); + this.do_action({ + type:'ir.actions.act_window', + view_type: 'form', + view_mode: 'form', + res_model: $(event.target).data('oe-model'), + views: [[false, 'form']], + res_id: $(event.target).data('oe-id'), + }); + }, + + load_more_messages: function () { + this.fetch_and_render_thread(this.msg_ids, {force_fetch: true}); + }, + + /** + * The value of the field has change (modification or switch to next document). Form view is + * not re-rendered, simply updated. A re-fetch is needed. + * @override + */ + render_value: function () { + // update context + this.context = _.extend({ + default_res_id: this.view.datarecord.id || false, + default_model: this.view.model || false, + }, this.options.context || {}); + this.thread_dataset = this.view.dataset; + this.res_id = this.view.datarecord.id; + this.record_name = this.view.datarecord.display_name; + this.msg_ids = this.get_value() || []; + + // destroy current composer, if any + if (this.composer) { + this.composer.destroy(); + } + + // fetch and render messages of current document + return this.fetch_and_render_thread(this.msg_ids); + }, + // composer toggle + on_open_composer_new_message: function () { + this.open_composer(); + }, + on_open_composer_log_note: function () { + this.open_composer({is_log: true}); + }, + open_composer: function (options) { + var self = this; + var old_composer = this.composer; + // create the new composer + this.composer = new ChatterComposer(this, this.thread_dataset, { + internal_subtypes: this.options.internal_subtypes, + is_log: options && options.is_log, + context: this.context, + record_name: this.record_name, + }); + this.composer.insertBefore(this.$('.o_mail_thread')).then(function () { + // destroy existing composer + if (old_composer) { + old_composer.destroy(); + } + self.composer.on('post_message', self, self.on_post_message); + }); + + }, + close_composer: function () { + if (this.composer.is_empty()) { + this.composer.do_hide(); + } + }, + + destroy: function () { + chat_manager.remove_chatter_messages(this.model); + this._super.apply(this, arguments); + }, + +}); + +// ----------------------------------------------------------------------------- +// Utils +// ----------------------------------------------------------------------------- +/** + * Parses text to find email: Tagada -> [Tagada, address@mail.fr] or False + */ +var parse_email = function (text) { + var result = text.match(/(.*)<(.*@.*)>/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[2])]; + } + result = text.match(/(.*@.*)/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[1])]; + } + return [text, false]; +}; +/** + * Replaces textarea text into html text (add

    , ) + * TDE note : should be done server-side, in Python -> use mail.compose.message ? + */ +var get_text2html = function (text) { + return text + .replace(/((?:https?|ftp):\/\/[\S]+)/g,'$1 ') + .replace(/[\n\r]/g,'
    '); +}; + +core.form_widget_registry.add('mail_followers', Followers); +core.form_widget_registry.add('mail_thread', Chatter); + +return Chatter; + +}); diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js new file mode 100644 index 00000000000..75244319053 --- /dev/null +++ b/addons/mail/static/src/js/composer.js @@ -0,0 +1,486 @@ +odoo.define('mail.ChatComposer', function (require) { +"use strict"; + +var chat_manager = require('mail.chat_manager'); + +var core = require('web.core'); +var data = require('web.data'); +var Model = require('web.Model'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var Composer = Widget.extend({ + template: "mail.ChatComposer", + + events: { + "keydown .o_composer_input": "on_keydown", + "keyup .o_composer_input": "on_keyup", + "change input.o_form_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_mention_proposition": "on_click_mention_item", + "mouseover .o_mention_proposition": "on_hover_mention_proposition", + }, + + init: function (parent, options) { + this._super.apply(this, arguments); + this.options = _.defaults(options || {}, { + context: {}, + mention_delimiter: '@', + mention_min_length: 2, + mention_typing_speed: 400, + mention_fetch_limit: 8, + }); + 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.PartnerModel = new Model('res.partner'); + this.set('mention_partners', []); // proposition of not-mention partner matching the mention_word + this.set('mention_selected_partners', []); // contains the mention partners sorted as they appear in the input text + }, + + start: function () { + var self = this; + + this.$attachment_button = this.$(".o_composer_button_add_attachment"); + this.$attachments_list = this.$('.o_composer_attachments_list'); + this.$mention_partner_tags = this.$('.o_composer_mentioned_partners'); + this.$mention_dropdown = this.$('.o_composer_mention_dropdown'); + this.$input = this.$('.o_composer_input'); + this.$input.focus(); + + // Attachments + $(window).on(this.fileupload_id, this.on_attachment_loaded); + this.on("change:attachment_ids", this, this.render_attachments); + + // Emoji + this.$('.o_composer_button_emoji').popover({ + placement: 'top', + content: function() { + if (!self.$emojis) { // lazy rendering + self.$emojis = $(QWeb.render('mail.ChatComposer.emojis', { + emojis: chat_manager.get_emojis(), + })); + self.$emojis.filter('.o_mail_emoji').on('click', self, self.on_click_emoji_img); + } + return self.$emojis; + }, + html: true, + container: '.o_composer_emoji', + trigger: 'focus', + }); + + // Mention + this.on('change:mention_partners', this, this.render_mention_partners); + this.on('change:mention_selected_partners', this, this.render_mention_selected_partners); + return this._super(); + }, + + preprocess_message: function () { + // Return a deferred as this function is extended with asynchronous + // behavior for the chatter composer + return $.when({ + content: this.mention_preprocess_message(this.$input.val()), + attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), + partner_ids: _.pluck(this.get('mention_selected_partners'), 'id'), + }); + }, + + send_message: function () { + if (this.is_empty() || !this.do_check_attachment_upload()) { + return; + } + + var self = this; + this.preprocess_message().then(function (message) { + self.trigger('post_message', message); + + // Empty input, selected partners and attachments + self.$input.val(''); + self.set('mention_selected_partners', []); + self.set('attachment_ids', []); + + self.$input.focus(); + }); + }, + + // Events + on_click_add_attachment: function () { + this.$('input.o_form_input_file').click(); + this.$input.focus(); + }, + + on_click_emoji_img: function(event) { + this.$input.val(this.$input.val() + " " + $(event.currentTarget).data('emoji') + " "); + this.$input.focus(); + }, + + on_keydown: function (event) { + switch(event.which) { + // UP, DOWN: prevent moving cursor if navigation in mention propositions + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + if (this.get('mention_partners').length) { + event.preventDefault(); + } + break; + // BACKSPACE, DELETE: check if need to remove a mention + case $.ui.keyCode.BACKSPACE: + case $.ui.keyCode.DELETE: + this.mention_check_remove(); + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case $.ui.keyCode.ENTER: + event.preventDefault(); + if (!this.get('mention_partners').length) { + 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: + this.set('mention_partners', []); + break; + // ENTER, UP, DOWN: check if navigation in mention propositions + case $.ui.keyCode.ENTER: + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + this.mention_proposition_navigation(event.which); + break; + // Otherwise, check if a mention is typed + default: + this.mention_word = this.mention_detect_delimiter(); + if (this.mention_word) { + this.mention_word_changed(); + } else { + this.set('mention_partners', []); // close the dropdown + } + } + }, + + // Attachments + on_attachment_change: function(event) { + var $target = $(event.target); + if ($target.val() !== '') { + var filename = $target.val().replace(/.*[\\\/]/,''); + // if the files exits for this answer, delete the file before upload + var attachments = []; + for (var i in this.get('attachment_ids')) { + if ((this.get('attachment_ids')[i].filename || this.get('attachment_ids')[i].name) === filename) { + if (this.get('attachment_ids')[i].upload) { + return false; + } + this.AttachmentDataSet.unlink([this.get('attachment_ids')[i].id]); + } else { + attachments.push(this.get('attachment_ids')[i]); + } + } + // submit filename + this.$('form.o_form_binary_form').submit(); + this.$attachment_button.prop('disabled', true); + + attachments.push({ + 'id': 0, + 'name': filename, + 'filename': filename, + 'url': '', + 'upload': true, + 'mimetype': '', + }); + this.set('attachment_ids', attachments); + } + }, + on_attachment_loaded: function(event, result) { + var attachment_ids = []; + if (result.error || !result.id ) { + this.do_warn(result.error); + attachment_ids = _.filter(this.get('attachment_ids'), function (val) { return !val.upload; }); + } else { + _.each(this.get('attachment_ids'), function(a) { + if (a.filename === result.filename && a.upload) { + attachment_ids.push({ + 'id': result.id, + 'name': result.name || result.filename, + 'filename': result.filename, + 'mimetype': result.mimetype, + 'url': session.url('/web/content', {'id': result.id, download: true}), + }); + } else { + attachment_ids.push(a); + } + }); + } + this.set('attachment_ids', attachment_ids); + 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 + on_click_mention_item: function (event) { + event.preventDefault(); + + var text_input = this.$input.val(); + var partner_id = $(event.currentTarget).data('partner-id'); + var selected_partner = _.filter(this.get('mention_partners'), function (p) { + return p.id === partner_id; + })[0]; + + // add the mention partner to the list + var mention_selected_partners = this.get('mention_selected_partners'); + if (mention_selected_partners.length) { // there are already mention partners + // get mention matches (ordered by index in the text) + var matches = this.mention_get_match(text_input); + var index = this.mention_get_index(matches, this.get_selection_positions().start); + mention_selected_partners.splice(index, 0, selected_partner); + mention_selected_partners = _.clone(mention_selected_partners); + } else { // this is the first mentionned partner + mention_selected_partners = mention_selected_partners.concat([selected_partner]); + } + this.set('mention_selected_partners', mention_selected_partners); + + // 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 + this.options.mention_delimiter + selected_partner.name + ' ' + text_right; + this.$input.val(text_input_new); + this.set_cursor_position(text_left.length+selected_partner.name.length+2); + this.set('mention_partners', []); + }, + + on_hover_mention_proposition: function (event) { + var $elem = $(event.currentTarget); + this.$('.o_mention_proposition').removeClass('active'); + $elem.addClass('active'); + }, + + mention_proposition_navigation: 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.next('.o_mention_proposition:not(.active)'); + } else { + $to = $active.prev('.o_mention_proposition:not(.active)'); + } + if ($to.length) { + $active.removeClass('active'); + $to.addClass('active'); + } + } + }, + + /** + * Return the text attached to the mention delimiter + * @returns {String|false}: the text right after the delimiter or false + */ + mention_detect_delimiter: function () { + var options = this.options; + var delimiter = options.mention_delimiter; + var text_val = this.$input.val(); + var cursor_position = this.get_selection_positions().start; + var left_string = text_val.substring(0, cursor_position); + var search_str = text_val.substring(left_string.lastIndexOf(delimiter) - 1, cursor_position); + + return validate_keyword(search_str); + + function validate_keyword (search_str) { + 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 > options.mention_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; + } + }, + + mention_word_changed: function () { + var self = this; + // start a timeout to fetch partner with the current 'mention word'. The timer avoid to start + // an RPC for each pushed key when the user is typing the partner name. + // The 'mention_typing_speed' option should approach the time for a human to type a letter. + clearTimeout(this.mention_fetch_timer); + this.mention_fetch_timer = setTimeout(function () { + self.mention_fetch_partner(self.mention_word); + }, this.options.mention_typing_speed); + }, + + mention_fetch_partner: function (search_str) { + var self = this; + this.PartnerModel + .query(['id', 'name', 'email']) + .filter([['id', 'not in', _.pluck(this.get('mention_selected_partners'), 'id')], + '|', ['name', 'ilike', search_str], ['email', 'ilike', search_str]]) + .limit(this.options.mention_fetch_limit) + .all().then(function (partners) { + self.set('mention_partners', partners); + }); + }, + + mention_check_remove: function () { + var mention_selected_partners = this.get('mention_selected_partners'); + var partners_to_remove = []; + var selection = this.get_selection_positions(); + var deleted_binf = selection.start; + var deleted_bsup = selection.end; + + var matches = this.mention_get_match(this.$input.val()); + for (var i=0; i%s", partners[i].id, partner_name); + var subtext = message.substring(start_index, end_index).replace(match[0], processed_text); + substrings.push(subtext); + start_index = end_index; + } + substrings.push(message.substring(start_index, message.length)); + return substrings.join(''); + } + return message; + }, + + render_mention_partners: function () { + if (this.get('mention_partners').length) { + this.$mention_dropdown.html(QWeb.render('mail.ChatComposer.MentionMenu', { + partners: this.get('mention_partners'), + })); + this.$mention_dropdown.addClass('open'); + } else { + this.$mention_dropdown.removeClass('open'); + this.$mention_dropdown.empty(); + } + }, + + render_mention_selected_partners: function () { + this.$mention_partner_tags.html(QWeb.render('mail.ChatComposer.MentionTags', { + partners: this.get('mention_selected_partners'), + })); + }, + + /** + * Return the matches (as RexExp.exec does) for the partner mention in the input text + * @param {String} input_text: the text to search matches + * @returns {Object[]} matches in the same format as RexExp.exec() + */ + mention_get_match: function (input_text) { + var self = this; + // create the regex of all mention partner name + var partner_names = _.pluck(this.get('mention_selected_partners'), 'name'); + var escaped_partner_names = _.map(partner_names, function (str) { + return "("+_.str.escapeRegExp(self.options.mention_delimiter+str)+")"; + }); + var regex_str = escaped_partner_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; + }, + + mention_get_index: function (matches, cursor_position) { + for (var i=0; i label { + display: inline-block; // Required for old design compatibility + width: 100px; + } + + .o_chatter_composer_subtype_select { + display: inline-block; + min-width: 150px; + width: auto; + } + } + + .o_composer { + margin-top: 10px; + + .o_composer_input { + width: 100%; + margin-bottom: 5px; + } + } + } + + .o_mail_thread { + margin-top: 10px; + } +} + +.o_followers { + float: right; + @media (max-width: @screen-xs-max) { + display: none; // Hide the followers in mobile + } + + .o_followers_actions { + position: relative; + float: right; + + .o_followers_follow_button { + width: 120px; + + .o_follow, .o_followers_unfollow, .o_followers_following { + display: none; + } + // when following, show 'following' + &.o_followers_following .o_followers_following { + display: inline; + } + // when following and hovering, show 'unfollow' + &.o_followers_following:hover .o_followers_following { + display: none; + } + &.o_followers_following:hover .o_followers_unfollow { + display: inline; + } + // when not following show 'follow' + &.o_followers_notfollow .o_follow { + display: inline; + } + } + + .o_subtype { + padding-left: 10px; + } + + } + + .o_followers_title_box { + position: relative; + float: right; + margin-left: 5px; + > button { + width: 140px; + } + + .o_followers_count { + padding-right: 5px; + } + + .o_followers_list { + font-size: small; + + .o_partner { + @partner_avatar_size: 24px; + position: relative; // required for old design compatibility + > a { + max-width: 160px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + height: @partner_avatar_size; + padding: 3px @partner_avatar_size + 8px; + } + > img { + .o-position-absolute(); + width: @partner_avatar_size; + } + .o_remove_follower { + .o-position-absolute(6px, 5px); + cursor: pointer; + } + .o_edit_subtype { + .o-position-absolute(6px, 18px); + cursor: pointer; + } + } + + } + } +} + +// Edit subtypes in dialog +.modal .modal-body > li.o_subtype { + list-style: none; +} diff --git a/addons/mail/static/src/less/composer.less b/addons/mail/static/src/less/composer.less new file mode 100644 index 00000000000..a53ffd34f70 --- /dev/null +++ b/addons/mail/static/src/less/composer.less @@ -0,0 +1,97 @@ +.o_composer_mentioned_partners, .o_composer_attachments_list { + margin-top: 5px; + text-align: justify; +} + +// Emoji size in the popover +.o_mail_emoji { + display: inline-block; + height: 24px; + width: 24px; + + > img { + cursor: pointer; + width: 100%; + } +} + +// Attachment Icons (common for chat thread and chat composer) +// ------------------------------------------------------------------ +@-webkit-keyframes oe_mail_attach_loading_anim { + 0% { background: @odoo-brand-optional } + 50% { background: darken(@odoo-brand-optional, 10%) } + 100% { background: @odoo-brand-optional } +} +@-moz-keyframes oe_mail_attach_loading_anim { + 0% { background: @odoo-brand-optional } + 50% { background: darken(@odoo-brand-optional, 10%) } + 100% { background: @odoo-brand-optional } +} +@-o-keyframes oe_mail_attach_loading_anim { + 0% { background: @odoo-brand-optional } + 50% { background: darken(@odoo-brand-optional, 10%) } + 100% { background: @odoo-brand-optional } +} +@keyframes oe_mail_attach_loading_anim { + 0% { background: @odoo-brand-optional } + 50% { background: darken(@odoo-brand-optional, 10%) } + 100% { background: @odoo-brand-optional } +} + +@o-mail-attachment-image-size: 60px; +.o_attachment { + position: relative; + width: 2*@o-mail-attachment-image-size; + display: inline-block; + height: @o-mail-attachment-image-size; + text-align: center; + margin-bottom: 15px; + + .o_image { + .o-position-absolute(@top: 0, @left: @o-mail-attachment-image-size/2); + width: @o-mail-attachment-image-size; + height: @o-mail-attachment-image-size; + } + + .o_attachment_name { + display: inline-block; + max-width: 100%; + margin-top: @o-mail-attachment-image-size; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: @odoo-main-text-color; + } + + .o_attachment_progress_bar { + display: none; + } + .o_attachment_delete { + .o-position-absolute(@top: 0, @right: 0); + color: @odoo-main-text-color; + cursor: pointer; + opacity: 0; + .o-transition(opacity, 0.2s, linear); + } + &.o_attachment_uploading { + .o_attachment_progress_bar { + display: block; + .o-position-absolute(@top: 18px, @left: 16px, @right: 16px); + height: 17px; + line-height: 13px; + color: white; + border-radius: @border-radius-base; + border: solid 1px grey; + box-shadow: 0px 1px 10px @odoo-shadow-color; + .o-animation(@name: oe_mail_attach_loading_anim, @duration: 1s, @timing-function: linear, @iteration-count: infinite); + } + .o_attachment_delete { + display: none; + } + } + + &:hover .o_attachment_delete { + opacity: 1; + .o-transition(opacity, 0.2s, linear); + } +} diff --git a/addons/mail/static/src/less/thread.less b/addons/mail/static/src/less/thread.less new file mode 100644 index 00000000000..61b3c571868 --- /dev/null +++ b/addons/mail/static/src/less/thread.less @@ -0,0 +1,71 @@ +@mail-thread-avatar-size: 50px; + +.o_mail_thread { + .o_thread_show_more { + text-align: center; + } + + .o_thread_date_separator { + margin-bottom: 15px; + border-bottom: solid @gray-lighter 2px; + text-align: center; + + .o_thread_date { + position: relative; + top: 10px; + margin: 0 auto; + padding: 0 10px; + font-weight: bold; + background: white; + } + } + + .o_thread_message { + .o-flex-display(); + margin: 10px 0; + + .o_thread_message_avatar { + .o-flex(0, 0, @mail-thread-avatar-size); + margin-right: 10px; + @media (max-width: @screen-xs-max) { + .o-flex(0, 0, @mail-thread-avatar-size/2); + } + + .o_thread_message_avatar_img { + display: block; + margin: 0 auto; // center the avatar + max-width: @mail-thread-avatar-size; + @media (max-width: @screen-xs-max) { + max-width: @mail-thread-avatar-size/2; + } + } + } + + .o_thread_message_core { + .o-flex(1, 1, auto); + + .o_mail_subject { + font-style: italic; + } + + > p { + margin: 0 0 9px; // Required by the old design to override a general rule on p's + } + + .o_thread_message_star, .o_thread_message_needaction { + cursor: pointer; + opacity: 0.5; + &:hover { + opacity: 1; + } + &.fa-star { + color: gold; + } + } + + .o_mail_redirect { + cursor: pointer; + } + } + } +} diff --git a/addons/mail/static/src/xml/chatter.xml b/addons/mail/static/src/xml/chatter.xml new file mode 100644 index 00000000000..51b0fc78eed --- /dev/null +++ b/addons/mail/static/src/xml/chatter.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + Log an internal note which will not be sent to followers, but which can be read + by users accessing this document. + + +

    + + +
    + + + + + + To: Followers of + + + + + this document + + + +
    + +
    + + + () + (no email address) +
    +
    +
    +
    + + + + +