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) +
    +
    +
    +
    + + + + +