diff options
39 files changed, 4054 insertions, 1282 deletions
diff --git a/addons/bus/models/__init__.py b/addons/bus/models/__init__.py index 208eb0c6c81..3e2808e6e2e 100644 --- a/addons/bus/models/__init__.py +++ b/addons/bus/models/__init__.py @@ -2,3 +2,4 @@ import bus import bus_presence import res_users +import res_partner diff --git a/addons/bus/models/bus_presence.py b/addons/bus/models/bus_presence.py index d89cfc9a104..445e9491a75 100644 --- a/addons/bus/models/bus_presence.py +++ b/addons/bus/models/bus_presence.py @@ -13,17 +13,17 @@ DISCONNECTION_TIMER = TIMEOUT + 5 AWAY_TIMER = 600 # 10 minutes -class ImChatPresence(models.Model): - """ User Chat Presence +class BusPresence(models.Model): + """ User Presence Its status is 'online', 'away' or 'offline'. This model should be a one2one, but is not attached to res_users to avoid database concurrence errors. Since the 'update' method is executed at each poll, if the user have multiple opened tabs, concurrence errors can happend, but are 'muted-logged'. """ - _name = 'im_chat.presence' - _description = 'User Chat Presence' + _name = 'bus.presence' + _description = 'User Presence' - _sql_constraints = [('im_chat_user_status_unique', 'unique(user_id)', 'A user can only have one IM status.')] + _sql_constraints = [('bus_user_presence_unique', 'unique(user_id)', 'A user can only have one IM status.')] user_id = fields.Many2one('res.users', 'Users', required=True, index=True, ondelete='cascade') last_poll = fields.Datetime('Last Poll', default=lambda self: fields.Datetime.now()) @@ -43,14 +43,14 @@ class ImChatPresence(models.Model): send_notification = True values = { 'last_poll': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'status' : presence and presence.status or 'offline' + 'status': presence and presence.status or 'offline' } # update the user or a create a new one - if not presence: # create a new presence for the user + if not presence: # create a new presence for the user values['status'] = 'online' values['user_id'] = self._uid self.create(values) - else: # write the user presence if necessary + else: # write the user presence if necessary if user_presence: values['last_presence'] = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT) values['status'] = 'online' @@ -69,7 +69,7 @@ class ImChatPresence(models.Model): self.env.cr.commit() # TODO : check if still necessary # notify if the status has changed if send_notification: # TODO : add user_id to the channel tuple to allow using user_watch in controller presence - self.env['bus.bus'].sendone((self._cr.dbname, 'im_chat.presence'), {'id': self._uid, 'im_status': values['status']}) + self.env['bus.bus'].sendone((self._cr.dbname, 'bus.presence'), {'id': self._uid, 'im_status': values['status']}) # gc : disconnect the users having a too old last_poll. 1 on 100 chance to do it. if random.random() < 0.01: self.check_users_disconnection() @@ -83,5 +83,5 @@ class ImChatPresence(models.Model): presences.write({'status': 'offline'}) notifications = [] for presence in presences: - notifications.append([(self._cr.dbname, 'im_chat.presence'), {'id': presence.user_id.id, 'im_status': presence.status}]) + notifications.append([(self._cr.dbname, 'bus.presence'), {'id': presence.user_id.id, 'im_status': presence.status}]) self.env['bus.bus'].sendmany(notifications) diff --git a/addons/bus/models/res_partner.py b/addons/bus/models/res_partner.py new file mode 100644 index 00000000000..358f2a41298 --- /dev/null +++ b/addons/bus/models/res_partner.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from openerp import api, fields, models + +class ResPartner(models.Model): + _inherit = 'res.partner' + + im_status = fields.Char('IM Status', compute='_compute_im_status') + + @api.multi + def _compute_im_status(self): + self.env.cr.execute(""" + SELECT P.id as id, B.status as im_status + FROM bus_presence B + JOIN res_users U ON B.user_id = U.id + JOIN res_partner P ON P.id = U.partner_id + WHERE P.id IN %s AND U.active = 't' + """, (tuple(self.ids),)) + fetch_result = self.env.cr.dictfetchall() + result = dict(((user_presence['id'], user_presence['im_status']) for user_presence in fetch_result)) + for partner in self: + partner.im_status = result.get(partner.id, 'offline') + + @api.model + def im_search(self, name, limit=20): + """ Search partner with a name and return its id, name and im_status. + Note : the user must be logged + :param name : the partner name to search + :param limit : the limit of result to return + """ + name = '%' + name + '%' + excluded_partner_ids = [self.env.user.partner_id.id] + self.env.cr.execute(""" + SELECT U.id as user_id, P.id as id, P.name as name, COALESCE(B.status, 'offline') as im_status + FROM res_users U + JOIN res_partner P ON P.id = U.partner_id + LEFT JOIN bus_presence B ON B.user_id = U.id + WHERE P.name ILIKE %s + AND P.id NOT IN %s + AND U.active = 't' + LIMIT %s + """, (name, tuple(excluded_partner_ids), limit)) + result = self.env.cr.dictfetchall() + return result diff --git a/addons/bus/models/res_users.py b/addons/bus/models/res_users.py index 5e9d664b6a8..2b7d7ccfe49 100644 --- a/addons/bus/models/res_users.py +++ b/addons/bus/models/res_users.py @@ -12,7 +12,7 @@ class ResUsers(models.Model): def _compute_im_status(self): """ Compute the im_status of the users """ res = dict((uid, 'offline') for uid in self.ids) - presences = self.env['im_chat.presence'].search([('user_id', 'in', self.ids)]) + presences = self.env['bus.presence'].search([('user_id', 'in', self.ids)]) for presence in presences: res[presence.user_id.id] = presence.status for user in self: @@ -37,7 +37,7 @@ class ResUsers(models.Model): # first query to find online employee self._cr.execute('''SELECT U.id as id, P.name as name, COALESCE(S.status, 'offline') as im_status - FROM im_chat_presence S + FROM bus_presence S JOIN res_users U ON S.user_id = U.id JOIN res_partner P ON P.id = U.partner_id WHERE '''+where_clause_base+''' @@ -52,7 +52,7 @@ class ResUsers(models.Model): # second query to find other online people if len(result) < limit: self._cr.execute('''SELECT U.id as id, P.name as name, COALESCE(S.status, 'offline') as im_status - FROM im_chat_presence S + FROM bus_presence S JOIN res_users U ON S.user_id = U.id JOIN res_partner P ON P.id = U.partner_id WHERE '''+where_clause_base+''' @@ -67,7 +67,7 @@ class ResUsers(models.Model): if len(result) < limit: self._cr.execute('''SELECT U.id as id, P.name as name, COALESCE(S.status, 'offline') as im_status FROM res_users U - LEFT JOIN im_chat_presence S ON S.user_id = U.id + LEFT JOIN bus_presence S ON S.user_id = U.id LEFT JOIN res_partner P ON P.id = U.partner_id WHERE '''+where_clause_base+''' AND U.id NOT IN %s diff --git a/addons/im_livechat/__openerp__.py b/addons/im_livechat/__openerp__.py index 5d460e6f58e..128d80489d4 100644 --- a/addons/im_livechat/__openerp__.py +++ b/addons/im_livechat/__openerp__.py @@ -30,7 +30,7 @@ Help your customers with this chat, and analyse their feedback. 'demo': [ "data/im_livechat_channel_demo.xml", ], - 'depends' : ["mail", "rating", "im_chat"], + 'depends' : ["mail", "rating"], 'installable': True, 'auto_install': False, 'application': True, diff --git a/addons/im_odoo_support/__openerp__.py b/addons/im_odoo_support/__openerp__.py index b68e1c872fd..1399441b50a 100644 --- a/addons/im_odoo_support/__openerp__.py +++ b/addons/im_odoo_support/__openerp__.py @@ -16,7 +16,7 @@ Ask your functionnal question directly to the Odoo Operators with the livechat s 'data': [ "views/im_odoo_support.xml" ], - 'depends' : ["web", "im_chat"], + 'depends' : ["web", "mail"], 'qweb': [ 'static/src/xml/im_odoo_support.xml' ], diff --git a/addons/mail/__openerp__.py b/addons/mail/__openerp__.py index 1e9149dc4fb..ca88214169e 100644 --- a/addons/mail/__openerp__.py +++ b/addons/mail/__openerp__.py @@ -25,7 +25,7 @@ Main Features * Includes an extensible generic email composition assistant, that can turn into a mass-mailing assistant and is capable of interpreting simple *placeholder expressions* that will be replaced with dynamic data when each email is actually sent. """, 'website': 'https://www.odoo.com/page/enterprise-social-network', - 'depends': ['base', 'base_setup'], + 'depends': ['base', 'base_setup', 'bus'], 'data': [ 'wizard/invite_view.xml', 'wizard/mail_compose_message_view.xml', @@ -40,7 +40,7 @@ Main Features 'views/res_config_views.xml', 'data/mail_data.xml', 'data/mail_channel_data.xml', - 'data/mail_shortcode.xml', + 'data/mail_shortcode_data.xml', 'security/mail_security.xml', 'security/ir.model.access.csv', 'views/mail_alias_views.xml', @@ -60,7 +60,10 @@ Main Features 'application': True, 'qweb': [ 'static/src/xml/mail.xml', + 'static/src/xml/mail_thread.xml', + 'static/src/xml/mail_chatter.xml', 'static/src/xml/mail_chat_common.xml', + 'static/src/xml/mail_chat_backend.xml', 'static/src/xml/mail_followers.xml', 'static/src/xml/announcement.xml', ], diff --git a/addons/mail/controllers/bus.py b/addons/mail/controllers/bus.py index 35ede172690..0a6e52a2514 100644 --- a/addons/mail/controllers/bus.py +++ b/addons/mail/controllers/bus.py @@ -6,12 +6,12 @@ from openerp import SUPERUSER_ID from openerp.http import request -class ChatController(openerp.addons.bus.controllers.main.BusController): +class MailChatController(openerp.addons.bus.controllers.main.BusController): def _default_request_uid(self): """ For Anonymous people, they receive the access right of SUPERUSER_ID since they have NO access (auth=none) !!! Each time a method from this controller is call, there is a check if the user (who can be anonymous and Sudo access) - can access to the ressource, thanks to 'is_in_session', (for instance). + can access to the ressource. """ return request.session.uid and request.session.uid or SUPERUSER_ID @@ -20,44 +20,42 @@ class ChatController(openerp.addons.bus.controllers.main.BusController): # -------------------------- def _poll(self, dbname, channels, last, options): if request.session.uid: - request.env['im_chat.presence'].update(options.get('im_presence')) - # channel to receive message - channels.append((request.db, 'im_chat.session', request.session.uid)) - ## For performance issue, the real time status notification is disabled. This means a change of status are still braoadcasted - ## but not received by anyone. Otherwise, all listening user restart their longpolling at the same time and cause a 'ConnectionPool Full Error' - ## since there is not enought cursors for everyone. Now, when a user open his list of users, an RPC call is made to update his user status list. - ##channels.append((request.db,'im_chat.presence')) - return super(ChatController, self)._poll(dbname, channels, last, options) + partner_id = request.env.user.partner_id.id + # mail channels to listen + for mail_channel in request.env['mail.channel'].search([('channel_partner_ids', 'in', [partner_id])]): + channels.append((request.db, 'mail.channel', mail_channel.id)) + # personal channel + channels.append((request.db, 'res.partner', partner_id)) + return super(MailChatController, self)._poll(dbname, channels, last, options) # -------------------------- # Anonymous routes (Common Methods) # -------------------------- - @openerp.http.route('/im_chat/post', type="json", auth="none") - def post(self, uuid, message_content, message_type): + @openerp.http.route('/mail/chat_post', type="json", auth="none") + def mail_chat_post(self, uuid, message_content, **kwargs): request_uid = self._default_request_uid() - return request.env["im_chat.message"].sudo(request_uid).send_message(request.session.uid, uuid, message_content, message_type) - - @openerp.http.route(['/im_chat/image/<string:uuid>/<int:user_id>', '/im_chat/image/<string:uuid>/<string:user_id>'], type='http', auth="none") - def image(self, uuid, user_id): - request_uid = self._default_request_uid() - # anonymous will have a user_id = False, interpreted by string - if isinstance(user_id, (basestring, unicode)): - user_id = False - # get image for the people in the channel - image_b64 = request.env['im_chat.session'].sudo(request_uid).session_user_image(uuid, user_id) - image_data = base64.b64decode(image_b64) - headers = [('Content-Type', 'image/png')] - headers.append(('Content-Length', len(image_data))) - return request.make_response(image_data, headers) - - @openerp.http.route(['/im_chat/history'], type="json", auth="none") - def history(self, uuid, last_id=False, limit=20): + # find the author from the user session, which can be None + author_id = False # message_post accept 'False' author_id, but not 'None' + if request.session.uid: + author_id = request.env['res.users'].sudo().browse(request.session.uid).partner_id.id + # post a message without adding followers to the channel. email_from=False avoid to get author from email data + mail_channel = request.env["mail.channel"].sudo(request_uid).search([('uuid', '=', uuid)], limit=1) + message = mail_channel.sudo(request_uid).with_context(mail_create_nosubscribe=True).message_post(author_id=author_id, email_from=False, body=message_content, message_type='comment', subtype='mail.mt_comment', **kwargs) + return message and message.id or False + + @openerp.http.route(['/mail/chat_history'], type="json", auth="none") + def mail_chat_history(self, uuid, last_id=False, limit=20): request_uid = self._default_request_uid() - return request.env["im_chat.message"].sudo(request_uid).fetch_message(uuid, last_id, limit) - - # -------------------------- - # User routes (Only Logged User Methods) - # -------------------------- - @openerp.http.route('/im_chat/init', type="json", auth="user") - def init(self): - return request.env['im_chat.message'].get_init_notifications() + channel = request.env["mail.channel"].sudo(request_uid).search([('uuid', '=', uuid)], limit=1) + return channel.sudo(request_uid).channel_fetch_message(last_id, limit) + + @openerp.http.route('/mail/chat_init', type="json", auth="none") + def mail_chat_init(self): + result = { + 'emoji': request.env['mail.shortcode'].sudo().search_read([('shortcode_type', '=', 'image')], ['source', 'substitution', 'description']) + } + # include the previous notifications, only for identified user + if request.session.uid: + request_uid = self._default_request_uid() + result['notifications'] = request.env['mail.channel'].sudo(request_uid).get_init_notifications() + return result diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index 30fc9c77d4f..edde14ffb6e 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -205,3 +205,16 @@ class MailController(http.Controller): except: return self._redirect_to_messaging() return werkzeug.utils.redirect('/mail/view?%s' % url_encode({'model': model, 'res_id': res_id})) + + @http.route('/mail/needaction', type='json', auth='user') + def needaction(self): + return request.env['res.partner'].get_needaction_count() + + @http.route('/mail/client_action', type='json', auth='user') + def mail_client_action(self): + values = { + 'needaction_inbox_counter': request.env['res.partner'].get_needaction_count(), + 'chatter_needaction_auto': request.env.user.chatter_needaction_auto, + 'channel_slots': request.env['mail.channel'].channel_fetch_slot() + } + return values diff --git a/addons/mail/data/mail_shortcode_data.xml b/addons/mail/data/mail_shortcode_data.xml index bd77ecbf8da..b85479ee30a 100644 --- a/addons/mail/data/mail_shortcode_data.xml +++ b/addons/mail/data/mail_shortcode_data.xml @@ -3,155 +3,180 @@ <data> <!-- Smileys --> - <record id="im_chat_smiley_smile" model="im_chat.shortcode"> + <record id="mail_smiley_smile" model="mail.shortcode"> <field name="source">:)</field> - <field name="substitution"><img src='/im_chat/static/src/img/smile.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/smile.png'/></field> <field name="description">Emoji Smile</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_sad" model="im_chat.shortcode"> + <record id="mail_smiley_sad" model="mail.shortcode"> <field name="source">:(</field> - <field name="substitution"><img src='/im_chat/static/src/img/sad.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/sad.png'/></field> <field name="description">Emoji Sad</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_laugh" model="im_chat.shortcode"> + <record id="mail_smiley_laugh" model="mail.shortcode"> <field name="source">:D</field> - <field name="substitution"><img src='/im_chat/static/src/img/laugh.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/laugh.png'/></field> <field name="description">Emoji Laugh</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_wink" model="im_chat.shortcode"> + <record id="mail_smiley_wink" model="mail.shortcode"> <field name="source">;)</field> - <field name="substitution"><img src='/im_chat/static/src/img/wink.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/wink.png'/></field> <field name="description">Emoji Wink</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_stuck" model="im_chat.shortcode"> + <record id="mail_smiley_stuck" model="mail.shortcode"> <field name="source">O_0</field> - <field name="substitution"><img src='/im_chat/static/src/img/stuck_out_tongue.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/stuck_out_tongue.png'/></field> <field name="description">Emoji Stuck Out Tongue Winking Eye</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_scream" model="im_chat.shortcode"> + <record id="mail_smiley_scream" model="mail.shortcode"> <field name="source">:@</field> - <field name="substitution"><img src='/im_chat/static/src/img/scream.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/scream.png'/></field> <field name="description">Emoji Scream</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_neutral" model="im_chat.shortcode"> + <record id="mail_smiley_neutral" model="mail.shortcode"> <field name="source">:|</field> - <field name="substitution"><img src='/im_chat/static/src/img/neutral.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/neutral.png'/></field> <field name="description">Emoji Speechless/Neutral face</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_cheeky" model="im_chat.shortcode"> + <record id="mail_smiley_cheeky" model="mail.shortcode"> <field name="source">:p</field> - <field name="substitution"><img src='/im_chat/static/src/img/yum.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/yum.png'/></field> <field name="description">Emoji Cheeky</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_confused" model="im_chat.shortcode"> + <record id="mail_smiley_confused" model="mail.shortcode"> <field name="source">:/</field> - <field name="substitution"><img src='/im_chat/static/src/img/confused.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/confused.png'/></field> <field name="description">Emoji Confused</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_open_mouth" model="im_chat.shortcode"> + <record id="mail_smiley_open_mouth" model="mail.shortcode"> <field name="source">:O</field> - <field name="substitution"><img src='/im_chat/static/src/img/open_mouth.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/open_mouth.png'/></field> <field name="description">Emoji Open Mouth</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_heart" model="im_chat.shortcode"> + <record id="mail_smiley_heart" model="mail.shortcode"> <field name="source">&lt;3</field> - <field name="substitution"><img src='/im_chat/static/src/img/heart.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/heart.png'/></field> <field name="description">Emoji Heart</field> + <field name="shortcode_type">image</field> </record> <!-- Other images --> - <record id="im_chat_smiley_thumbsup" model="im_chat.shortcode"> + <record id="mail_smiley_thumbsup" model="mail.shortcode"> <field name="source">:+1</field> - <field name="substitution"><img src='/im_chat/static/src/img/thumbsup.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/thumbsup.png'/></field> <field name="description">Emoji Thumbs Up</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_thumbsdown" model="im_chat.shortcode"> + <record id="mail_smiley_thumbsdown" model="mail.shortcode"> <field name="source">:-1</field> - <field name="substitution"><img src='/im_chat/static/src/img/thumbsdown.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/thumbsdown.png'/></field> <field name="description">Emoji Thumbs Down</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_kitten" model="im_chat.shortcode"> + <record id="mail_smiley_kitten" model="mail.shortcode"> <field name="source">:kitten</field> - <field name="substitution"><img src='/im_chat/static/src/img/cat.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/cat.png'/></field> <field name="description">Emoji Kitten</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_pinky" model="im_chat.shortcode"> + <record id="mail_smiley_pinky" model="mail.shortcode"> <field name="source">:pinky</field> - <field name="substitution"><img src='/im_chat/static/src/img/pinky.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/pinky.png'/></field> <field name="description">Emoji Pinky</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_musti" model="im_chat.shortcode"> + <record id="mail_smiley_musti" model="mail.shortcode"> <field name="source">:musti</field> - <field name="substitution"><img src='/im_chat/static/src/img/musti.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/musti.png'/></field> <field name="description">Emoji Musti</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_man_with_turban" model="im_chat.shortcode"> + <record id="mail_smiley_man_with_turban" model="mail.shortcode"> <field name="source">:turban</field> - <field name="substitution"><img src='/im_chat/static/src/img/man_with_turban.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/man_with_turban.png'/></field> <field name="description">Emoji Man With Turban</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_troll" model="im_chat.shortcode"> + <record id="mail_smiley_troll" model="mail.shortcode"> <field name="source">:troll</field> - <field name="substitution"><img src='/im_chat/static/src/img/trollface.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/trollface.png'/></field> <field name="description">Emoji Troll Face</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_boar" model="im_chat.shortcode"> + <record id="mail_smiley_boar" model="mail.shortcode"> <field name="source">:boar</field> - <field name="substitution"><img src='/im_chat/static/src/img/boar.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/boar.png'/></field> <field name="description">Emoji Boar</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_poop" model="im_chat.shortcode"> + <record id="mail_smiley_poop" model="mail.shortcode"> <field name="source">:poop</field> - <field name="substitution"><img src='/im_chat/static/src/img/poop.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/poop.png'/></field> <field name="description">Emoji Poop</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_cookie" model="im_chat.shortcode"> + <record id="mail_smiley_cookie" model="mail.shortcode"> <field name="source">:cookie</field> - <field name="substitution"><img src='/im_chat/static/src/img/cookie.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/cookie.png'/></field> <field name="description">Emoji Cookie</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_postal_horn" model="im_chat.shortcode"> + <record id="mail_smiley_postal_horn" model="mail.shortcode"> <field name="source">:postal_horn</field> - <field name="substitution"><img src='/im_chat/static/src/img/postal_horn.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/postal_horn.png'/></field> <field name="description">Emoji Postal Horn</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_ghost" model="im_chat.shortcode"> + <record id="mail_smiley_ghost" model="mail.shortcode"> <field name="source">:ghost</field> - <field name="substitution"><img src='/im_chat/static/src/img/ghost.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/ghost.png'/></field> <field name="description">Emoji Ghost</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_beer" model="im_chat.shortcode"> + <record id="mail_smiley_beer" model="mail.shortcode"> <field name="source">:beer</field> - <field name="substitution"><img src='/im_chat/static/src/img/beer.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/beer.png'/></field> <field name="description">Emoji Beer</field> + <field name="shortcode_type">image</field> </record> - <record id="im_chat_smiley_man" model="im_chat.shortcode"> + <record id="mail_smiley_man" model="mail.shortcode"> <field name="source">:man</field> - <field name="substitution"><img src='/im_chat/static/src/img/man.png'/></field> + <field name="substitution"><img src='/mail/static/src/img/smiley/man.png'/></field> <field name="description">Emoji Man</field> + <field name="shortcode_type">image</field> </record> </data> diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 635a4999cce..6eeebcef21d 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -1,21 +1,28 @@ # -*- coding: utf-8 -*- +import datetime +import time import uuid from openerp import _, api, fields, models, modules, tools +from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT from openerp.exceptions import UserError +from openerp.addons.bus.models.bus_presence import AWAY_TIMER + class ChannelPartner(models.Model): _name = 'mail.channel.partner' _description = 'Last Seen Many2many' + _table = 'mail_channel_partner' _rec_name = 'partner_id' partner_id = fields.Many2one('res.partner', string='Recipient') channel_id = fields.Many2one('mail.channel', string='Channel') seen_message_id = fields.Many2one('mail.message', string='Last Seen') - state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Status', default='open') - seen_datetime = fields.Datetime('Last Seen Datetime') + fold_state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Conversation Fold State', default='open') + is_minimized = fields.Boolean("Conversation is minimied") + is_pinned = fields.Boolean("Is pinned on the interface", default=True) class Channel(models.Model): @@ -124,10 +131,6 @@ class Channel(models.Model): self._subscribe_users() return result - def _notify(self, message): - # DO SOMETHING USEFULL - return True - def _subscribe_users(self): for mail_channel in self: mail_channel.write({'channel_partner_ids': [(4, pid) for pid in mail_channel.mapped('group_ids').mapped('users').mapped('partner_id').ids]}) @@ -164,138 +167,310 @@ class Channel(models.Model): res['headers'] = repr(headers) return res - - # -------------------------- - # Session Methods - # -------------------------- @api.multi - def session_info(self): - """ Get the header of the current session - :returns the values of the session - :rtype : dict + @api.returns('self', lambda value: value.id) + def message_post(self, body='', subject=None, message_type='notification', subtype=None, parent_id=False, attachments=None, content_subtype='html', **kwargs): + # auto pin 'direct_message' channel partner + self.filtered(lambda channel: channel.channel_type == 'direct_message').mapped('channel_last_seen_partner_ids').write({'is_pinned': True}) + # apply shortcode (text only) subsitution + body = self.env['mail.shortcode'].apply_shortcode(body, shortcode_type='text') + message = super(Channel, self).message_post(body=body, message_type=message_type, subtype=subtype, content_subtype=content_subtype, **kwargs) + return message + + #------------------------------------------------------ + # Instant Messaging API + #------------------------------------------------------ + # A channel header should be broadcasted: + # - when adding user to channel (only to the new added partners) + # - when folding/minimizing a channel (only to the user making the action) + # A message should be broadcasted: + # - when a message is posted on a channel (to the channel, using _notify() method) + + # Anonymous method + @api.multi + def _broadcast(self, partner_ids): + """ Broadcast the current channel header to the given partner ids + :param partner_ids : the partner to notify """ - self.ensure_one() - info = { - 'uuid': self.uuid, - 'users': self.session_user_info(), - 'state': 'open', - } - # add user session state, if available and if user is logged - if self._uid: - domain = [('user_id', '=', self._uid), ('session_id', 'in', self.ids)] - session_state = self.env['im_chat.conversation_state'].search(domain, limit=1) - if session_state: - info['state'] = session_state.state - return info + notifications = [] + for partner in self.env['res.partner'].browse(partner_ids): + user_id = partner.user_ids and partner.user_ids[0] or False + if user_id: + for channel_info in self.sudo(user_id).channel_info(): + notifications.append([(self._cr.dbname, 'res.partner', partner.id), channel_info]) + self.env['bus.bus'].sendmany(notifications) @api.multi - def session_user_info(self): - """ Get the user infos for all the identified user in the session - :returns a list of user infos - :rtype : list(dict) + def _notify(self, message): + """ Broadcast the given message on the current channels. + Send the message on the Bus Channel (uuid for public mail.channel, and partner private bus channel (the tuple)). + A partner will receive only on message on its bus channel, even if this message belongs to multiple mail channel. Then 'channel_ids' field + of the received message indicates on wich mail channel the message should be displayed. + :param : mail.message to broadcast """ - self.ensure_one() - return self.env['res.users'].browse(self.user_ids.ids).read(['id', 'name', 'im_status']) + message.ensure_one() + message_values = message.message_format()[0] - @api.model - def session_user_image(self, uuid, user_id): - """ Get the avatar of a user in the given session - :param uuid : the uuid of the session - :param user_id : the user identifier - """ - # get the session - image_b64 = False - if user_id: - session = self.env["im_chat.session"].search([('uuid', '=', uuid)], limit=1) - if session and session.sudo(user_id).is_in_session(): - image_b64 = self.env["res.users"].sudo().browse(user_id).image_small - # set default image if not found - if not image_b64: - image_b64 = 'R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' - return image_b64 + notifications = [] + for channel in self: + message_values['channel_ids'] = [channel.id] + notifications.append([(self._cr.dbname, 'mail.channel', channel.id), dict(message_values)]) + # add uuid to allow anonymous to listen + if channel.public == 'public': + notifications.append([channel.uuid, dict(message_values)]) + self.env['bus.bus'].sendmany(notifications) - @api.model - def session_get(self, user_to): - """ Get the canonical session between 2 users, create it if needed. - To reuse an old session, this one must be private, and contains only 2 users. - :param user_to : the identifier of the user - :returns a session header, or False if the user_to was False - :rtype : dict - """ - if user_to: - session = self.search([('user_ids', 'in', user_to), ('user_ids', 'in', [self._uid])], limit=1) - if not (session and len(session.user_ids) == 2 and session.is_private()): - session = self.create({'user_ids': [(6, 0, (user_to, self._uid))]}) - return session.session_info() - - # -------------------------- - # Utils Methods - # -------------------------- @api.multi - def is_private(self): - """ Return true if the session is private between identified users (no external messages). - The only way to do that is to check if there is a message without author (from_id is False). + def channel_info(self): + """ Get the informations header for the current channels + :returns a list of channels values + :rtype : list(dict) """ - self.ensure_one() - message_ids = self.env["im_chat.message"].search([('to_id', 'in', self.ids), ('from_id', '=', None)]) - return len(message_ids) == 0 + channel_infos = [] + partner_channels = self.env['mail.channel.partner'] + # find the channel partner state, if logged user + if self.env.user and self.env.user.partner_id: + partner_channels = self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', 'in', self.ids)]) + # for each channel, build the information header and include the logged partner information + for channel in self: + info = { + 'id': channel.id, + 'name': channel.name, + 'uuid': channel.uuid, + 'state': 'open', + 'is_minimized': False, + 'channel_type': channel.channel_type, + 'public': channel.public, + } + # add the partner for 'direct mesage' channel + if channel.channel_type == 'chat': + info['direct_partner'] = channel.sudo().channel_partner_ids.filtered(lambda p: p.id != self.env.user.partner_id.id).read(['id', 'name', 'im_status']) + # add user session state, if available and if user is logged + if partner_channels.ids: + partner_channel = partner_channels.filtered(lambda c: channel.id == c.channel_id.id) + if len(partner_channel) >= 1: + partner_channel = partner_channel[0] + info['state'] = partner_channel.fold_state or 'open' + info['is_minimized'] = partner_channel.is_minimized + info['seen_message_id'] = partner_channel.seen_message_id.id + # add needaction and unread counter, since the user is logged + info['message_needaction_counter'] = channel.message_needaction_counter + info['message_unread_counter'] = channel.message_unread_counter + channel_infos.append(info) + return channel_infos @api.multi - def is_in_session(self): - """ Return True if the current user is in the user_ids of the session. False otherwise. - Note : the user need to be logged + def channel_fetch_message(self, last_id=False, limit=20): + """ Return message values of the current channel. + :param last_id : last message id to start the research + :param limit : maximum number of messages to fetch + :returns list of messages values + :rtype : list(dict) """ self.ensure_one() - user_id = self._uid - return user_id and user_id in [u.id for u in self.user_ids] + domain = [("channel_ids", "in", self.ids)] + if last_id: + domain.append(("id", "<", last_id)) + return self.env['mail.message'].message_fetch(domain=domain, limit=limit) + + # User methods + @api.model + def channel_get(self, partners_to, pin=True): + """ Get the canonical private channel between some partners, create it if needed. + To reuse an old channel (conversation), this one must be private, and contains + only the given partners. + :param partners_to : list of res.partner ids to add to the conversation + :param pin : True if getting the channel should pin it for the current user + :returns a channel header, or False if the users_to was False + :rtype : dict + """ + if partners_to: + partners_to.append(self.env.user.partner_id.id) + # determine type according to the number of partner in the channel + self.env.cr.execute(""" + SELECT P.channel_id as channel_id + FROM mail_channel C, mail_channel_partner P + WHERE P.channel_id = C.id + AND C.public LIKE 'private' + AND P.partner_id IN %s + AND channel_type LIKE 'chat' + GROUP BY P.channel_id + HAVING COUNT(P.partner_id) = %s + """, (tuple(partners_to), len(partners_to),)) + result = self.env.cr.dictfetchall() + if result: + # get the existing channel between the given partners + channel = self.browse(result[0].get('channel_id')) + # pin up the channel for the current partner + if pin: + self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]).write({'is_pinned': True}) + else: + # create a new one + channel = self.create({ + 'channel_partner_ids': [(6, 0, partners_to)], + 'public': 'private', + 'channel_type': 'chat', + 'name': ', '.join(self.env['res.partner'].sudo().browse(partners_to).mapped('name')), + }) + # broadcast the channel header to the other partner (not me) + channel._broadcast(partners_to) + return channel.channel_info()[0] + return False @api.model - def update_state(self, uuid, state=None): + def channel_fold(self, uuid, state=None): """ Update the fold_state of the given session. In order to syncronize web browser tabs, the change will be broadcast to himself (the current user channel). - Note : the user need to be logged - :param status : the new status of the session for the current user. + Note: the user need to be logged + :param state : the new status of the session for the current user. """ - domain = [('user_id', '=', self._uid), ('session_id.uuid', '=', uuid)] - for session_state in self.env['im_chat.conversation_state'].search(domain): + domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)] + for session_state in self.env['mail.channel.partner'].search(domain): if not state: - state = session_state.state - if session_state.state == 'open': + state = session_state.fold_state + if session_state.fold_state == 'open': state = 'folded' else: state = 'open' - session_state.write({'state': state}) - self.env['bus.bus'].sendone((self._cr.dbname, 'im_chat.session', self._uid), session_state.session_id.session_info()) + session_state.write({ + 'fold_state': state, + 'is_minimized': bool(state != 'closed'), + 'seen_datetime': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT), # TODO JEM (to check) set seen_datetime to read needaction + }) + self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), session_state.channel_id.channel_info()[0]) @api.model + def channel_minimize(self, uuid, minimized=True): + values = { + 'fold_state': minimized and 'open' or 'closed', + 'is_minimized': minimized + } + domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)] + channel_partners = self.env['mail.channel.partner'].search(domain) + channel_partners.write(values) + self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_partners.channel_id.channel_info()[0]) + + @api.model + def channel_pin(self, uuid, pinned=False): + # add the person in the channel, and pin it (or unpin it) + channel = self.search([('uuid', '=', uuid)]) + channel_partners = self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]) + if channel_partners: + channel_partners.write({'is_pinned': pinned}) + + @api.multi + def channel_seen(self): + self.ensure_one() + if self.channel_message_ids.ids: + last_message_id = self.channel_message_ids.ids[0] # zero is the index of the last message + self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id)]).write({'seen_message_id': last_message_id}) + + @api.multi + def channel_invite(self, partner_ids): + """ Add the given partner_ids to the current channels and broadcast the channel header to them. + :param partner_ids : list of partner id to add + """ + partners = self.env['res.partner'].browse(partner_ids) + # add the partner + for channel in self: + partners_to_add = partners - channel.channel_partner_ids + channel.write({'channel_partner_ids': [(4, partner_id) for partner_id in partners_to_add.ids]}) + # broadcast the channel header to the added partner + self._broadcast(partner_ids) + + #------------------------------------------------------ + # Instant Messaging View Specific (Slack Client Action) + #------------------------------------------------------ + @api.model def get_init_notifications(self): """ Get unread messages and old messages received less than AWAY_TIMER - ago and the session_info for open or folded window + ago of minimized channel ONLY. This aims to set the minimized channel + when refreshing the page. Note : the user need to be logged """ + # get current user's minimzed channel + minimized_channels = self.env['mail.channel.partner'].search([('is_minimized', '=', True), ('partner_id', '=', self.env.user.partner_id.id)]).mapped('channel_id') + # get the message since the AWAY_TIMER threshold = datetime.datetime.now() - datetime.timedelta(seconds=AWAY_TIMER) threshold = threshold.strftime(DEFAULT_SERVER_DATETIME_FORMAT) - domain = [('to_id.user_ids', 'in', [self._uid]), ('create_date', '>', threshold)] + domain = [('channel_ids', 'in', minimized_channels.ids), ('create_date', '>', threshold)] # get the message since the last poll of the user - presence = self.env['im_chat.presence'].search([('user_id', '=', self._uid)], limit=1) + presence = self.env['bus.presence'].search([('user_id', '=', self._uid)], limit=1) if presence: domain.append(('create_date', '>', presence.last_poll)) - messages = self.search_read(domain, ['from_id', 'to_id', 'create_date', 'type', 'message'], order='id asc') - # get the session of the messages and the not-closed ones - session_ids = [m['to_id'][0] for m in messages] - domain = [('user_id', '=', self._uid), '|', ('state', '!=', 'closed'), ('session_id', 'in', session_ids)] - session_states = self.env['im_chat.conversation_state'].search(domain) - # re-open the session where a message have been recieved recently - session_states.filtered(lambda r: r.state == 'closed').write({'state': 'folded'}) + # do the message search + message_values = self.env['mail.message'].message_fetch(domain=domain) - # create the notifications (session infos first, then messages) + # create the notifications (channel infos first, then messages) notifications = [] - for state in session_states: - session_infos = state.session_id.session_info() - notifications.append([(self._cr.dbname, 'im_chat.session', self._uid), session_infos]) - for message in messages: - notifications.append([(self._cr.dbname, 'im_chat.session', self._uid), message]) + for channel_info in minimized_channels.channel_info(): + notifications.append([(self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info]) + for message_value in message_values: + for channel_id in message_value['channel_ids']: + if channel_id in minimized_channels.ids: + message_value['channel_ids'] = [channel_id] + notifications.append([(self._cr.dbname, 'mail.channel', channel_id), dict(message_value)]) return notifications + + @api.model + def channel_fetch_slot(self): + """ Return the channels of the user grouped by 'slot' (channel, direct_message or private_group), and + the mapping between partner_id/channel_id for direct_message channels. + :returns dict : the grouped channels and the mapping + """ + values = {} + my_partner_id = self.env.user.partner_id.id + pinned_channels = self.env['mail.channel.partner'].search([('partner_id', '=', my_partner_id), ('is_pinned', '=', True)]).mapped('channel_id') + + # get the group/public channels + values['channel_channel'] = self.search([('channel_type', '=', 'channel'), ('public', 'in', ['public', 'groups']), ('channel_partner_ids', 'in', [my_partner_id])]).channel_info() + + # get the pinned 'direct message' channel + direct_message_channels = self.search([('channel_type', '=', 'chat'), ('id', 'in', pinned_channels.ids)]) + values['channel_direct_message'] = direct_message_channels.channel_info() + + # get the partner of the pinned 'direct message' channels with mapping partner/channel + direct_channel_partner = direct_message_channels.mapped('channel_last_seen_partner_ids').filtered(lambda cp: cp.partner_id.id != my_partner_id) + values['mapping'] = dict((item.partner_id.id, item.channel_id.id) for item in direct_channel_partner) + # partners of the 'direct message' channels + values['partners'] = direct_message_channels.mapped('channel_partner_ids').filtered(lambda partner: partner.id != my_partner_id).read(['id', 'name', 'im_status']) + + # get the private group + values['channel_private_group'] = self.search([('channel_type', '=', 'channel'), ('public', '=', 'private'), ('channel_partner_ids', 'in', [my_partner_id])]).channel_info() + return values + + @api.model + def channel_search_to_join(self, name=None, domain=None): + """ Return the channel info of the channel the current partner can join + :param name : the name of the researched channels + :param domain : the base domain of the research + :returns dict : channel dict + """ + if not domain: + domain = [] + domain += [('channel_type', '=', 'channel'), ('channel_partner_ids', 'not in', [self.env.user.partner_id.id])] + if name: + domain.append(('name', 'ilike', '%'+name+'%')) + return self.search(domain).read(['name', 'public', 'uuid', 'channel_type']) + + @api.multi + def channel_join_and_get_info(self): + self.ensure_one() + self.action_follow() + return self.channel_info()[0] + + @api.model + def channel_create(self, name, privacy='public'): + """ Create a channel and add the current partner, broadcast it (to make the user directly + listen to it when polling) + :param name : the name of the channel to create + :param privacy : privacy of the channel. Should be 'public' or 'private'. + :return dict : channel header + """ + # create the channel + new_channel = self.create({'name': name, 'public': privacy}) + new_channel.action_follow() + return new_channel.channel_info()[0] diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 8f180609475..08d5478a660 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -248,12 +248,12 @@ class Message(models.Model): tracking_value_ids.append(tracking_tree[tracking_value.id]) message_dict.update({ - 'is_author': pid == author[0], + #'is_author': pid == author[0], 'author_id': author, 'partner_ids': partner_ids, 'attachment_ids': attachment_ids, 'tracking_value_ids': tracking_value_ids, - 'user_pid': pid + #'user_pid': pid }) return True @@ -281,7 +281,8 @@ class Message(models.Model): 'record_name': self.record_name, 'subject': self.subject, 'date': self.date, - 'needaction': self.needaction, + #'needaction': self.needaction, JEM : not used. + 'needaction_partner_ids': self.needaction_partner_ids.ids, 'parent_id': parent_id, 'is_private': is_private, 'author_id': False, @@ -291,12 +292,13 @@ class Message(models.Model): 'is_favorite': self.starred, 'attachment_ids': [], 'tracking_value_ids': [], + 'channel_ids': self.channel_ids.ids, } @api.cr_uid_context def message_read_wrapper(self, cr, uid, ids=None, domain=None, context=None, thread_level=0, parent_id=False, limit=None, child_limit=None): - return self.message_read(cr, uid, ids, domain=domain, thread_level=thread_level, context=context, + return self.message_read(cr, uid, ids, domain=domain, thread_level=thread_level, context=context, parent_id=parent_id, limit=limit, child_limit=child_limit) @api.multi @@ -308,7 +310,7 @@ class Message(models.Model): well formed threads, if uid has access to them. After reading the messages, expandable messages are added in the - message list. It consists in messages holding the 'read more' data: + message list. It consists in messages holding the 'read more' data: number of messages to read, domain to apply. :param list ids: optional IDs to fetch @@ -320,7 +322,7 @@ class Message(models.Model): :param int limit: number of messages to fetch, before adding the ancestors and expandables :param int child_limit: number of child messages to fetch - :return dict: + :return dict: - int: number of messages read (status 'unread' to 'read') - list: list of threads [[messages_of_thread1], [messages_of_thread2]] """ @@ -397,7 +399,7 @@ class Message(models.Model): 'nb_messages': more_count, 'parent_id': parent} - if parent and thread_level: + if parent and thread_level: #insert expandable before parent message parent_tree[parent].insert(len(parent_tree[parent])-1, exp) else: @@ -408,7 +410,7 @@ class Message(models.Model): parent_list = parent_tree.values() parent_list = sorted(parent_list, key=lambda item: max([msg.get('id') for msg in item]), reverse=True) - #add 'thread' expandable + #add 'thread' expandable if thread_level: exp_domain = domain + [('id', '<', min(self.ids)), ('id', 'not in', parent_ids), ('parent_id', 'not in', parent_ids)] more_count = self.search_count(exp_domain) @@ -419,15 +421,80 @@ class Message(models.Model): 'parent_id': parent_id}]) nb_read = 0 - if context and 'mail_read_set_read' in context and context['mail_read_set_read']: + if context and 'mail_read_set_read' in context and context['mail_read_set_read']: nb_read = self.set_message_read(True, create_missing=False) else: nb_read = 0 - parent_list = [] + parent_list = [] return {'nb_read': nb_read, 'threads': parent_list} + + @api.model + def message_fetch(self, domain, limit=20): + return self.search(domain, limit=limit).message_format() + + @api.multi + def message_format(self): + """ Get the message values in the format for web client. Since message values can be broadcasted, + computed fields MUST NOT BE READ and broadcasted. + :returns list(dict). + Example : + { + 'body': HTML content of the message + 'model': u'res.partner', + 'record_name': u'Agrolait', + 'attachment_ids': [ + { + 'file_type_icon': u'webimage', + 'id': 45, + 'name': u'sample.png', + 'filename': u'sample.png' + } + ], + 'needaction_partner_ids': [], # list of partner ids + 'res_id': 7, + 'tracking_value_ids': [ + { + 'old_value': "", + 'changed_field': "Customer", + 'id': 2965, + 'new_value': "Axelor" + } + ], + 'author_id': (3, u'Administrator'), + 'author_avatar': .... # base64 of the author avatar + 'email_from': 'sacha@pokemon.com' # email address or False + 'subtype_id': (1, u'Discussions'), + 'channel_ids': [], # list of channel ids + 'date': '2015-06-30 08:22:33', + 'partner_ids': [[7, "Sacha Du Bourg-Palette"]], # list of partner name_get + 'message_type': u'comment', + 'id': 59, + 'subject': False + 'is_note': True # only if the subtype is internal + } + """ + message_values = self.read([ + 'id', 'body', 'date', 'author_id', 'author_avatar', 'email_from', # base message fields + 'message_type', 'subtype_id', 'subject', # message specific + 'model', 'res_id', 'record_name', # document related + 'channel_ids', 'partner_ids', # recipients + 'needaction_partner_ids', # list of partner ids for whom the message is a needaction + 'starred_partner_ids', # list of partner ids for whom the message is starred + ]) + message_tree = dict((m.id, m) for m in self) + self._message_read_dict_postprocess(message_values, message_tree) + + # add is_note flag + internal_subtype_ids = self.env['mail.message.subtype'].search([('internal', '=', True)]).ids + for message in message_values: + if message['subtype_id'] and message['subtype_id'][0]: + message['is_note'] = bool(message['subtype_id'][0] in internal_subtype_ids) + return message_values + + #------------------------------------------------------ # mail_message internals #------------------------------------------------------ @@ -750,7 +817,9 @@ class Message(models.Model): self_sudo = self.sudo() # TDE CHECK: add partners / channels as arguments to be able to notify a message with / without computation ?? - self.ensure_one() # tde: not sure, just for testing, will see + self.ensure_one() # tde: not sure, just for testinh, will see + partners = self.env['res.partner'] | self.partner_ids + channels = self.env['mail.channel'] | self.channel_ids # all followers of the mail.message document have to be added as partners and notified # and filter to employees only if the subtype is internal diff --git a/addons/mail/models/mail_shortcode.py b/addons/mail/models/mail_shortcode.py index 42627e5abe5..a28bd0df552 100644 --- a/addons/mail/models/mail_shortcode.py +++ b/addons/mail/models/mail_shortcode.py @@ -5,44 +5,55 @@ import re from openerp import api, fields, models -class ImChatShortcode(models.Model): +class MailShortcode(models.Model): """ Shortcode - Canned Responses, allowing the user to defined shortcuts in its chat message. - These shortcode are globals and are available for every user. Smiley use this mecanism. + Canned Responses, allowing the user to defined shortcuts in its message. Should be applied before storing message in database. + Emoji allowing replacing text with image for visual effect. Should be applied when the message is displayed (only for final rendering). + These shortcodes are global and are available for every user. """ - _name = 'im_chat.shortcode' + _name = 'mail.shortcode' _description = 'Canned Response / Shortcode' source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replace in the Chat Messages") substitution = fields.Char('Substitution', required=True, index=True, help="The excaped html code replacing the shortcut") description = fields.Char('Description') + shortcode_type = fields.Selection([('image', 'Smiley'), ('text', 'Canned Response')], required=True, default='text', + help="* Smiley are only used for HTML code to display an image "\ + "* Text (default value) is use to substitute text with another text") @api.model def create(self, values): if values.get('substitution'): values['substitution'] = self._sanitize_shorcode(values['substitution']) - return super(ImChatShortcode, self).create(values) + return super(MailShortcode, self).create(values) @api.multi def write(self, values): if values.get('substitution'): values['substitution'] = self._sanitize_shorcode(values['substitution']) - return super(ImChatShortcode, self).write(values) + return super(MailShortcode, self).write(values) def _sanitize_shorcode(self, substitution): """ Sanitize the shortcode substitution : - - HTML substitution : only allow the img tag (smiley) - - escape other substitutions to avoid XSS + - HTML substitution : only allow the img tag (emoji) + - escape other substitutions to avoid XSS """ - is_img_tag = re.match(r'''^<img\s+src=('|")([^'"]*)\1\s*/?>$''', substitution, re.M|re.I) + is_img_tag = re.match(r'''^<img\s+src=('|")([^'"]*)\1\s*/?>$''', substitution, re.M | re.I) if is_img_tag: return substitution return cgi.escape(substitution) @api.model - def replace_shortcode(self, message): - for shortcode in self.search([]): + def apply_shortcode(self, message, shortcode_type=None): + """ Apply the substitution on the given text + :param message : the text where the shortcode will be applied + :param shortcode_type : shortcode type that should be applied during the substitution + """ + domain = [] + if shortcode_type: + domain.append(('shortcode_type', '=', shortcode_type)) + for shortcode in self.search(domain): regex = '(?:^|\s)(%s)(?:\s|$)' % re.escape(shortcode.source) message = re.sub(regex, " " + shortcode.substitution + " ", message) return message diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index f8966608bf4..f295d2f8347 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -346,6 +346,9 @@ class MailThread(models.AbstractModel): ('res_model', 'in', [False, self._name]), ('internal', '=', True)], ['name', 'description', 'sequence']) options['internal_subtypes'] = internal_subtypes + # emoji list + options['emoji_list'] = self.env['mail.shortcode'].search([('shortcode_type', '=', 'image')]).read(['source', 'description', 'substitution']) + # save options on the node node.set('options', json.dumps(options)) res['arch'] = etree.tostring(doc) return res @@ -1855,9 +1858,9 @@ class MailThread(models.AbstractModel): @api.multi def message_set_read(self): - # Nothing done here currently. Will be implemented with the final - # slack modeling. - return True + messages = self.env['mail.message'].search([('model', '=', self._name), ('res_id', 'in', self.ids), ('needaction', '=', True)]) + messages.write({'needaction_partner_ids': [(3, self.env.user.partner_id.id)]}) + return messages.ids @api.multi def message_change_thread(self, new_thread): diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 9034dbe4f19..3f12b8846bf 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -173,3 +173,12 @@ class Partner(models.Model): emails.send() return True + + @api.model + def get_needaction_count(self): + """ compute the number of needaction of the current user """ + self.env.cr.execute(""" + SELECT count(*) as needaction_count + FROM mail_message_res_partner_needaction_rel R + WHERE R.res_partner_id = %s """, (self.env.user.partner_id.id,)) + return self.env.cr.dictfetchall()[0].get('needaction_count') diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 13410b21185..6471273b1db 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -27,4 +27,6 @@ access_mail_tracking_value_system,mail.tracking.value.system,model_mail_tracking access_mail_thread_all,mail.thread.all,model_mail_thread,,1,1,1,1
access_publisher_warranty_contract_all,publisher.warranty.contract.all,model_publisher_warranty_contract,,1,1,1,1
access_mail_template,mail.template,model_mail_template,base.group_user,1,1,1,0
-access_mail_template_system,mail.template_system,model_mail_template,base.group_system,1,1,1,1
\ No newline at end of file +access_mail_template_system,mail.template_system,model_mail_template,base.group_system,1,1,1,1
+access_mail_shortcode,mail.shortcode,model_mail_shortcode,base.group_user,1,1,1,1
+access_mail_shortcode_portal,mail.shortcode.portal,model_mail_shortcode,base.group_portal,1,0,0,0
diff --git a/addons/mail/static/src/css/mail.css b/addons/mail/static/src/css/mail.css index 2d14d8f98b0..522495a8fac 100644 --- a/addons/mail/static/src/css/mail.css +++ b/addons/mail/static/src/css/mail.css @@ -4,7 +4,7 @@ position: relative; color: white; } - + /* ----------------------- TIMELINE ----------------------- */ @@ -23,7 +23,7 @@ .o_web_client .o_timeline.o_timeline_inbox { padding-top: 15px; - padding-bottom: 15px; + padding-bottom: 15px; } .o_web_client .o_timeline.o_timeline_inbox .oe_thread { @@ -117,7 +117,7 @@ } .o_web_client .o_timeline .o_timeline_thread_parent .o_timeline_parent_message .o_timeline_parent_subject .o_timeline_nb_unread_msg { - font-weight: bold; + font-weight: bold; } .o_web_client .o_timeline .o_timeline_thread_parent .o_timeline_parent_message .o_timeline_parent_author { @@ -140,10 +140,10 @@ /* avatar */ - .o_web_client .o_timeline .o_timeline_thread_parent .o_timeline_parent_message .o_timeline_parent_avatar { - left:0; - top: 0; - bottom: 0; + .o_web_client .o_timeline .o_timeline_thread_parent .o_timeline_parent_message .o_timeline_parent_avatar { + left:0; + top: 0; + bottom: 0; width: 30px; overflow: hidden; padding-right: 3px; @@ -188,7 +188,7 @@ .o_web_client .o_timeline .o_timeline_thread_parent .o_timeline_parent_followers { display: inline-block; min-width: 45px; - margin-right: 10px; + margin-right: 10px; color: #337ab7; } @@ -337,7 +337,7 @@ .o_web_client .o_timeline button.oe_full, .o_web_client .o_timeline button.oe_attach { - background: transparent; + background: transparent; color: #337ab7; box-shadow: none; border: none; @@ -456,22 +456,22 @@ @-webkit-keyframes oe_mail_attach_loading_anim { 0% { background: #4BBD00 } - 50% { background: #009123 } + 50% { background: #009123 } 100% { background: #4BBD00 } } @-moz-keyframes oe_mail_attach_loading_anim { 0% { background: #4BBD00 } - 50% { background: #009123 } + 50% { background: #009123 } 100% { background: #4BBD00 } } @-o-keyframes oe_mail_attach_loading_anim { 0% { background: #4BBD00 } - 50% { background: #009123 } + 50% { background: #009123 } 100% { background: #4BBD00 } } @keyframes oe_mail_attach_loading_anim { 0% { background: #4BBD00 } - 50% { background: #009123 } + 50% { background: #009123 } 100% { background: #4BBD00 } } @@ -482,29 +482,29 @@ left: 0px; right: 0px; max-height: 64px; - background: rgba(0,0,0,0.8); + background: rgba(0,0,0,0.8); color: white; border-radius: 1px; border-top-left-radius: 0px; border-top-right-radius: 0px; opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - -o-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; } .oe_attachment.oe_preview:hover .oe_name { opacity: 1; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - -o-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; } .o_web_client .o_timeline .o_timeline_msg_footer button.oe_attach { width: 24px; - margin-bottom: 5px; + margin-bottom: 5px; filter: none; } @@ -521,7 +521,7 @@ cursor: pointer; } - .o_web_client .o_timeline .o_timeline_msg_footer .oe_attachment_file .oe_form_binary_file { + .o_web_client .o_timeline .o_timeline_msg_footer .oe_attachment_file .oe_form_binary_file { display: inline; position: relative; margin-left: -100px; @@ -538,7 +538,7 @@ vertical-align: middle; } - .o_web_client .o_timeline .oe_hidden_input_file, + .o_web_client .o_timeline .oe_hidden_input_file, .o_web_client .o_timeline .oe_hidden_input_file form { display: inline; cursor: pointer; @@ -557,26 +557,26 @@ text-shadow: 1px 0px white, -1px 0px white, 0px 1px white, 0px -1px white; cursor: pointer; opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - -o-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; } .o_web_client .o_timeline .o_timeline_msg_composer .oe_attachment:hover .oe_delete .fa { opacity: 1; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - -o-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; } - /* ------------ expandable message ------------ */ + /* ------------ expandable message ------------ */ - .o_web_client .o_timeline .o_timeline_msg.o_timeline_msg_expandable, - .o_web_client .o_timeline .o_timeline_parent_expandable { + .o_web_client .o_timeline .o_timeline_msg.o_timeline_msg_expandable, + .o_web_client .o_timeline .o_timeline_parent_expandable { color: #A4A4A4; - cursor: pointer; + cursor: pointer; margin-bottom: 0px; min-height: 30px; height: 30px; @@ -637,10 +637,10 @@ /* 2. avatar (message_left) */ .o_web_client .o_timeline .o_timeline_msg .o_timeline_msg_left { - float: left; - left:0; - top: 0; - bottom: 0; + float: left; + left:0; + top: 0; + bottom: 0; width: 45px; overflow: hidden; } @@ -677,11 +677,11 @@ -ms-border-bottom-right-radius: 4px; -o-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; - position: relative; - background: white; - margin-left: 53px; - padding: 5px 5px 11px 7px; - box-shadow: 0px 1px #CACACA; + position: relative; + background: white; + margin-left: 53px; + padding: 5px 5px 11px 7px; + box-shadow: 0px 1px #CACACA; } .o_web_client .o_timeline .o_timeline_msg.o_timeline_msg_comment .o_timeline_msg_center:after, /*********/ @@ -704,7 +704,7 @@ background-color: #DADADA; } .o_web_client .o_timeline .o_timeline_msg.o_timeline_msg_comment.o_timeline_msg_nobody .o_timeline_msg_center:after, - .o_web_client .o_timeline .o_timeline_msg.o_timeline_msg_email.o_timeline_msg_nobody .o_timeline_msg_center:after { + .o_web_client .o_timeline .o_timeline_msg.o_timeline_msg_email.o_timeline_msg_nobody .o_timeline_msg_center:after { border-right-color: #DADADA; } @@ -717,7 +717,7 @@ .o_web_client .o_timeline .o_timeline_msg .o_timeline_msg_content .o_timeline_header { font-weight: normal; - margin-bottom: 11px; + margin-bottom: 11px; } .o_web_client .o_timeline .o_timeline_msg .o_timeline_msg_content .o_timeline_header a { @@ -733,7 +733,7 @@ margin-right: 5px; } - /* message header - votes */ + /* message header - votes */ .o_web_client .o_timeline .o_timeline_vote { display: inline-block; @@ -927,8 +927,8 @@ text-overflow: ellipsis; } - .o_web_client .o_timeline_followers button.o_timeline_follower .o_timeline_follow, - .o_web_client .o_timeline_followers button.o_timeline_follower .o_timeline_unfollow, + .o_web_client .o_timeline_followers button.o_timeline_follower .o_timeline_follow, + .o_web_client .o_timeline_followers button.o_timeline_follower .o_timeline_unfollow, .o_web_client .o_timeline_followers button.o_timeline_follower .o_timeline_following { display: none; } diff --git a/addons/mail/static/src/css/mail_channel.css b/addons/mail/static/src/css/mail_channel.css index 51b04a4a9fc..923cdffe726 100644 --- a/addons/mail/static/src/css/mail_channel.css +++ b/addons/mail/static/src/css/mail_channel.css @@ -1,32 +1,3 @@ -/* ------------------------------ */ -/* Group Form */ -/* ------------------------------ */ - -/* Resize sheet width */ -.o_web_client .oe_form_sheetbg.o_web_client_mail_channel_sheet { - min-height: 0px; - max-height: none; -} - -.o_web_client .oe_form_sheetbg.o_web_client_mail_channel_sheet .oe_form_sheet { - min-height: 0px; - max-height: none; - padding: 0px 18px; -} - -.o_web_client .oe_form_sheetbg.o_web_client_mail_channel_sheet .oe_avatar { - padding-top: 3px; -} - -/* Resize footer width */ -.o_web_client .oe_form div.oe_mail_channel_footer { - max-width: 80%; -} - -/* Resize group description */ -.o_web_client .oe_form_sheetbg.o_web_client_mail_channel_sheet .oe_form_field_text > textarea { - height: 60px; -} /* ------------------------------ */ /* Group Kanban */ diff --git a/addons/mail/static/src/css/mail_chat_backend.css b/addons/mail/static/src/css/mail_chat_backend.css index d01577eb56a..d896befbf5e 100644 --- a/addons/mail/static/src/css/mail_chat_backend.css +++ b/addons/mail/static/src/css/mail_chat_backend.css @@ -1,12 +1,10 @@ + .oe_im { position: fixed; - background-color: #eaeaea; - width: 250px; + background-color: #E8EBEF; + width: 220px; border-left: 1px solid #AEB9BD; z-index: 500; - -webkit-box-shadow: -2px 0px 5px 0px rgba(50, 50, 50, 0.25); - -moz-box-shadow: -2px 0px 5px 0px rgba(50, 50, 50, 0.25); - box-shadow: -2px 0px 5px 0px rgba(50, 50, 50, 0.25); } @media print { @@ -23,92 +21,66 @@ top: -8px; } -/* search stuff */ -.oe_im_frame_header { - position: relative; - padding: 16px; -} -.oe_im_frame_header .oe_im_searchbox { - width: 200px; - padding: 1px 21px 1px 19px; - font-size: 13px; - border: none; - border-bottom: 1px solid #c6c6c6; - background: none; - margin-left: 20px; - height: 38px; -} -.oe_im_frame_header .oe_im_searchbox:focus { - outline: none; -} -.oe_im_frame_header .oe_im_search_icon { - position: absolute; - color: #888; - top: 28px; - left: 8px; - font-size: 16px; - font-weight: 300 !important; -} -.oe_im_frame_header .oe_im_search_clear { - display: none; - position: absolute; - right: 11px; - top: 4px; - font-size: 26px; - color: #b6b6b6; - cursor: pointer; -} -.oe_im_frame_header .oe_im_search_clear:hover { - color: #888; -} -/* users */ -.oe_im_users { - padding-bottom: 38px; + +/* +INSTANT MESSAGING VIEW +*/ +.o_mail_chat_im_container{ + margin: 0; height: 100%; +} +.o_mail_chat_im_row{ + height: inherit; +} +.o_mail_chat_im_container .o_mail_chat_im_sidebar{ + background-color: #f5f5f5; + height: inherit; overflow-y: scroll; } -.oe_im_user { - position: relative; - padding: 2px 8px; - cursor: pointer; - font-size: 13px; - margin-bottom: 10px; -} -.oe_im_user:hover { - background: lightGrey; -} -.oe_im_user_clip { - display: inline-block; - width: 26px; - height: 26px; - margin-right: 4px; - -moz-box-shadow: 0 0 2px 1px rgba(0,0,0,0.25); - -webkit-box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.25); - box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.25); -} -.oe_im_user_avatar { - width: 26px; - height: auto; -} -.oe_im_user_name { - width: 162px; - line-height: 26px; - padding-right: 15px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - position: relative; + +.o_mail_chat_im_container .o_mail_chat_im_content{ + height: inherit; +} +/* content of conversation */ +.o_mail_chat_im_container .o_mail_chat_im_content_wrap_conversation{ + height: inherit; +} +.o_mail_chat_im_container .o_mail_chat_im_content_wrap_conversation .o_mail_chat_im_messages{ + height: 80%; + overflow-y: scroll; +} +.o_mail_chat_im_container .o_mail_chat_im_content_wrap_conversation .o_mail_compose_message{ + height: 20%; + padding:10px; + background-color: #f5f5f5; +} +.o_mail_chat_im_container .o_mail_chat_im_messages .o_mail_chat_im_message{ + margin: 0; +} +/* content of personal inbox and starred */ +.o_mail_chat_im_container .o_mail_chat_im_content_wrap_personal{ + height: inherit; + overflow-y: scroll; } -.oe_im_user_online { - display: none; - position: absolute; - top: 9.5px; - right: 11px; - width: 11px; - height: 11px; - vertical-align: middle; - border: 0; +/* message row */ +.o_mail_chat_im_container .o_mail_chat_im_message .o_mail_chat_im_star{ + opacity: 0.5; + color: black; } +.o_mail_chat_im_container .o_mail_chat_im_message .o_mail_chat_im_star:hover{ + opacity: 1; + cursor: pointer; +} +.o_mail_chat_im_container .o_mail_chat_im_message .o_mail_chat_im_star.o_mail_starred{ + color: orange; +} + + + + +.o_mail_chat_im_search_partner{ + height: 50px; +}
\ No newline at end of file diff --git a/addons/mail/static/src/css/mail_chat_common.css b/addons/mail/static/src/css/mail_chat_common.css index 1ad2981eb41..97119ed506c 100644 --- a/addons/mail/static/src/css/mail_chat_common.css +++ b/addons/mail/static/src/css/mail_chat_common.css @@ -1,92 +1,51 @@ - - -.openerp_style { /* base style of openerp */ - font-family: "Lucida Grande", Helvetica, Verdana, Arial, sans-serif; - color: #4c4c4c; - font-size: 13px; - background: white; -} - -/* button */ - -.oe_chat_button { - position: fixed; - bottom: 0px; - right: 6px; - display: inline-block; - min-width: 100px; - background-color: rgba(60, 60, 60, 0.6); - font-family: 'Lucida Grande', 'Lucida Sans Unicode', Arial, Verdana, sans-serif; - font-size: 14px; - font-weight: bold; - padding: 10px; - color: white; - text-shadow: rgb(59, 76, 88) 1px 1px 0px; - border: 1px solid rgb(80, 80, 80); - border-bottom: 0px; - border-top-left-radius: 5px; - border-top-right-radius: 5px; - cursor: pointer; - z-index:500; +/* emoji size */ +.o_mail_emoji img{ + height: 20px; } -/* conversations */ +// ------------------------------------------------------------------ +// Chat Window +// ------------------------------------------------------------------ -.oe_im_chatview { - position: fixed; - overflow: hidden; - margin-bottom: 5px; - margin-right: 6px; - background-color: #fff; - border: 1px solid #DEDEDE; - width: 240px; - z-index:500; -} @media screen and (max-width:360px) { - .oe_im_chatview { + .o_mail_chat_im_window { width: 100%; margin-right: 0; } - .oe_im_chatview .oe_im_chatview_content { + .o_mail_chat_im_window .o_mail_chat_im_window_content { width: 100% !important; } - .oe_im_chatview .oe_im_chatview_input { + .o_mail_chat_im_window .o_mail_chat_im_window_input { width: 100% !important; } } -.oe_im_chatview .oe_im_chatview_disconnected { - display:none; - z-index: 100; - width: 100%; - background: #E8EBEF; - padding: 5px; - font-size: 11px; - color: #999; - line-height: 14px; - height: 28px; - overflow: hidden; -} -.oe_im_chatview.oe_im_chatview_disconnected_status .oe_im_chatview_disconnected { - display: block; -} +.o_mail_chat_im_window { + position: fixed; + overflow: hidden; + margin-bottom: 5px; + margin-right: 6px; + background: rgba(250, 250, 250, 0.95); + border: 1px solid #DEDEDE; + width: 240px; + z-index:500; +} -/* ####### Chat View : Header ####### */ -.oe_im_chatview_header { +/* HEADER */ +.o_mail_chat_im_window .o_mail_chat_im_window_header { padding: 3px 6px 2px; + cursor: pointer; background: #DEDEDE; background: -moz-linear-gradient(#FCFCFC, #DEDEDE); background: -webkit-gradient(linear, left top, left bottom, from(#FCFCFC), to(#DEDEDE)); border-radius: 3px 3px 0 0; -moz-border-radius: 3px 3px 0 0; -webkit-border-radius: 3px 3px 0 0; - border-bottom: 1px solid #AEB9BD; - cursor: pointer; } -.oe_im_chatview_header .header_name{ +.o_mail_chat_im_window .o_mail_chat_im_window_header_name { max-width: 75%; word-wrap: break-word; display: inline-block; @@ -96,164 +55,61 @@ text-overflow: ellipsis; } -.oe_im_chatview_header .right_buttons { +.o_mail_chat_im_window .o_mail_chat_im_window_header_buttons{ float: right; } -.oe_im_chatview_header .right_buttons > * { - display: inline-block; - border: 0; - cursor: pointer; - color: gray ; - background: transparent; - font-size: 16px; - line-height: 16px; - margin-left: 5px; - -webkit-appearance: none; -} - -.oe_im_chatview_header .right_buttons .dropdown-menu{ - right: 0; - left: auto; -} -.oe_im_chatview_header .right_buttons .dropdown-menu > li{ - display: block; - padding: 3px 20px; - clear: both; - font-weight: normal; - line-height: 1.428571429; - color: #4c4c4c; - white-space: nowrap; -} - -.oe_im_chatview .oe_im_chatview_content { - overflow: auto; - height: 287px; - width: 240px; -} -.oe_im_chatview.oe_im_chatview_disconnected_status .oe_im_chatview_content { - height: 249px; +/* CONTENT (messages, ...) */ +.o_mail_chat_im_window .o_mail_chat_im_window_content { + height: 280px; + overflow-y: scroll; } -.oe_im_chatview .oe_im_chatview_footer { - position: relative; - padding: 3px; - border-top: 1px solid #AEB9BD; - background: #DEDEDE; - background: -moz-linear-gradient(#FCFCFC, #DEDEDE); - background: -webkit-gradient(linear, left top, left bottom, from(#FCFCFC), to(#DEDEDE)); - -moz-border-radius: 0 0 3px 3px; - -webkit-border-radius: 0 0 3px 3px; - border-radius: 0 0 3px 3px; + +.o_mail_chat_im_window .o_mail_chat_im_messages .o_mail_chat_im_window_date_sperator{ + padding: 5px; + padding-right: 10px; + text-align: right; + font-weight: bold; } -.oe_im_chatview .oe_im_chatview_input { - width: 222px; - font-family: Lato, Helvetica, sans-serif; - font-size: 13px; - color: #333; - padding: 1px 5px; - border: 1px solid #AEB9BD; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; - -moz-box-shadow: inset 0 1px 4px rgba(0,0,0,0.2); - -webkit-box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2); - box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2); + +.o_mail_chat_im_window .o_mail_chat_im_message{ + margin: auto; + margin-bottom: 5px; } -.oe_im_chatview .oe_im_chatview_message_bubble { - background: white; - position: relative; - min-height: 32px; - padding: 3px; - margin: 3px; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; + +.o_mail_chat_im_window .o_mail_chat_im_message .o_mail_chat_im_message_avatar{ + width: 20%; + display: inline-block; + vertical-align: top; } -/* Smiley size */ -.oe_im_chatview .oe_im_chatview_bubble_item img{ - width: 18px; +.o_mail_chat_im_window .o_mail_chat_im_message .o_mail_chat_im_message_avatar img{ + width: 80%; + float: right; } -.oe_im_chatview .oe_im_chatview_technical_bubble { - background: #D8D8D8; - position: relative; - min-height: 32px; - padding: 3px; - margin: 3px; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; +.o_mail_chat_im_window .o_mail_chat_im_message .o_mail_chat_im_message_core{ + width: 75%; + display: inline-block; + padding: 5px; - font-style:italic; + background: white; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + max-width:100%; } -.oe_im_chatview .oe_im_chatview_clip { - position: relative; - float: left; - width: 26px; - height: 26px; - margin-right: 4px; - -moz-box-shadow: 0 0 2px 1px rgba(0,0,0,0.25); - -webkit-box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.25); - box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.25); -} -.oe_im_chatview .oe_im_chatview_avatar { - float: left; - width: 26px; - height: auto; - clip: rect(0, 26px, 26px, 0); - max-width: 100%; - width: auto 9; - height: auto; - vertical-align: middle; - border: 0; - -ms-interpolation-mode: bicubic; -} -.oe_im_chatview .oe_im_chatview_time { - position: absolute; - right: 0px; - top: 0px; - margin: 3px; - text-align: right; - line-height: 13px; - font-size: 11px; - color: #999; - width: 60px; - overflow: hidden; -} -.oe_im_chatview .oe_im_chatview_from { - margin: 0 0 2px 0; - line-height: 14px; - font-weight: bold; - font-size: 12px; - width: 140px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - color: #3A87AD; -} -.oe_im_chatview .oe_im_chatview_bubble_list { -} -.oe_im_chatview .oe_im_chatview_bubble_item { - margin: 0 0 2px 30px; - line-height: 14px; - word-wrap: break-word; +.o_mail_chat_im_window .o_mail_chat_im_message .o_mail_chat_im_message_core p{ + margin: 0; } -.oe_im_chatview_online { - margin-top: -4px; - width: 11px; - height: 11px; +/* FOOTER */ +.o_mail_chat_im_window .o_mail_chat_im_window_footer { + padding:3px; + background: #DEDEDE; } -.oe_im_chatview .oe_im_chatview_date_separator { - background: transparent; - position: relative; - padding: 3px; - color: #aaa; - margin: 3px; - text-align: right; - font-size: 12px; -}
\ No newline at end of file +.o_mail_chat_im_window .o_mail_chat_im_window_footer .o_mail_chat_im_window_input { + width: 100%; +} diff --git a/addons/mail/static/src/js/mail.js b/addons/mail/static/src/js/mail.js index 9dd4b2c508d..8064fca4ee1 100644 --- a/addons/mail/static/src/js/mail.js +++ b/addons/mail/static/src/js/mail.js @@ -1167,7 +1167,7 @@ var ComposeMessage = Attachment.extend ({ 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 = []; @@ -1236,7 +1236,7 @@ var ComposeMessage = Attachment.extend ({ }); }); }); - + return check_done; }, @@ -1445,10 +1445,10 @@ var ComposeMessageTopButton = Widget.extend({ }); // Put the ComposeMessageTopButton widget in the systray menu -SystrayMenu.Items.push(ComposeMessageTopButton); +//SystrayMenu.Items.push(ComposeMessageTopButton); core.view_registry.add('timeline', TimelineView); -core.form_widget_registry.add('mail_thread', TimelineRecordThread); +//core.form_widget_registry.add('mail_thread', TimelineRecordThread); return { TimelineView: TimelineView, diff --git a/addons/mail/static/src/js/mail_chat_backend.js b/addons/mail/static/src/js/mail_chat_backend.js index cd1cc00fdf2..9353afb68ab 100644 --- a/addons/mail/static/src/js/mail_chat_backend.js +++ b/addons/mail/static/src/js/mail_chat_backend.js @@ -1,301 +1,1032 @@ -odoo.define('im_chat.im_chat', function (require) { +odoo.define('mail.chat_backend', function (require) { "use strict"; -// to do: make this work in website - var bus = require('bus.bus'); var core = require('web.core'); var data = require('web.data'); -var Model = require('web.DataModel'); -var session = require('web.session'); +var Model = require('web.Model'); +var pyeval = require('web.pyeval'); var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var Dialog = require('web.Dialog'); +var ControlPanelMixin = require('web.ControlPanelMixin'); +var SearchView = require('web.SearchView'); +var WebClient = require('web.WebClient'); + +var session = require('web.session'); var utils = require('web.utils'); var web_client = require('web.web_client'); -var Widget = require('web.Widget'); -var im_chat_common = require('im_chat.im_chat_common'); + +var mail_utils = require('mail.utils'); +var mail_chat_common = require('mail.chat_common'); +var mail_thread = require('mail.thread'); var _t = core._t; var QWeb = core.qweb; - -var USERS_LIMIT = 20; +var internal_bus = core.bus; -// ########## CONVERSATION extentions ############### - -im_chat_common.ConversationManager.include({ - _message_receive: function(message){ +/** + * Widget handeling the channels, in the backend + * + * Responsible to listen the bus and apply action with the received message. Add layer to coordinate the + * folded conversation and trigger event for the InstantMessagingView client action (using internal + * comminication bus). It is a component of the WebClient. + **/ +var ConversationManagerBackend = mail_chat_common.ConversationManager.extend({ + _setup: function(init_data){ var self = this; - var session_id = message.to_id[0]; - var conv = this.sessions[message.to_id[1]]; - if(!conv){ - // fetch the session, and init it with the message - var def_session = new Model("im_chat.session").call("session_info", [], {"ids" : [session_id]}).then(function(session){ - conv = self.session_apply(session, {'force_open': true}); - conv.message_receive(message); - }); - }else{ - this._super(message); + this._super.apply(this, arguments); + _.each(init_data['notifications'], function(n){ + self.on_notification(n); + }); + }, + // window title + window_title_change: function() { + this._super.apply(this, arguments); + var title = undefined; + if (this.get("waiting_messages") !== 0) { + title = _.str.sprintf(_t("%d Messages"), this.get("waiting_messages")); } + web_client.set_title_part("im_messages", title); }, -}); - -im_chat_common.Conversation.include({ - // user actions - prepare_action_menu: function(){ - this._super(); - this._add_action(_t('Shortcuts'), 'im_chat_option_shortcut', 'fa fa-info-circle', this.action_shorcode); - this._add_action(_t('Quit discussion'), 'im_chat_option_quit', 'fa fa-minus-square', this.action_quit_session); + // sessions and messages + session_apply: function(active_session, options){ + // for chat windows + if(active_session.is_minimized || (!active_session.is_minimized && active_session.state === 'closed')){ + this._super.apply(this, arguments); + } + // for client action + if(!active_session.is_minimized){ // if not minimized, + internal_bus.trigger('mail_session_receive', active_session); + } }, - action_shorcode: function(e){ - return web_client.action_manager.do_action({ - type: 'ir.actions.act_window', - name : _t('Shortcode'), - res_model: 'im_chat.shortcode', - view_mode: 'tree,form', - view_type: 'tree', - views: [[false, 'list'], [false, 'form']], - target: "new", - limit: 80, - flags: { - action_buttons: true, - pager: true, - } + message_receive: function(message) { + var actual_channel_ids = _.map(_.keys(this.sessions), function(item){ + return parseInt(item); }); + var message_channel_ids = message.channel_ids; + if(_.intersection(actual_channel_ids, message_channel_ids).length){ + this._super.apply(this, arguments); + } + // broadcast the message to the NotificationButton and the InstantMessagingView + internal_bus.trigger('mail_message_receive', message); + // increment the needaction top counter + if(message.needaction_partner_ids && _.contains(message.needaction_partner_ids, session.partner_id)){ + internal_bus.trigger('mail_needaction_new', 1); + } }, - action_quit_session: function(e){ +}); + + +/** + * Widget Minimized Conversation + * + * Add layer of WebClient integration, and user fold state handling (comminication with server) + **/ +mail_chat_common.Conversation.include({ + session_fold: function(state){ var self = this; - var Session = new Model("im_chat.session"); - return Session.call("quit_user", [this.get("session").uuid]).then(function(res) { - if(! res){ - self.do_warn(_t("Warning"), _t("You are only 2 identified users. Just close the conversation to leave.")); - } + var args = arguments; + var super_call = this._super; + // broadcast the state changing + return new Model("mail.channel").call("channel_fold", [], {"uuid" : this.get("session").uuid, "state" : state}).then(function(){ + super_call.apply(self, args); }); }, - // session - session_update_state: function(state){ +}); + +/** + * Widget : Patch for WebClient + * + * Create the conversation manager, and attach it to the web_client. + **/ +WebClient.include({ + show_application: function(){ var self = this; var args = arguments; var super_call = this._super; - // broadcast the state changing - return new Model("im_chat.session").call("update_state", [], {"uuid" : this.get("session").uuid, "state" : state}).then(function(){ + this.mail_conversation_manager = new ConversationManagerBackend(this); + this.mail_conversation_manager.start().then(function(){ super_call.apply(self, args); + self.mail_conversation_manager.bus.start_polling(); }); }, - // window title - window_title_change: function() { - this.super(); - var title = undefined; - if (this.get("waiting_messages") !== 0) { - title = _.str.sprintf(_t("%d Messages"), this.get("waiting_messages")) - } - web_client.set_title_part("im_messages", title); +}); + + +/** + * Widget Top Menu Notification Counter + * + * Counter of notification in the Systray Menu. Need to know if InstantMessagingView is displayed to + * increment (or not) the counter. On click, should redirect to the client action. + **/ +var NotificationTopButton = Widget.extend({ + template:'mail.chat.NotificationTopButton', + events: { + "click": "on_click", }, - // TODO : change this way - add_user: function(user){ - return new Model("im_chat.session").call("add_user", [this.get("session").uuid , user.id]); + init: function(parent){ + this._super.apply(this, arguments); + this.set('counter', 0); + }, + willStart: function(){ + var self = this; + return $.when(session.rpc('/mail/needaction'), this._super()).then(function(needaction_count){ + self.set('counter', needaction_count); + }); + }, + start: function() { + var self = this; + this.on("change:counter", this, this.on_change_counter); + // events + internal_bus.on('mail_needaction_new', this, this.counter_increment); + internal_bus.on('mail_needaction_done', this, this.counter_decrement); + return this._super(); + }, + counter_increment: function(inc){ + this.set('counter', this.get('counter')+inc); + }, + counter_decrement: function(dec){ + this.set('counter', this.get('counter')-dec); + }, + on_change_counter: function() { + this.$('.fa-comment').html(this.get('counter') || ''); + }, + on_click: function(e){ + e.preventDefault(); + this.do_action({ + type: 'ir.actions.client', + tag: 'mail.chat.instant_messaging', + params: { + 'default_active_id': 'channel_inbox', + }, + }, { + clear_breadcrumbs: true, + }); }, }); +SystrayMenu.Items.push(NotificationTopButton); -// ###### BACKEND : contact panel, top menu button ######### - -var UserWidget = Widget.extend({ - template: "im_chat.UserWidget", +/** + * Abstract Class to 'Add More/Search' Widget + * + * Inputbox using jQueryUI autocomplete to fetch selection, like a Many2One field (on form view) + * Used to create or pin a mail.channel or a res.partner on the InstantMessagingView + **/ +var AbstractAddMoreSearch = Widget.extend({ + template: 'mail.chat.AbstractAddMoreSearch', events: { - "click": "activate_user", + "click .o_mail_chat_add_more_text": "on_click_text", + "blur .o_mail_chat_search_input": "_toggle_elements", }, - init: function(parent, user) { - this._super(parent); - this.set("id", user.id); - this.set("name", user.name); - this.set("im_status", user.im_status); - this.set("image_url", user.image_url); + init: function(parent, options){ + this._super.apply(this, arguments); + options = _.defaults(options || {}, { + 'can_create': false, + 'label': _t('+ Add More'), + }); + this.limit = 10; + this.can_create = options.can_create; + this.label = options.label; }, - start: function() { - this.$el.data("user", {id:this.get("id"), name:this.get("name")}); - this.$el.draggable({helper: "clone"}); - this.on("change:im_status", this, this.update_status); - this.update_status(); + start: function(){ + var self = this; + this.last_search_val = false; + this.$input = this.$('.o_mail_chat_search_input'); + this._bind_events(); + return this._super(); }, - update_status: function(){ - this.$(".oe_im_user_online").toggle(this.get('im_status') !== 'offline'); - var img_src = (this.get('im_status') == 'away' ? '/im_chat/static/src/img/yellow.png' : '/im_chat/static/src/img/green.png'); - this.$(".oe_im_user_online").attr('src', img_src); + _bind_events: function(){ + // autocomplete + var self = this; + this.$input.autocomplete({ + source: function(request, response) { + self.last_search_val = request.term; + self.do_search(request.term).done(function(result){ + if(self.can_create){ + result.push({ + 'label': _.str.sprintf('<strong>'+_t("Create %s")+'</strong>', '<em>"'+self.last_search_val+'"</em>'), + 'value': '_create', + }); + } + response(result); + }); + }, + select: function(event, ui) { + self.on_click_item(ui.item); + }, + focus: function(event, ui) { + event.preventDefault(); + }, + html: true, + }); + }, + // ui + _toggle_elements: function(){ + this.$('.o_mail_chat_add_more_text').toggle(); + this.$('.o_mail_chat_add_more_search_bar').toggle(); + this.$input.val(''); + this.$input.focus(); }, - activate_user: function() { - this.trigger("user_clicked", this.get("id")); + on_click_text: function(event){ + event.preventDefault(); + this._toggle_elements(); + }, + // to be redefined + do_search: function(search_val){ + return $.when(); + }, + on_click_item: function(item){ + if(item.value === '_create'){ + if(this.last_search_val){ + this.trigger('item_create', this.last_search_val); + } + }else{ + this.trigger('item_clicked', item); + } }, }); -var InstantMessaging = Widget.extend({ - template: "im_chat.InstantMessaging", - events: { - "keydown .oe_im_searchbox": "input_change", - "keyup .oe_im_searchbox": "input_change", - "change .oe_im_searchbox": "input_change", - }, - init: function(parent) { - this._super(parent); - this.shown = false; - this.set("right_offset", 0); - this.set("current_search", ""); - this.users = []; - this.widgets = {}; - - // listen bus - this.bus = bus.bus; - this.bus.on("notification", this, this.on_notification); +var PartnerAddMoreSeach = AbstractAddMoreSearch.extend({ + /** + * Do the search call + * @override + */ + do_search: function(search_val){ + var self = this; + var Partner = new Model("res.partner"); + return Partner.call('im_search', [search_val, this.limit]).then(function(result){ + var values = []; + _.each(result, function(user){ + values.push(_.extend(user, { + 'value': user.name, + 'label': user.name, + })); + }); + return values; + }); }, - start: function() { +}); + +var ChannelAddMoreSearch = AbstractAddMoreSearch.extend({ + /** + * Do the search call + * @override + */ + do_search: function(search_val){ var self = this; - // ui - this.conv_manager = new im_chat_common.ConversationManager(this); - this.on("change:right_offset", this.conv_manager, _.bind(function() { - this.conv_manager.set("right_offset", this.get("right_offset")); - }, this)); - $(window).scroll(_.bind(this.position_compute, this)); - $(window).resize(_.bind(this.position_compute, this)); - this.$el.css("right", -this.$el.outerWidth()); - this.position_compute(); - - // business - this.on("change:current_search", this, this.user_search); - - // add a drag & drop listener - self.conv_manager.on("im_session_activated", self, function(conv) { - conv.$el.droppable({ - drop: function(event, ui) { - conv.add_user(ui.draggable.data("user")); + var Channel = new Model("mail.channel"); + return Channel.call('channel_search_to_join', [search_val]).then(function(result){ + var values = []; + _.each(result, function(channel){ + values.push(_.extend(channel, { + 'value': channel.name, + 'label': channel.name, + })); + }); + return values; + }); + }, +}); + +var PrivateGroupAddMoreSearch = AbstractAddMoreSearch.extend({ + _bind_events: function(){ + // don't call the super to avoid autocomplete + this.$input.on('keyup', this, this.on_keydown); + }, + on_keydown: function(event){ + if(event.which === $.ui.keyCode.ENTER && this.$input.val()){ + this.trigger('item_create', this.$input.val()); + } + }, +}); + + +/** + * Widget : Invite People to Channel Dialog + * + * Popup containing a 'many2many_tags' custom input to select multiple partners. + * Search user according to the input, and trigger event when selection is validated. + **/ +var PartnerInviteDialog = Dialog.extend({ + dialog_title: _t('Invite people'), + template: "mail.chat.PartnerInviteDialog", + init: function(parent, options){ + options = _.defaults(options || {}, { + buttons: [{ + text: _t("Invite"), + close: true, + classes: "btn-primary", + click: _.bind(this.on_click_add, this), + }], + channel: undefined, + }); + this._super.apply(this, arguments); + this.set("partners", []); + this.channel = options.channel; + this.PartnersModel = new Model('res.partner'); + this.ChannelModel = new Model('mail.channel'); + this.limit = 20; + }, + start: function(){ + var self = this; + this.$('.o_mail_chat_partner_invite_input').select2({ + width: '100%', + allowClear: true, + multiple: true, + formatResult: function(item){ + if(item.im_status === 'online'){ + return '<span class="fa fa-circle"> ' + item.text + '</span>'; } + return '<span class="fa fa-circle-o"> ' + item.text + '</span>'; + }, + query: function (query) { + self.PartnersModel.call('im_search', [query.term, self.limit]).then(function(result){ + var data = []; + _.each(result, function(partner){ + partner['text'] = partner.name; + data.push(partner); + }); + query.callback({results: data}); + }); + } + }); + return this._super.apply(this, arguments); + }, + on_click_add: function(){ + var self = this; + var data = this.$('.o_mail_chat_partner_invite_input').select2('data'); + if(data.length >= 1){ + this.ChannelModel.call('channel_invite', [], {"ids" : [this.channel.id], 'partner_ids': _.pluck(data, 'id')}).then(function(){ + var names = _.pluck(data, 'text').join(', '); + self.do_notify(_t('New people'), _.str.sprintf(_t('You added <b>%s</b> to the conversation.'), names)); + self.close(); }); + }else{ + self.close(); + } + }, +}); + +/** + * Client Action : Instant Messaging View, inspired by Slack.com + * + * Action replacing the Inbox, and the list of group (mailing list, multiple conversation, rooms, ...) + * Includes real time messages (received and sent), creating group, channel, chat conversation, ... + **/ +var ChatMailThread = Widget.extend(mail_thread.MailThreadMixin, ControlPanelMixin, { + template: 'mail.chat.ChatMailThread', + events: { + // events from MailThreadMixin + "click .o_mail_redirect": "on_click_redirect", + "click .o_mail_thread_message_star": "on_message_star", + // events specific for ChatMailThread + "click .o_mail_chat_channel_item": "on_click_channel", + "click .o_mail_chat_partner_item": "on_click_partner", + "click .o_mail_chat_partner_unpin": "on_click_partner_unpin", + "click .o_mail_thread_message_needaction": "on_message_needaction", + }, + init: function (parent, action) { + this._super.apply(this, arguments); + // attributes + this.action_manager = parent; + this.help_message = action.help || ''; + this.context = action.context; + this.action = action; + this.loading_history = false; + this.chatter_needaction_auto = false; + this.options = this.action.params; + // mail thread mixin + mail_thread.MailThreadMixin.init.call(this); + // components : conversation manager and search widget (channel_type + '_search_widget') + this.conv_manager = web_client.mail_conversation_manager; + this.channel_search_widget = new ChannelAddMoreSearch(this, {'label': _t('+ Subscribe'), 'can_create': true}); + this.group_search_widget = new PrivateGroupAddMoreSearch(this, {'label': _t('+ New private group'), 'can_create': true}); + this.partner_search_widget = new PartnerAddMoreSeach(this); + // options (update the default of MailThreadMixin) + this.options = _.extend(this.options, { + 'display_document_link': true, + 'display_needaction_button': true, + 'emoji_list': this.conv_manager.emoji_list, }); + this.emoji_set_substitution(this.conv_manager.emoji_list); + // channel business + this.channels = {}; + this.mapping = {}; // mapping partner_id/channel_id for 'direct message' channel + this.set('current_channel_id', false); + this.set('needaction_inbox_counter', 0); + this.search_domain = []; + // channel slots + this.set('channel_channel', []); + this.set('channel_direct_message', []); + this.set('channel_private_group', []); + this.set('partners', []); + // models + this.ChannelModel = new Model('mail.channel', this.context); + // control panel items + this.control_elements = {}; + }, + willStart: function(){ + var self = this; + return session.rpc('/mail/client_action').then(function(result){ + self.chatter_needaction_auto = result['chatter_needaction_auto']; + self.set('needaction_inbox_counter', result['needaction_inbox_counter']); + self.set('partners', result['channel_slots']['partners']); + self.mapping = result['channel_slots']['mapping']; + self._channel_slot(_.omit(result['channel_slots'], 'partners', 'mapping')); + }); + }, + start: function(){ + var self = this; + this._super.apply(this, arguments); + mail_thread.MailThreadMixin.start.call(this); + // channel business events + this.on("change:current_channel_id", this, this.channel_change); + this.on("change:channel_channel", this, function(){ + self.channel_render('channel_channel'); + }); + this.on("change:channel_private_group", this, function(){ + self.channel_render('channel_private_group'); + }); + this.on("change:partners", this, this.partner_render); - // fetch the unread message and the recent activity (e.i. to re-init in case of refreshing page) - return session.rpc("/im_chat/init", {}).then(function(notifications) { - _.each(notifications, function(notif){ - self.conv_manager.on_notification(notif, {'load_history': true}); + // search widget for channel + this.channel_search_widget.insertAfter(this.$('.o_mail_chat_channel_slot_channel_channel')); + this.channel_search_widget.on('item_create', this, function(name){ + self.channel_create(name, 'public'); + }); + this.channel_search_widget.on('item_clicked', this, function(item){ + self.channel_join_and_get_info(item.id).then(function(channel){ + self.channel_apply(channel); + }); + }); + // search widget for direct message + this.partner_search_widget.insertAfter(this.$('.o_mail_chat_channel_slot_partners')); + this.partner_search_widget.on('item_clicked', this, function(item){ + self.channel_get([item.id]); + self.partner_add(item); + }); + // search widget for private group + this.group_search_widget.insertAfter(this.$('.o_mail_chat_channel_slot_channel_private_group')); + this.group_search_widget.on('item_create', this, function(name){ + self.channel_create(name, 'private'); + }); + + // bind event to load history when scrolling near top + this.$('.o_mail_chat_messages').scroll(function() { + if ($(this).scrollTop() <= 50 && self.loading_history) { // at 50px of the top, load history + self.message_load_history().then(function(messages){ + if(messages.length < mail_thread.LIMIT_MESSAGE){ + self.loading_history = false; + } + }); + } + }); + + // needaction inbox counter + this.on('change:needaction_inbox_counter', this, this.needaction_inbox_change); + + return $.when(this._super.apply(this, arguments), this.cp_render_searchview()).then(function(res){ + // update control panel + self.cp_render_buttons(); + // apply default channel + var channel_id = self.context.active_id || self.action.params['default_active_id'] || 'channel_inbox'; + if(!_.isString(channel_id)){ + if(_.contains(_.keys(self.channels), channel_id)){ + self.set('current_channel_id', channel_id); + }else{ + self.channel_info(channel_id).then(function(channel){ + self.channel_apply(channel); + }); + } + }else{ + self.set('current_channel_id', channel_id); + } + // internal communication (bind here to avoid receving message from bus when client action still not totally ready) + internal_bus.on('mail_message_receive', self, self.message_receive); + internal_bus.on('mail_session_receive', self, self.channel_receive); + // needaction : the inbox has the same counter as the needaction top counter + internal_bus.on('mail_needaction_new', this, function(inc){ + self.set('needaction_inbox_counter' , self.get('needaction_inbox_counter') + inc); + }); + internal_bus.on('mail_needaction_done', this, function(dec){ + self.set('needaction_inbox_counter' , self.get('needaction_inbox_counter') - dec); }); - // start polling - self.bus.start_polling(); }); }, - on_notification: function(notification){ - var channel = notification[0]; - var message = notification[1]; - // user status notification - if(channel[1] === 'im_chat.presence'){ - if(message.im_status){ - this.user_update_status([message]); + // event actions + on_click_channel: function(event){ + event.preventDefault(); + var channel_id = this.$(event.currentTarget).data('channel-id'); + this.set('current_channel_id', channel_id); + }, + on_click_partner: function(event){ + if(!this.$(event.target).hasClass('o_mail_chat_channel_unpin')){ + event.preventDefault(); + var partner_id = this.$(event.currentTarget).data('partner-id'); + if(this.mapping[partner_id]){ // don't fetch if channel already in local + this.set('current_channel_id', this.mapping[partner_id]); + }else{ + this.channel_get([partner_id]); } } }, - // ui - position_compute: function() { - var top = 48; - this.$el.css("top", top); - this.$el.css("bottom", 0); - }, - input_change: function() { - this.set("current_search", this.$(".oe_im_searchbox").val()); - }, - switch_display: function() { - this.position_compute(); - var fct = _.bind(function(place) { - this.set("right_offset", place + this.$el.outerWidth()); - this.$(".oe_im_searchbox").focus(); - }, this); - var opt = { - step: fct, - }; - if (this.shown) { - this.$el.animate({ - right: -this.$el.outerWidth(), - }, opt); - } else { - if (! bus.bus.activated) { - this.do_warn("Instant Messaging is not activated on this server. Try later.", ""); - return; + on_click_partner_unpin: function(event){ + event.preventDefault(); + var self = this; + var $source = this.$(event.currentTarget); + var partner_id = $source.data('partner-id'); + var channel_id = this.mapping[partner_id]; + var channel = this.channels[channel_id]; + this.channel_pin(channel.uuid, false).then(function(){ + self.set('partners', _.filter(self.get('partners'), function(p){ return p.id !== partner_id; })); + self.channel_remove(channel_id); + delete self.mapping[partner_id]; + // if unpin current channel, switch to inbox + if(self.get('current_channel_id') === channel_id){ + self.set('current_channel_id', 'channel_inbox'); } - // update the list of user status when show the IM - this.user_search(); - this.$el.animate({ - right: 0, - }, opt); + }); + }, + on_click_button_minimize: function(event){ + event.preventDefault(); + var current_channel = this.channels[this.get('current_channel_id')]; + if(current_channel){ + var channel_uuid = current_channel.uuid; + return this.ChannelModel.call("channel_minimize", [channel_uuid, true]); } - this.shown = ! this.shown; + return $.Deferred.reject(); }, - // user methods - user_search: function(e) { + on_click_button_invite: function(event){ + var dialog = new PartnerInviteDialog(this, { + title: _.str.sprintf(_t('Invite people to %s.'), this.get_current_channel_name()), + channel: this.channels[this.get('current_channel_id')], + }).open(); + }, + on_click_button_unsubscribe: function(event){ var self = this; - var user_model = new Model("res.users"); - return user_model.call('im_search', [this.get("current_search"), USERS_LIMIT]).then(function(result){ - self.$(".oe_im_input").val(""); - var old_widgets = self.widgets; - self.widgets = {}; - self.users = []; - _.each(result, function(user) { - user.image_url = session.url('/web/image', {model:'res.users', field: 'image_small', id: user.id}); - var widget = new UserWidget(self, user); - widget.appendTo(self.$(".oe_im_users")); - widget.on("user_clicked", self, self.user_clicked); - self.widgets[user.id] = widget; - self.users.push(user); - }); - _.each(old_widgets, function(w) { - w.destroy(); - }); + this.ChannelModel.call('action_unfollow', [[this.get('current_channel_id')]]).then(function(){ + var channel = self.channels[self.get('current_channel_id')] + var slot = self.get_channel_slot(channel); + self.set(slot, _.filter(self.get(slot), function(c){ return c.id !== channel.id; })); + self.do_notify(_t('Unsubscribe'), _.str.sprintf(_t('You unsubscribe from <b>%s</b>.'), self.get_current_channel_name())); + self.set('current_channel_id', 'channel_inbox'); // jump to inbox }); }, - user_clicked: function(user_id) { + on_click_button_settings: function(event){ + this.do_action({ + type: 'ir.actions.act_window', + res_model: "mail.channel", + res_id: this.get('current_channel_id'), + views: [[false, 'form']], + target: 'current' + }, { + clear_breadcrumbs: true + }); + }, + on_message_needaction: function(event){ var self = this; - var sessions = new Model("im_chat.session"); - return sessions.call("session_get", [user_id]).then(function(session) { - self.conv_manager.session_apply(session, {'focus': true, 'force_open': true}); + mail_thread.MailThreadMixin.on_message_needaction.call(this, event).then(function(message_id){ + // decrement the needaction top counter + internal_bus.trigger('mail_needaction_done', 1); + // decrement the channel labels + var treated_messages = _.filter(self.get('messages'), function(m){ return m.id == message_id; }); + if(treated_messages.length){ + self.needaction_decrement(treated_messages[0].channel_ids || []); + } }); }, - user_update_status: function(users_list){ + // control panel + cp_update: function(){ var self = this; - _.each(users_list, function(el) { - if (self.widgets[el.id]) { - self.widgets[el.id].set("im_status", el.im_status); + var current_channel_id = this.get('current_channel_id'); + var current_channel_name = this.get_current_channel_name(); + // toggle cp buttons + if(_.isString(current_channel_id)){ + self.control_elements.$buttons.find('button').hide(); + }else{ + self.control_elements.$buttons.find('button').show(); + var current_channel = this.channels[current_channel_id]; + if(current_channel.channel_type == 'chat'){ + self.control_elements.$buttons.find('.o_mail_chat_button_invite, .o_mail_chat_button_more').hide(); } + } + // update control panel + var status = { + breadcrumbs: this.action_manager.get_breadcrumbs().concat([{'title': current_channel_name, 'action': _.clone(this)}]), + cp_content: { + $buttons: self.control_elements.$buttons, + $searchview: self.control_elements.$searchview, + $searchview_buttons: self.control_elements.$searchview_buttons, + $switch_buttons: false, + $pager: false, + }, + searchview: self.searchview, + }; + this.update_control_panel(status, {clear: false}); + }, + cp_render_buttons: function() { + var self = this; + this.control_elements.$buttons = $(QWeb.render("mail.chat.ControlButtons", {})); + this.control_elements.$buttons.on('click', '.o_mail_chat_button_minimize', _.bind(this.on_click_button_minimize, this)); + this.control_elements.$buttons.on('click', '.o_mail_chat_button_invite', _.bind(this.on_click_button_invite, this)); + this.control_elements.$buttons.on('click', '.o_mail_chat_button_unsubscribe', _.bind(this.on_click_button_unsubscribe, this)); + this.control_elements.$buttons.on('click', '.o_mail_chat_button_settings', _.bind(this.on_click_button_settings, this)); + }, + cp_render_searchview: function(){ + var self = this; + var options = { + $buttons: $("<div>"), + action: this.action, + }; + var view_id = (this.action && this.action.search_view_id && this.action.search_view_id[0]) || false; + this.searchview = new SearchView(this, this.MessageDatasetSearch, view_id, {}, options); + + this.searchview.on('search_data', this, this.on_search); + return $.when(this.searchview.appendTo($("<div>"))).done(function() { + self.control_elements.$searchview = self.searchview.$el; + self.control_elements.$searchview_buttons = self.searchview.$buttons.contents(); + // hack to hide 'group by' button, since it is not relevant + self.searchview.$buttons.find('.oe-groupby-menu').hide(); + }); + }, + /** + * Method call when action if done after redirecting (using 'on_click_redirect') + * @override + */ + on_reverse_breadcrumb: function(){ + var current_channel_id = this.get('current_channel_id'); + this.cp_update(); // do not reload the client action, just display it, but a refresh of the control panel is needed. + // push state + this.action_manager.do_push_state({ + action: this.action.id, + active_id: current_channel_id, + }); + }, + // messages search methods + on_search: function(domains, contexts, groupbys){ + var self = this; + return pyeval.eval_domains_and_contexts({ + domains: [[]].concat(domains || []), + contexts: [this.context].concat(contexts || []), + group_by_seq: groupbys || [] + }).done(function(results){ + if (results.error) { + throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s", JSON.stringify(results.error))); + } + // modify the search domain and do search + self.search_domain = results.domain; + return self.message_load_new(); + }); + }, + // channels + /** + * Set the channel slot + * @param {Object[]} fetch_result : should contains only the slot name (as key) and the list of channel header as value. + */ + _channel_slot: function(fetch_result){ + var self = this; + var channel_slots = _.keys(fetch_result); + _.each(channel_slots, function(slot){ + // update the channel slot + self.set(slot, fetch_result[slot]); + // flatten the result : update the complete channel list + _.each(fetch_result[slot], function(channel){ + self.channels[channel.id] = channel; + }); + }); + }, + /** + * Apply a channel means adding it, and swith to it + * @param {Object} channel : channel header + */ + channel_apply: function(channel){ + this.channel_add(channel); + this.set('current_channel_id', channel.id); + }, + /** + * Add the given channel, or update it if already exists and loaded + * @param {Object} channel : channel header to add + */ + channel_add: function(channel){ + var channel_slot = this.get_channel_slot(channel); + var existing = this.get(channel_slot); + if(_.contains(_.pluck(existing, 'id'), channel.id)){ + // update the old channel + var filtered_channels = _.filter(existing, function(item){ return item.id != channel.id; }); + this.set(channel_slot, filtered_channels.concat([channel])); + }else{ + // simply add the reveiced channel + this.set(channel_slot, existing.concat([channel])); + } + // also update the flatten list + this.channels[channel.id] = channel; + + // update the mapping for 'direct message' channel, and the partner list + if(channel_slot === 'channel_direct_message'){ + var partner = channel.direct_partner[0]; + this.mapping[partner.id] = channel.id; + this.partner_add(partner); + } + }, + channel_remove: function(channel_id){ + var channel = this.channels[channel_id]; + var slot = this.get_channel_slot(channel); + this.set(slot, _.filter(this.get(slot), function(c){ return c.id !== channel_id; })); + delete this.channels[channel_id]; + }, + /** + * Get the channel the current user has with the given partner, and get the channel header + * @param {Number[]} partner_ids : list of res.partner identifier + */ + channel_get: function(partner_ids){ + var self = this; + return this.ChannelModel.call('channel_get', [partner_ids]).then(function(channel){ + self.channel_apply(channel); + }); + }, + /** + * Create a channel with the given name and type, and apply it + * @param {String} channel_name : the name of the channel + * @param {String} privacy : the privacy of the channel (groups, public, ...) + */ + channel_create: function(channel_name, privacy){ + var self = this; + return this.ChannelModel.call('channel_create', [channel_name, privacy]).then(function(channel){ + self.channel_apply(channel); + }); + }, + channel_info: function(channel_id){ + var self = this; + return this.ChannelModel.call('channel_info', [[channel_id]]).then(function(channels){ + return channels[0]; + }); + }, + channel_pin: function(uuid, pinned){ + return this.ChannelModel.call('channel_pin', [uuid, pinned]); + }, + channel_join_and_get_info: function(channel_id){ + return this.ChannelModel.call('channel_join_and_get_info', [[channel_id]]); + }, + channel_change: function(){ + var self = this; + var current_channel_id = this.get('current_channel_id'); + var current_channel = this.channels[current_channel_id]; + // mail chat compose message (destroy and replace it) + this.mail_chat_compose_message && this.mail_chat_compose_message.destroy(); + this.mail_chat_compose_message = new mail_thread.MailComposeMessage(this, new data.DataSetSearch(this, 'mail.channel', this.context), { + 'emoji_list': this.options.emoji_list, + 'context': _.extend(this.context, { + 'default_res_id': current_channel_id, + }), + 'display_mode': 'chat', + }); + if(_.isString(current_channel_id)){ + this.$('.o_mail_chat_composer_wrapper').hide(); + }else{ + this.$('.o_mail_chat_composer_wrapper').show(); + } + this.mail_chat_compose_message.appendTo(this.$('.o_mail_chat_composer_wrapper')); + this.mail_chat_compose_message.focus(); + + // push state (the action is referred by action_manager, and no reloaded when jumping + // channel, so update context is requried) + this.action.context['active_id'] = current_channel_id; + this.action.context['active_ids'] = [current_channel_id]; + web_client.action_manager.do_push_state({ + action: this.action.id, + active_id: current_channel_id, }); - } -}); + // update control panel + this.cp_update(); -var ImTopButton = Widget.extend({ - template:'im_chat.ImTopButton', - events: { - "click": "clicked", + // fetch the messages + return this.message_load_new().then(function(raw_messages){ + // allow loading history + self.loading_history = true; + // if normal channel, update last message id, and unbold + var def = $.Deferred().resolve(); + if(_.isNumber(current_channel_id)){ + def = self.ChannelModel.call('channel_seen', [[current_channel_id]]); + }else{ + // auto treat needaction from inbox + if(current_channel_id === 'channel_inbox' && self.chatter_needaction_auto){ + def = self.MessageDatasetSearch._model.query(['id', 'channel_ids']).filter([['needaction', '=', true]]).all().then(function(messages){ + // get needaction message ids + var message_ids = _.pluck(messages, 'id'); + if(message_ids){ + // call to unlink + self.MessageDatasetSearch._model.call('set_message_done',[message_ids]).then(function(){ + // decrement channel items in batch + var flatten_channel_ids = _.flatten(_.pluck(messages, 'channel_ids')); + var channel_count = _.countBy(flatten_channel_ids, function(cid) { + return cid; + }); + _.each(_.keys(channel_count), function(cid){ + self.needaction_decrement([cid], channel_count[cid]); + }); + // decrement the needaction top counter + internal_bus.trigger('mail_needaction_done', message_ids.length); + }); + } + }); + } + } + def.then(function(){ + self._toggle_unread_message(current_channel_id, false, true); + self._scroll(); + }); + }); }, - start: function() { - // Create the InstantMessaging widget and put it in the DOM - var im = new InstantMessaging(this); - web_client.im_messaging = im; - im.appendTo(web_client.$el); - // Bind the click action to the ImTopButton - this.on("clicked", im, im.switch_display); - return this._super(); + channel_render: function(channel_slot){ + this.$('.o_mail_chat_channel_slot_' + channel_slot).replaceWith(QWeb.render("mail.chat.ChatMailThread.channels", {'widget': this, 'channel_slot': channel_slot})); + }, + // partners + partner_add: function(partner){ + var partners = _.filter(this.get('partners'), function(p){ return p.id != partner.id; }); + this.set('partners', partners.concat([partner])); + }, + partner_render: function(){ + this.$('.o_mail_chat_channel_slot_partners').replaceWith(QWeb.render("mail.chat.ChatMailThread.partners", {'widget': this})); + }, + // needaction + needaction_increment: function(channel_ids, inc){ + var self = this; + _.each(channel_ids, function(channel_id){ + if(self.channels[channel_id]){ + var count = (self.channels[channel_id].message_needaction_counter || 0) + (inc || 1); + self.channels[channel_id].message_needaction_counter = count; + self.$('.o_mail_chat_needaction[data-channel-id='+channel_id+']').html(count); + self.$('.o_mail_chat_needaction[data-channel-id='+channel_id+']').show(); + } + }); }, - clicked: function(ev) { - ev.preventDefault(); - this.trigger("clicked"); + needaction_decrement: function(channel_ids, dec){ + var self = this; + _.each(channel_ids, function(channel_id){ + if(self.channels[channel_id]){ + var count = (self.channels[channel_id].message_needaction_counter || 0) - (dec || 1); + self.channels[channel_id].message_needaction_counter = count; + self.$('.o_mail_chat_needaction[data-channel-id='+channel_id+']').html(count); + if(count <= 0){ + self.$('.o_mail_chat_needaction[data-channel-id='+channel_id+']').show(); + } + } + }); + }, + needaction_inbox_change: function(inc){ + var count = this.get('needaction_inbox_counter'); + var $elem = this.$('.o_mail_chat_needaction[data-channel-id="channel_inbox"]'); + $elem.html(count); + if(count > 0){ + $elem.show(); + }else{ + $elem.hide(); + } + }, + // from bus + channel_receive: function(channel){ + this.channel_add(channel); + }, + message_receive: function(message){ + var self = this; + // if current channel should reveice message, give it to it + if(_.contains(message['channel_ids'], this.get('current_channel_id'))){ + this.message_insert([message]); + } + // for other message channel, get the channel if not loaded yet, and bolded them + var other_message_channel_ids = _.without(message['channel_ids'], this.get('current_channel_id')); + var active_channel_ids = _.map(_.keys(this.channels), function(cid){ + return parseInt(cid); + }); // integer as key of a dict is cast as string in javascript + var channel_to_fetch = _.difference(other_message_channel_ids, active_channel_ids); + // fetch unloaded channels and add it + var def = $.Deferred(); + if(channel_to_fetch.length >= 1){ + def = this.ChannelModel.call("channel_info", [], {"ids" : channel_to_fetch}).then(function(channels){ + _.each(channels, function(channel){ + self.channel_add(channel); + }); + }); + }else{ + def.resolve([]); + } + // bold the channel to indicate unread messages + def.then(function(){ + // bold channel having unread messages + _.each(other_message_channel_ids, function(channel_id){ + self._toggle_unread_message(channel_id, true); + }); + // auto scroll to bottom + self._scroll(); + }); + // if needaction, then increment the label + if(message.needaction_partner_ids && _.contains(message.needaction_partner_ids, session.partner_id)){ + this.needaction_increment(message.channel_ids || []); + } + }, + // utils function + get_channel_slot: function(channel){ + if(channel.channel_type === 'channel'){ + if(channel.public === 'private'){ + return 'channel_private_group'; + } + return 'channel_channel'; + } + if(channel.channel_type === 'chat'){ + return 'channel_direct_message'; + } + }, + get_current_channel_name: function(){ + var current_channel_id = this.get('current_channel_id'); + var current_channel = this.channels[current_channel_id]; + var current_channel_name = current_channel && current_channel.name || _t('Unknown'); + // virtual channel id (for inbox, or starred channel) + if(_.isString(current_channel_id)){ + if(current_channel_id == 'channel_inbox'){ + current_channel_name = _t('Inbox'); + } + if(current_channel_id == 'channel_starred'){ + current_channel_name = _t('Starred'); + } + } + return current_channel_name; + }, + _toggle_unread_message: function(channel_id, add_or_remove, set_as_active){ + var partner_id = false; + var inverse_mapping = _.invert(this.mapping); + // toggle bold channel/partner item + this.$('.o_mail_chat_sidebar .o_mail_chat_channel_item[data-channel-id="'+channel_id+'"]').toggleClass('o_mail_chat_channel_unread', add_or_remove); + if(_.contains(_.keys(inverse_mapping), channel_id.toString())){ + partner_id = parseInt(inverse_mapping[channel_id]); + this.$('.o_mail_chat_sidebar .o_mail_chat_partner_item[data-partner-id="'+partner_id+'"]').toggleClass('o_mail_chat_channel_unread', add_or_remove); + } + // set the channel/partner item as the active one + if(set_as_active){ + this.$('.o_mail_chat_sidebar .o_mail_chat_channel_item, .o_mail_chat_sidebar .o_mail_chat_partner_item').find('a').removeClass('active'); + this.$('.o_mail_chat_sidebar .o_mail_chat_channel_item[data-channel-id="'+channel_id+'"]').find('a').addClass('active'); + if(partner_id){ + this.$('.o_mail_chat_sidebar .o_mail_chat_partner_item[data-partner-id="'+partner_id+'"]').find('a').addClass('active'); + } + } + }, + _scroll: function(){ + var current_channel_id = this.get('current_channel_id'); + var current_channel = this.channels[current_channel_id]; + if(_.isNumber(current_channel_id)){ + if(current_channel.seen_message_id){ + if(this.$(".o_mail_thread_message[data-message-id="+current_channel.seen_message_id+"]")){ + this.$('.o_mail_chat_messages').scrollTop(this.$(".o_mail_thread_message[data-message-id="+current_channel.seen_message_id+"]").offset().top); + }else{ + this.$('.o_mail_chat_messages').scrollTop(0); + } + }else{ + this.$('.o_mail_chat_messages').scrollTop(this.$('.o_mail_chat_messages')[0].scrollHeight); + } + current_channel.seen_message_id = false; + }else{ + this.$('.o_mail_chat_messages').scrollTop(this.$('.o_mail_chat_messages')[0].scrollHeight); + } + }, + /** + * Render the messages + * @override + */ + message_render: function(){ + this.$('.o_mail_chat_messages_content').html(QWeb.render('mail.chat.ChatMailThread.content', {'widget': this})); + }, + /** + * Return the message domain for the current channel + * @override + */ + get_message_domain: function(){ + // default channel domain + var current_channel_id = this.get('current_channel_id'); + var domain = [['channel_ids', 'in', current_channel_id]]; + // virtual channel id (for inbox, or starred channel) + if(_.isString(current_channel_id)){ + if(current_channel_id == 'channel_inbox'){ + domain = [['needaction', '=', true]]; + } + if(current_channel_id == 'channel_starred'){ + domain = [['starred', '=', true]]; + } + } + // add search domain + domain = domain.concat(this.search_domain); + return domain; }, }); -// Put the ImTopButton widget in the systray menu if the user is an employee -var Users = new Model('res.users'); -Users.call('has_group', ['base.group_user']).done(function(is_employee) { - if (is_employee) { - SystrayMenu.Items.push(ImTopButton); - } -}); +core.action_registry.add('mail.chat.instant_messaging', ChatMailThread); + return { - InstantMessaging: InstantMessaging, - UserWidget: UserWidget, + ChatMailThread: ChatMailThread, }; }); diff --git a/addons/mail/static/src/js/mail_chat_common.js b/addons/mail/static/src/js/mail_chat_common.js index 0aaa0e68d16..a5f52ddf4b1 100644 --- a/addons/mail/static/src/js/mail_chat_common.js +++ b/addons/mail/static/src/js/mail_chat_common.js @@ -1,13 +1,12 @@ -odoo.define('im_chat.im_chat_common', function (require) { +odoo.define('mail.chat_common', function (require) { "use strict"; -// to do: make this work in website - var bus = require('bus.bus'); var core = require('web.core'); var session = require('web.session'); var time = require('web.time'); var Widget = require('web.Widget'); +var mail_utils = require('mail.utils'); var _t = core._t; var QWeb = core.qweb; @@ -15,140 +14,130 @@ var QWeb = core.qweb; var NBR_LIMIT_HISTORY = 20; +/** + * Widget handeling the sessions/conversations + * + * Responsible to listen the bus and do the correct action according to the received notifications (fetch + * conversation, add message, ...) + **/ var ConversationManager = Widget.extend({ init: function(parent, options) { var self = this; this._super(parent); this.options = _.clone(options) || {}; _.defaults(this.options, { - inputPlaceholder: _t("Say something..."), - defaultMessage: null, - defaultUsername: _t("Visitor"), - force_open: false, + placeholder: _t("Say something..."), + default_username: _t("Visitor"), load_history: true, focus: false, }); + this.emoji_list = []; // business this.sessions = {}; this.bus = bus.bus; this.bus.on("notification", this, this.on_notification); - this.bus.options.im_presence = true; - // ui this.set("right_offset", 0); this.set("bottom_offset", 0); this.on("change:right_offset", this, this.calc_positions); this.on("change:bottom_offset", this, this.calc_positions); - this.set("window_focus", true); - this.on("change:window_focus", self, function(e) { - self.bus.options.im_presence = self.get("window_focus"); - }); this.set("waiting_messages", 0); this.on("change:waiting_messages", this, this.window_title_change); $(window).on("focus", _.bind(this.window_focus, this)); - $(window).on("blur", _.bind(this.window_blur, this)); this.window_title_change(); + // session widgets deferred (for their creation) + this._session_defs = {}; + }, + start: function(){ + var self = this; + return $.when(session.rpc("/mail/chat_init"), this._super.apply(this, arguments)).then(function(result){ + self._setup(result); + }); + }, + _setup: function(init_data){ + this.emoji_list = init_data['emoji']; + this.options['emoji'] = mail_utils.shortcode_substitution(init_data['emoji']); }, on_notification: function(notification, options) { var self = this; var channel = notification[0]; var message = notification[1]; var regex_uuid = new RegExp(/(\w{8}(-\w{4}){3}-\w{12}?)/g); + var is_channel_uuid = regex_uuid.test(channel); - // Concern im_chat : if the channel is the im_chat.session or im_chat.status, or a 'private' channel (aka the UUID of a session) - if((Array.isArray(channel) && (channel[1] === 'im_chat.session')) || (regex_uuid.test(channel))){ - // message to display in the chatview - if (message.type === "message" || message.type === "meta") { - self.message_receive(message); - } + // (dbname, 'res.partner', partner_id) receive only the new mail.channel header + if((Array.isArray(channel) && (channel[1] === 'res.partner')) || is_channel_uuid){ // activate the received session if(message.uuid){ - this.session_apply(message, options); + this.session_apply(message, options); + } + } + // (dbname, 'mail.channel', channel_id) receive only the mail.message of the channel + if((Array.isArray(channel) && (channel[1] === 'mail.channel')) || is_channel_uuid){ + // message to display in the chatview + if (message.message_type) { + self.message_receive(message); } } }, - // window focus unfocus beep and title window_focus: function() { - this.set("window_focus", true); this.set("waiting_messages", 0); }, - window_blur: function() { - this.set("window_focus", false); - }, window_beep: function() { - if (typeof(Audio) === "undefined") { - return; - } - var audio = new Audio(); - var ext = audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3"; - audio.src = session.url("/im_chat/static/src/audio/ting") + ext; - audio.play(); + mail_utils.beep(session); }, window_title_change: function() { var title = undefined; if (this.get("waiting_messages") !== 0) { - title = _.str.sprintf(_t("%d Messages"), this.get("waiting_messages")); this.window_beep(); } }, - // sessions and messages - session_apply: function(active_session, options){ - // options used by this function : force_open and focus (load_history can be usefull) + // sessions methods + session_apply: function(session, options){ + // options used by this function : focus (load_history can be usefull) var self = this; - options = _.extend(_.clone(this.options), options || {}); - // force open - var session = _.clone(active_session); - if(options['force_open']){ - session.state = 'open'; - } + options = _.defaults(options || {}, { + 'focus': true, + }, this.options); // create/get the conversation widget - var conv = this.sessions[session.uuid]; + var conv = this.sessions[session.id]; if (! conv) { if(session.state !== 'closed'){ - conv = new Conversation(this, this, session, options); - conv.appendTo($("body")); + conv = new Conversation(this, session, options); + // store this deferred to apply next action (notifications) when it is resolved. + this._session_defs[session.id] = conv.appendTo($("body")); + this._session_defs[session.id].then(function() { + self.calc_positions(); + }); conv.on("destroyed", this, _.bind(this.session_delete, this)); - this.sessions[session.uuid] = conv; - this.calc_positions(); + this.sessions[session.id] = conv; } }else{ - conv.set("session", session); - } - // if force open, broadcast it - if(options['force_open'] && active_session.state !== 'open'){ - conv.session_update_state('open'); - } - // apply the focus - if (options['focus']){ - conv.focus(); + // update the state only when the 'contruction' deferred is resolved. + this._session_defs[session.id].then(function(){ + conv.set('session', session); + }); } return conv; }, - session_delete: function(uuid){ - delete this.sessions[uuid]; + session_delete: function(channel_id){ + delete this.sessions[channel_id]; this.calc_positions(); }, - message_receive: function(message) { + /** + * Adding a message in all its channels + * /!\ Suppose the channel are already loaded in the ConversationManager /!\ + * @param {Object} message : the received message (formatted server side) + */ + message_receive: function(message){ var self = this; - var session_id = message.to_id[0]; - var uuid = message.to_id[1]; - var from_id = message['from_id'] ? message['from_id'][0] : false; - if (! this.get("window_focus") && from_id != this.get_current_uid()) { - this.set("waiting_messages", this.get("waiting_messages") + 1); - } - this._message_receive(message); - }, - _message_receive: function(message){ - var uuid = message.to_id[1]; - var conv = this.sessions[uuid]; - conv.message_receive(message); + _.each(message.channel_ids, function(channel_id){ + self.sessions[channel_id].message_receive(message); + }); }, // others - get_current_uid: function(){ - return session && !_.isUndefined(session.uid) ? session.uid : false; - }, calc_positions: function() { var self = this; var current = this.get("right_offset"); @@ -159,84 +148,72 @@ var ConversationManager = Widget.extend({ }); }, destroy: function() { - $(window).off("unload", this.unload); $(window).off("focus", this.window_focus); $(window).off("blur", this.window_blur); return this._super(); } }); - +/** + * Widget Conversation + * + * Display the conversation in a 'portable' window, manage sending and receiving message, + * and the fold state of the conversation. + **/ var Conversation = Widget.extend({ - className: "openerp_style oe_im_chatview", + template: "mail.chat.im.Conversation", events: { "keydown input": "keydown", - "click .oe_im_chatview_close": "click_close", - "click .oe_im_chatview_header": "click_header" + "click .o_mail_chat_im_window_close": "on_click_close", + "click .o_mail_chat_im_window_header": "on_click_header" }, - init: function(parent, c_manager, session, options) { + init: function(parent, session, options) { this._super(parent); - this.c_manager = c_manager; - this.options = options || {}; - this.loading_history = true; + this.options = _.defaults(options || {}, { + 'emoji': {}, + 'placeholder': _t('Write something ...'), + 'loading_history': true, + 'focus': true, + }); this.set("messages", []); this.set("session", session); + this.set("pending", 0); this.set("right_position", 0); this.set("bottom_position", 0); - this.set("pending", 0); - this.inputPlaceholder = this.options.defaultInputPlaceholder; + }, + willStart: function(){ + if(this.options.loading_history){ + return $.when(this.message_history(), this._super.apply(this, arguments)); + } + return this._super.apply(this, arguments); }, start: function() { var self = this; - self._super.apply(this, arguments); - self.prepare_action_menu(); // ui - self.$().append(QWeb.render("im_chat.Conversation", {widget: self})); self.on("change:right_position", self, self.calc_pos); self.on("change:bottom_position", self, self.calc_pos); - self.on("change:pending", self, _.bind(function() { - if (self.get("pending") === 0) { - self.$(".nbr_messages").text(""); - } else { - self.$(".nbr_messages").text("(" + self.get("pending") + ")"); - } - }, self)); + self.on("change:pending", self, self.message_pending_change); + self.full_height = self.$().height(); - self.calc_pos(); - // business - self.bind_action_menu(); - self.on("change:session", self, self.session_update); - self.on("change:messages", self, self.render_messages); - self.$('.oe_im_chatview_content').on('scroll',function(){ + self.$('.o_mail_chat_im_window_content').on('scroll',function(){ if($(this).scrollTop() === 0){ self.message_history(); } }); - if(self.options['load_history']){ // load history if asked - self.message_history(); - } - // prepare the header and the correct state - self.session_update(); - }, - // action menu - _add_action: function(label, style_class, icon_fa_class, callback){ - this.actions.push({ - 'label': label, - 'class': style_class, - 'icon_class': icon_fa_class, - 'callback': callback, - }); - }, - prepare_action_menu: function(){ - // override this method to add action with _add_action() - this.actions = []; - }, - bind_action_menu: function(){ - var self = this; - _.each(this.actions, function(action){ - if(action.callback){ - self.$('.button_option_group .' + action.class).on('click', _.bind(action.callback, self)); + // business + self.on("change:session", self, self.session_change); + self.on("change:messages", self, self.message_render); + + return self._super.apply(this, arguments).then(function(){ + // place the conversation window correctly on the screen + self.calc_pos(); + // focus if needed + if(self.options.focus){ + self.focus(); } + // re-apply the session to display the correct state + self.set('session', _.clone(self.get('session'))); + self._go_bottom(); }); }, // ui @@ -248,7 +225,7 @@ var Conversation = Widget.extend({ }, hide: function(){ this.$().animate({ - height: this.$(".oe_im_chatview_header").outerHeight() + height: this.$(".o_mail_chat_im_window_header").outerHeight() }); }, calc_pos: function() { @@ -256,21 +233,25 @@ var Conversation = Widget.extend({ this.$().css("bottom", this.get("bottom_position")); }, // session - session_update_state: function(state){ + /** + * Set the fold state LOCALLY for the session. This will + * trigger a ui update + * @param {string} state : the fold state ('fold', 'closed' or 'open') + */ + session_fold: function(state){ var session = this.get('session'); session.state = state; this.set('session', session); }, - session_update: function(){ - // built the name - var names = []; - _.each(this.get("session").users, function(user){ - if( (session.uid !== user.id) && !(_.isUndefined(session.uid) && !user.id) ){ - names.push(user.name); - } - }); - this.$(".header_name").text(names.join(", ")); - this.$(".header_name").attr('title', names.join(", ")); + /** + * Triggered when the session has changed : it update the header name + * and fold the window (ui) according to its state. + */ + session_change: function(){ + // update conversation name in header + var name = this.get("session").name || _t('Unknown Channel'); + this.$(".o_mail_chat_im_window_header_name").text(name); + this.$(".o_mail_chat_im_window_header_name").attr('title', name); // update the fold state if(this.get("session").state){ if(this.get("session").state === 'closed'){ @@ -285,39 +266,53 @@ var Conversation = Widget.extend({ } }, // messages + /** + * Load the history of the current session, and insert it into already loaded messages + * @returns {Deferred} resolved with the new loaded messages (Object[]) + */ message_history: function(){ var self = this; - if(this.loading_history){ - var data = {uuid: self.get("session").uuid, limit: NBR_LIMIT_HISTORY}; - var lastid = _.first(this.get("messages")) ? _.first(this.get("messages")).id : false; - if(lastid){ - data.last_id = lastid; - } - session.rpc("/im_chat/history", data).then(function(messages){ + if(this.options.loading_history){ + var data = { + uuid: self.get("session").uuid, + limit: NBR_LIMIT_HISTORY, + last_id: _.first(this.get("messages")) ? _.first(this.get("messages")).id : false, + }; + return session.rpc("/mail/chat_history", data).then(function(messages){ if(messages){ - self.insert_messages(messages); + self.message_insert(messages); if(messages.length != NBR_LIMIT_HISTORY){ - self.loading_history = false; + self.options.loading_history = false; } }else{ - self.loading_history = false; + self.options.loading_history = false; } + return messages; }); } + return $.Deferred().resolve([]); }, message_receive: function(message) { - if (this.get('session').state === 'open') { - this.set("pending", 0); - } else { - this.set("pending", this.get("pending") + 1); + // don't add duplicated message (this can happen when refreshing and receiving already recieved + // message that are still in the bus queue/buffer). + if(!_.contains(_.pluck(this.get('messages'), 'id'), message.id)){ + // is pending message ? + if (this.get('session').state === 'open') { + this.set("pending", 0); + } else { + if(!this.message_am_i_author(message)){ + this.set("pending", this.get("pending") + 1); + } + } + // insert it + this.message_insert([message]); + this._go_bottom(); } - this.insert_messages([message]); - this._go_bottom(); }, - message_send: function(message, type) { + message_send: function(message) { var self = this; var send_it = function() { - return session.rpc("/im_chat/post", {uuid: self.get("session").uuid, message_type: type, message_content: message}); + return session.rpc("/mail/chat_post", {uuid: self.get("session").uuid, message_content: message}); }; var tries = 0; send_it().fail(function(error, e) { @@ -327,71 +322,65 @@ var Conversation = Widget.extend({ return send_it(); }); }, - insert_messages: function(messages){ + /** + * Preporcess the message (add informations in the Object, ...) + * @param {Object[]} messages : server side formatted message (from message_format method) + */ + _message_preprocess: function(raw_messages){ var self = this; - // avoid duplicated messages - messages = _.filter(messages, function(m){ return !_.contains(_.pluck(self.get("messages"), 'id'), m.id) ; }); - // escape the message content and set the timezone - _.map(messages, function(m){ - if(!m.from_id){ - m.from_id = [false,self. get_anonymous_name()]; + _.each(raw_messages, function(m){ + m.date = moment(time.str_to_datetime(m.date)).format('YYYY-MM-DD HH:mm:ss'); // set the date in the correct browser user timezone + if(m.body){ + m.body = mail_utils.shortcode_apply(m.body, self.options.emoji); } - m.create_date = moment(time.str_to_datetime(m.create_date)).format('YYYY-MM-DD HH:mm:ss'); - return m; + _.each(m.attachment_ids, function(a){ + a.url = mail_utils.get_attachment_url(session, m.id, a.id); + a.fa_class = mail_utils.attachment_filetype_to_fa_class(a.file_type_icon); + }); }); + return raw_messages; + }, + /** + * Insert new raw message into the current ones + * @param {Object[]} messages : server side formatted message (from message_format method) + */ + message_insert: function(raw_messages){ + var messages = this._message_preprocess(raw_messages); this.set("messages", _.sortBy(this.get("messages").concat(messages), function(m){ return m.id; })); }, - render_messages: function(){ + message_render: function(){ var self = this; - var res = {}; - var last_date_day, last_user_id = -1; - _.each(this.get("messages"), function(current){ - // add the url of the avatar for all users in the conversation - current.from_id[2] = session.url(_.str.sprintf("/im_chat/image/%s/%s", self.get('session').uuid, current.from_id[0])); - var date_day = current.create_date.split(" ")[0]; - if(date_day !== last_date_day){ - res[date_day] = []; - last_user_id = -1; - } - last_date_day = date_day; - if(current.type == "message"){ // traditionnal message - if(last_user_id === current.from_id[0]){ - _.last(res[date_day]).push(current); - }else{ - res[date_day].push([current]); - } - last_user_id = current.from_id[0]; - }else{ // meta message - res[date_day].push([current]); - last_user_id = -1; - } - }); - // render and set the content of the chatview - // TODO jem : when refactoring this, don't forget to pre-process date in this function before render quweb template - // since, moment will not be define in qweb on the website pages, because the helper (see csn) is in core.js and cannot be - // imported in the frontend. - this.$('.oe_im_chatview_content_bubbles').html($(QWeb.render("im_chat.Conversation_content", {"list": res}))); - this._go_bottom(); + var message_html = $(QWeb.render("mail.chat.im.Conversation.messages", {"widget": this})); + this.$('.o_mail_chat_im_window_content').html(message_html); + }, + message_am_i_author: function(message){ + return message.author_id && message.author_id[0] === session.partner_id; + }, + message_pending_change: function(event){ + if (this.get("pending") === 0) { + this.$(".o_mail_chat_im_window_header_counter").text(""); + } else { + this.$(".o_mail_chat_im_window_header_counter").text("(" + this.get("pending") + ")"); + } }, // utils and event - get_anonymous_name: function(){ - var name = this.options["defaultUsername"]; - _.each(this.get('session').users, function(u){ - if(!u.id){ - name = u.name; - } - }); - return name; + _go_bottom: function() { + if(this.$(".o_mail_chat_im_window_content").length){ + this.$(".o_mail_chat_im_window_content").scrollTop(this.$(".o_mail_chat_im_window_content").get(0).scrollHeight); + } + }, + focus: function() { + this.$(".o_mail_chat_im_window_input").focus(); }, keydown: function(e) { if(e && e.which == 27) { - if(this.$el.prev().find('.oe_im_chatview_input').length > 0){ - this.$el.prev().find('.oe_im_chatview_input').focus(); + if(this.$el.prev().find('.o_mail_chat_im_window_input').length > 0){ + this.$el.prev().find('.o_mail_chat_im_window_input').focus(); }else{ - this.$el.next().find('.oe_im_chatview_input').focus(); + this.$el.next().find('.o_mail_chat_im_window_input').focus(); } e.stopPropagation(); - this.session_update_state('closed'); + this.session_fold('closed'); } if(e && e.which !== 13) { return; @@ -401,25 +390,17 @@ var Conversation = Widget.extend({ return; } this.$("input").val(""); - this.message_send(mes, "message"); + this.message_send(mes); }, - _go_bottom: function() { - this.$(".oe_im_chatview_content").scrollTop(this.$(".oe_im_chatview_content").get(0).scrollHeight); + on_click_header: function(event){ + this.session_fold(); }, - focus: function() { - this.$(".oe_im_chatview_input").focus(); - }, - click_header: function(event){ - var classes = event.target.className.split(' '); - if(_.contains(classes, 'header_name') || _.contains(classes, 'oe_im_chatview_header')){ - this.session_update_state(); - } - }, - click_close: function(event) { - this.session_update_state('closed'); + on_click_close: function(event) { + event.stopPropagation(); // avoid triggering event for 'on_click_header' + this.session_fold('closed'); }, destroy: function() { - this.trigger("destroyed", this.get('session').uuid); + this.trigger("destroyed", this.get('session').id); return this._super(); } }); @@ -430,4 +411,4 @@ return { ConversationManager: ConversationManager, }; -});
\ No newline at end of file +}); diff --git a/addons/mail/static/src/js/mail_chatter.js b/addons/mail/static/src/js/mail_chatter.js new file mode 100644 index 00000000000..7032f864eb4 --- /dev/null +++ b/addons/mail/static/src/js/mail_chatter.js @@ -0,0 +1,471 @@ +odoo.define('mail.chatter', function (require) { +"use strict"; + +var core = require('web.core'); +var data = require('web.data'); +var session = require('web.session'); +var web_client = require('web.web_client'); +var Model = require('web.Model'); +var Widget = require('web.Widget'); +var form_common = require('web.form_common'); +var SystrayMenu = require('web.SystrayMenu'); +var mail_utils = require('mail.utils'); +var mail_thread = require('mail.thread'); + +var QWeb = core.qweb; +var _t = core._t; +var internal_bus = core.bus; + + +/** + * Mail Compose Message for Chatter + * + * Extends the basic MailComposeMessage Widget to add 'suggested partner' layer (open popup + * when suggested partner is selected without email, or other informations), and the button + * to open the 'mail.compose.message' wizard. + **/ +var ChatterMailComposeMessage = mail_thread.MailComposeMessage.extend({ + template: 'mail.chatter.ComposeMessage', + init: function(parent, dataset, options){ + this._super.apply(this, arguments); + this.suggested_partners = []; + this.context = options.context || {}; + // options + this.options = _.defaults(options, { + 'record_name': false, + 'is_log': false, + 'internal_subtypes': [], + }); + }, + 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(){ + this.$input = this.$('.o_mail_compose_message_input'); + this.$input.focus(); + // add mail compose message full button + this.$('.o_mail_compose_message_button_group').append('<button class="btn btn-default o_mail_compose_message_button_full_message" type="button" data-toggle="popover"><i class="fa fa-pencil-square-o"/></button>') + this.$('.o_mail_compose_message_button_full_message').on('click', this, this.on_compose_message); + return this._super.apply(this, arguments); + }, + 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 = mail_utils.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 partner + * @returns Array() : list of 'recipient' selected partner (may not be created in db) + **/ + get_checked_suggested_partners: function(){ + var self = this; + var checked_partners = []; + this.$('.o_mail_chatter_compose_message_suggested_partners input:checked').each(function(index){ + var $input = $(this); + var full_name = $input.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 = mail_utils.parse_email(partner_name); + + var pop = new form_common.FormViewDialog(this, { + 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(); + pop.on('closed', self, function () { + deferred.resolve(); + }); + pop.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; + }, + message_post: function(body, attachment_ids, values){ + var self = this; + var _super = self._super; + // default values + values = _.defaults(values || {}, { + 'message_type': 'comment', + 'content_subtype': 'html', + 'partner_ids': [], + 'subtype_id': false, + 'subtype': false, + }); + // update subtype + if(this.options.is_log) { + var subtype_id = parseInt(this.$('.o_mail_chatter_compose_message_subtype_select').val()); + if(_.indexOf(_.pluck(this.internal_subtypes, 'id'), subtype_id) == -1) { + values['subtype'] = 'mail.mt_note' + }else{ + values['subtype_id'] = subtype_id; + } + }else{ + values['subtype'] = 'mail.mt_comment'; + } + // update partner_ids + var def = $.when(); + if(this.options.is_log){ + // directly call the super() + def = _.bind(_super, self)(body, attachment_ids, values); + }else{ + // call the super() when all partner popup are treated + var checked_suggested_partners = this.get_checked_suggested_partners(); + this.check_suggested_partners(checked_suggested_partners).done(function(partner_ids){ + // update context + values['partner_ids'] = partner_ids; + values['context'] = _.defaults(self.context, { + 'mail_post_autofollow': true, + 'mail_post_autofollow_partner_ids': partner_ids, + }); + def = _.bind(_super, self)(body, attachment_ids, values); + }); + } + return def + }, + is_empty: function(){ + return this.$input.val() === ''; + }, + on_compose_message: function(event){ + var self = this; + if (!this.do_check_attachment_upload()){ + return false; + } + + 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': mail_utils.get_text2html(self.$('.o_mail_compose_message_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) + * + * Displayed on every document. Since it is displayed on a form view, it extends 'AbstractField' widget, and + * required the behaviour of 'MailThreadMixin' to manage mail.message (using common methods). + * This widget is responsible of the toggle the message composer and the custom message rendering. + **/ +var ChatterMailThread = form_common.AbstractField.extend(mail_thread.MailThreadMixin, { + template: 'mail.chatter.ChatterMailThread', + events: { + "click .o_mail_redirect": "on_click_redirect", + "click .o_mail_thread_message_star": "on_message_star", + "click .o_mail_thread_message_needaction": "on_message_needaction", + "click .o_mail_chatter_show_more_message": "on_message_show_more", + // toggle message composer (!! declaration order is important !!) + "click .o_mail_chatter_button_new_message": "on_open_composer", + "click .o_mail_chatter_button_log_note": "on_open_composer", + "blur .o_mail_compose_message_input": "on_close_composer", + "mousedown .o_mail_chatter_compose_message": function(event) { + this.stay_open = true; + }, + "focus .o_mail_compose_message_input": function(event) { + this.stay_open = false; + }, + "keydown .o_mail_compose_message_input": function(event) { + this.stay_open = false; + }, + }, + init: function (parent, node){ + this._super.apply(this, arguments); + mail_thread.MailThreadMixin.init.call(this); + this.message_composer = undefined; + this.record_name = ''; + this.context = this.options.context || {}; + // options from 'message_ids' node in field_view_get + this.options = _.defaults(this.options, { + 'display_log_button': true, + 'display_document_link': false, + 'internal_subtypes': [], + 'emoji_list': [], + }); + this.emoji_set_substitution(this.options.emoji_list); + }, + start: function(){ + mail_thread.MailThreadMixin.start.call(this); + return this._super.apply(this, arguments); + }, + on_message_show_more: function(event){ + var self = this; + this.message_load_history().then(function(messages){ + if(messages.length < mail_thread.LIMIT_MESSAGE){ + self.$('.o_mail_chatter_show_more_message').hide(); + } + }); + }, + /** + * 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_message_post: function(message_id){ + var self = this; + return this.message_format([message_id]).then(function(messages){ + self.stay_open = false; + self.message_insert(messages); + self.on_close_composer(); + }); + }, + /** + * 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(){ + var self = this; + // re-rendering for mail composer message + this.options.display_show_more_message = this.get_value().length > mail_thread.LIMIT_MESSAGE; + this.renderElement(); + // reset dataset + this.ThreadDataset = this.view.dataset; + // update context (require for mail.compose.message wizard) + this.context = _.extend({ + 'mail_read_set_read': true, // set messages as read in Chatter TODO JEM : not used anymore + 'default_res_id': this.view.datarecord.id || false, + 'default_model': this.view.model || false, + }, this.options.context || {}); + // fetch messages of current document + this.message_format(this.get_value().slice(0, mail_thread.LIMIT_MESSAGE)).then(function(raw_messages){ + self._message_replace(self._message_preprocess(raw_messages)); + self.record_name = _.last(raw_messages) ? _.last(raw_messages).record_name : ''; + // set the thread as read + self.ThreadDataset.call('message_set_read', [[self.view.datarecord.id]]).then(function(message_ids){ + // decrement the needaction top counter + internal_bus.trigger('mail_needaction_done', message_ids.length); + }); + }); + }, + // composer toggle + on_open_composer: function(event){ + var self = this; + this.$('.o_mail_chatter_compose_buttons').hide(); + this.$('.o_mail_chatter_compose_message_wrap').show(); + // destroy existing composer + if(this.message_composer){ + this.message_composer.destroy(); + } + // create the new composer + this.message_composer = new ChatterMailComposeMessage(this, this.ThreadDataset, { + 'internal_subtypes': this.options.internal_subtypes, + 'is_log': this.$(event.currentTarget).hasClass('o_mail_chatter_button_log_note'), + 'emoji_list': this.options.emoji_list, + 'context': this.context, + 'record_name': this.record_name, + }); + this.message_composer.appendTo(this.$('.o_mail_chatter_compose_message_wrap')); + this.message_composer.on('message_sent', this, this.on_message_post); + this.message_composer.on('need_refresh', this, function(e){ + self.message_refresh(); + self.on_close_composer(); + }); + }, + on_close_composer: function(event){ + if(this.message_composer && this.message_composer.is_empty() && !this.stay_open){ + this.$('.o_mail_chatter_compose_buttons').show(); + this.$('.o_mail_chatter_compose_message_wrap').hide(); + } + }, + /** + * Refresh the current message, and set them as current message. Use 'message_fetch', since its + * domain don't use 'ids', so if there is new messages, they'll be fetched. + * @returns {Deferred} + */ + message_refresh: function(){ + return this.message_load_new(); + }, + /** + * Render the messages + * @override + */ + message_render: function(){ + this.$('.o_mail_chatter_messages').html(QWeb.render('mail.chatter.ChatterMailThread.messages', {'widget': this})); + }, + /** + * Order the messages + * @param {Object[]} messages : messages to order + * @override + */ + _message_order: function(messages){ + var messages = mail_thread.MailThreadMixin._message_order.call(this, messages); + return messages.reverse(); + }, + /** + * Return the message domain for the current document mail thread + * @override + */ + get_message_domain: function(){ + var res_id = this.ThreadDataset.ids[this.ThreadDataset.index]; + return [['model', '=', this.ThreadDataset.model], ['res_id', '=', res_id]]; + } +}); + +core.form_widget_registry.add('mail_thread', ChatterMailThread); + + + +/** + * Global ComposeMessage Top Button + * + * Add a link on the top user bar to write a full mail. It opens the form view + * of the mail.compose.message (in a modal). + */ +var ComposeMessageTopButton = Widget.extend({ + template:'mail.ComposeMessageTopButton', + events: { + "click": "on_compose_message", + }, + on_compose_message: function (ev) { + ev.preventDefault(); + var ctx = {}; + if ($('button.o_timeline_compose_post') && $('button.o_timeline_compose_post').is(":visible") == true && + (this.getParent()).getParent().action_manager.inner_widget.active_view.type == 'form'){ + ctx = { + 'default_res_id': (this.getParent()).getParent().action_manager.inner_widget.active_view.controller.datarecord.id, + 'default_model': (this.getParent()).getParent().action_manager.inner_widget.active_view.controller.model, + }; + } + web_client.action_manager.do_action({ + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: ctx, + }); + }, +}); + +// Put the ComposeMessageTopButton widget in the systray menu +SystrayMenu.Items.push(ComposeMessageTopButton); + + +return { + ChatterMailThread: ChatterMailThread, +}; + +}); diff --git a/addons/mail/static/src/js/mail_followers.js b/addons/mail/static/src/js/mail_followers.js index 0f2353c729e..124ed218a7e 100644 --- a/addons/mail/static/src/js/mail_followers.js +++ b/addons/mail/static/src/js/mail_followers.js @@ -13,9 +13,9 @@ var web_client = require('web.web_client'); var _t = core._t; var qweb = core.qweb; -/** +/** * ------------------------------------------------------------ - * timeline_followers Widget + * Mail Followers Widget * ------------------------------------------------------------ * * This widget handles the display of a list of records as a vertical @@ -27,7 +27,7 @@ var qweb = core.qweb; var Followers = form_common.AbstractField.extend({ - template: 'mail_followers', + template: 'mail.Followers', init: function() { this._super.apply(this, arguments); @@ -40,7 +40,7 @@ var Followers = form_common.AbstractField.extend({ this.value = []; this.followers = []; this.data_subtype = {}; - + this.view_is_editable = this.__parentedParent.is_action_enabled('edit'); }, @@ -112,7 +112,7 @@ var Followers = form_common.AbstractField.extend({ size: 'small', title: _t('Edit Subscription of ') + $currentTarget.siblings('a').text(), buttons: [ - { text: _t("Apply"), click: function() { + { text: _t("Apply"), click: function() { self.do_update_subscription(event, user_pid); this.parents('.modal').modal('hide'); }}, @@ -236,11 +236,11 @@ var Followers = form_common.AbstractField.extend({ 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; - $(qweb.render('mail_followers_add_more', {'widget': self})).appendTo(node_user_list); + $(qweb.render('mail.Followers.add_more', {'widget': self})).appendTo(node_user_list); // truncate number of displayed followers _(this.followers).each(function (record) { - $(qweb.render('mail_followers_partner', { + $(qweb.render('mail.Followers.partner', { 'record': _.extend(record, {'avatar_url': '/web/image/' + record['res_model'] + '/' + record['res_id'] + '/image_small'}), 'widget': self}) ).appendTo(node_user_list); @@ -279,7 +279,7 @@ var Followers = form_common.AbstractField.extend({ if (user_pid) { dialog = true; - } + } else { var subtype_list_ul = this.$('.o_timeline_subtype_list ul').empty(); if (! this.message_is_follower) { @@ -302,7 +302,7 @@ var Followers = form_common.AbstractField.extend({ display_subtypes:function (data, dialog) { var self = this; var $list = this.$('.o_timeline_subtype_list ul'); - + if (_.isEmpty(this.data_subtype)) { this.data_subtype = data; @@ -326,7 +326,7 @@ var Followers = form_common.AbstractField.extend({ } old_parent_model = record.parent_model; record.followed = record.followed || undefined; - $(qweb.render('mail_followers_subtype', {'record': record, + $(qweb.render('mail.Followers.subtype', {'record': record, 'dialog': dialog})) .appendTo($list); }); @@ -336,9 +336,9 @@ var Followers = form_common.AbstractField.extend({ do_follow: function () { var context = new data.CompoundContext(this.build_context(), {}); this.$('.o_timeline_subtype_list > .dropdown-toggle').attr('disabled', false); - this.ds_model.call('message_subscribe_users', [[this.view.datarecord.id], - [session.uid], - undefined, + this.ds_model.call('message_subscribe_users', [[this.view.datarecord.id], + [session.uid], + undefined, context]) .then(this.proxy('read_value')); @@ -403,9 +403,9 @@ var Followers = form_common.AbstractField.extend({ $(event.target).attr("checked", "checked"); } else { - self.$('.o_timeline_subtype_list ul').empty(); + self.$('.o_timeline_subtype_list ul').empty(); } - } + } else { var context = new data.CompoundContext(this.build_context(), {}); return this.ds_model.call(action_subscribe, [[this.view.datarecord.id], follower_ids, undefined, checklist, context]) diff --git a/addons/mail/static/src/js/mail_thread.js b/addons/mail/static/src/js/mail_thread.js new file mode 100644 index 00000000000..b40e1df5ced --- /dev/null +++ b/addons/mail/static/src/js/mail_thread.js @@ -0,0 +1,430 @@ +odoo.define('mail.thread', function (require) { +"use strict"; + +var core = require('web.core'); +var data = require('web.data'); +var time = require('web.time'); +var Widget = require('web.Widget'); +var session = require('web.session'); +var web_client = require('web.web_client'); +var mail_utils = require('mail.utils'); + +var _t = core._t; +var QWeb = core.qweb; + + +var LIMIT_MESSAGE = 20; + +/** + * Widget : Input textbox to post messages + * + * Input with 2 buttons to manage attachment and emoji + * - Attachment : upload selected attachment (one at a time) and display them below the textbox. Prevent + posting message while uploading + * - Emoji : popover exposing emoji list, and append emoji shortcode to the textbox when click on emoji image + * It triggers 'message_sent' event with the posted message, when it is sent to the server. + */ +var MailComposeMessage = Widget.extend({ + template: 'mail.ComposeMessage', + events: { + "keydown .o_mail_compose_message_input": "on_keydown", + "change input.oe_form_binary_file": "on_attachment_change", + "click .o_mail_compose_message_attachment_list .o_mail_attachment_delete": "on_attachment_delete", + "click .o_mail_compose_message_button_attachment": 'on_click_attachment', + "click .o_mail_compose_message_button_send": 'message_send', + }, + /** + * Contructor + * @param {Object} options : the options of the MailComposeMessage + * @param {Object[]} [options.emoji_list] : the list of emoji + * @param {Object} options.context : the context. It should contains the default_res_id' (id of the current document). + * @param {chat|mail} options.display_mode : the 'chat' mode will display an input, when the 'mail' mode will display a texarea. + */ + init: function(parent, dataset, options){ + this._super.apply(this, arguments); + this.thread_dataset = dataset; + this.options = _.defaults(options || {}, { + 'emoji_list': {}, + 'context': {}, + 'display_mode': 'mail', + }); + this.emoji_list = this.options.emoji_list; + this.context = this.options.context; + // attachment handeling + this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); + this.fileupload_id = _.uniqueId('o_mail_chat_fileupload'); + this.set('attachment_ids', []); + }, + start: function(){ + var self = this; + this.$input = this.$('.o_mail_compose_message_input'); + this.$input.focus(); + this.$attachment_button = this.$(".o_mail_compose_message_button_attachment"); + // attachments + $(window).on(this.fileupload_id, this.on_attachment_loaded); + this.on("change:attachment_ids", this, this.attachment_render); + // emoji + self.$('.o_mail_compose_message_button_emoji').popover({ + placement: 'top', + content: function(){ + if(!self.$emoji){ // lazy rendering + self.$emoji = $(QWeb.render('mail.ComposeMessage.emoji', {'widget': self})); + self.$emoji.find('.o_mail_compose_message_emoji_img').on('click', self, self.on_click_emoji_img); + } + return self.$emoji; + }, + html: true, + container: '.o_mail_compose_message_emoji', + trigger: 'focus', + }); + return this._super(); + }, + // events + on_click_attachment: function(event){ + event.preventDefault(); + this.$('input.oe_form_binary_file').click(); + }, + on_click_emoji_img: function(event){ + this.$input.val(this.$input.val() + " " + $(event.currentTarget).data('emoji')+ " "); + this.$input.focus(); + }, + on_keydown: function(event){ + if(event.which === 13 && this.options.display_mode === 'chat') { + this.message_send(); + } + }, + message_send: function(){ + var $input = this.$('.o_mail_compose_message_input'); + var mes = mail_utils.get_text2html($input.val()); + if (! mes.trim() && this.do_check_attachment_upload()) { + return; + } + $input.val(""); + this.message_post(mes, _.pluck(this.get('attachment_ids'), 'id')); + }, + /** + * Sent the message to the server to create a mail.message, using 'message_post' method + * @param {String} body : the core of the message (html, or plaintext) + * @param {Number[]} attachment_ids : attachment_ids linked to the message + * @param {Object} kwargs : other parameters (in a dictionnary) + * @returns {Deferred} resolved when the message is posted, and returning the new message id + */ + message_post: function(body, attachment_ids, kwargs){ + var self = this; + var values = _.defaults(kwargs || {}, { + 'body': body, + 'attachment_ids': attachment_ids || [], + 'message_type': 'comment', + 'content_subtype': 'html', + 'partner_ids': [], + 'subtype': 'mail.mt_comment', + }); + return this.thread_dataset._model.call('message_post', [this.context.default_res_id], values).then(function(message_id){ + self.clean_attachments(); // empty attachment list + self.trigger('message_sent', message_id); + return message_id; + }); + }, + // attachment business + 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.oe_form_binary_form').submit(); + this.$attachment_button.prop('disabled', true); + + attachments.push({ + 'id': 0, + 'name': filename, + 'filename': filename, + 'url': '', + 'upload': true + }); + 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, + 'url': session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'name', 'id': result.id}), + }); + }else{ + attachment_ids.push(a); + } + }); + } + this.set('attachment_ids', attachment_ids); + + // TODO JEM : understand the 2 lines below .... + var $input = this.$('input.oe_form_binary_file'); + $input.after($input.clone(true)).remove(); + + 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; + }, + clean_attachments: function(){ + this.set('attachment_ids', []); + }, + // ui + attachment_render: function(){ + this.$('.o_mail_compose_message_attachment_list').html(QWeb.render('mail.ComposeMessage.attachments', {'widget': this})); + }, + focus: function(){ + this.$input.focus(); + } +}); + + +/** + * Mail Thread Mixin : Messages Managment + * + * Load, Fetch, Display mail.message + * This is a mixin since it will be inherit by a form_common.AbstractField and a Wigdet (Client Action) + * + * @mixin + **/ +var MailThreadMixin = { + /** + * Constructor of the 'mail_thread' mixin + * @param {Boolean} [options.display_log_button] : display of the 'log a note' button + * @param {Boolean} [options.display_document_link] : display document link on message + * @param {Boolean} [options.display_needaction_button] : display the needaction 'check' button on messages + * @param {Object[]} [options.internal_subtypes]: list of internal subtype (for employee only) + * @param {Object[]} [options.emoji_list] : list of emoji + */ + init: function(){ + this.MessageDatasetSearch = new data.DataSetSearch(this, 'mail.message'); + this.set('messages', []); + this.emoji_substitution = {}; + // options + this.options = _.defaults(this.options || {}, { + 'display_log_button': false, + 'display_document_link': false, + 'display_needaction_button': false, + 'internal_subtypes': [], + 'emoji_list': [], + }); + }, + start: function(){ + this.on("change:messages", this, this.message_render); + }, + // Common Actions (They should be bind on the implementing widget, the 'events' dict) + /** + * Generic redirect action : redirect to the form view if the + * click node contains 'data-oe-model' and 'data-oe-id'. + */ + on_click_redirect: function(event){ + event.preventDefault(); + var res_id = $(event.target).data('oe-id'); + var res_model = $(event.target).data('oe-model'); + web_client.action_manager.do_push_state({ + 'model': res_model, + 'id': res_id, + 'title': this.record_name, + }); + this.do_action({ + type:'ir.actions.act_window', + view_type: 'form', + view_mode: 'form', + res_model: res_model, + views: [[false, 'form']], + res_id: res_id, + }, { + 'on_reverse_breadcrumb': this.on_reverse_breadcrumb, + }); + }, + /** + * Will be called when clicking on the Breadcrumb (mainly of client action) + * Need to be override + */ + on_reverse_breadcrumb: function(){ + + }, + /** + * Toggle a message as 'starred' (or not), by toggling the 'o_mail_message_starred' css class. + * Trigger when clicking on selector '.o_mail_thread_message_star'. + */ + on_message_star: function(event){ + var $source = this.$(event.currentTarget); + var mid = $source.data('message-id'); + var is_starred = !$source.hasClass('o_mail_message_starred'); + return this.MessageDatasetSearch.call('set_message_starred', [[mid], is_starred]).then(function(res){ + $source.toggleClass('o_mail_message_starred'); + return mid; + }); + }, + on_message_needaction: function(event){ + var $source = this.$(event.currentTarget); + var mid = $source.data('message-id'); + return this.MessageDatasetSearch.call('set_message_done', [[mid]]).then(function(res){ + $source.remove(); + return mid; + }); + }, + // Message functions + /** + * Fetch given message + * @param {Number[]} message_ids : list of mail.message identifiers to fetch + * @returns {Deferred} resolved when the messages are loaded + */ + message_format: function(message_ids){ + return this.MessageDatasetSearch._model.call('message_format', [message_ids]); + }, + /** + * Fetch mail.message in the format defined server side + * @param {Array} domain : Odoo Domain of the message to fetch + * @param {Number} limit : the limit of messages to fetch + * @returns {Deferred} resolved when the messages are loaded + */ + message_fetch: function(domain, limit){ + domain = domain || this.get_message_domain(); + limit = limit || LIMIT_MESSAGE; + return this.MessageDatasetSearch._model.call('message_fetch', [domain], {limit: limit}); + }, + _message_replace: function(messages){ + this.set('messages', this._message_order(messages)); + }, + /** + * Order the message in a particular order, to be defined by implementation + * @param {Object[]} messages : messages to order + */ + _message_order: function(messages){ + return _.sortBy(messages, 'date'); + }, + /** + * Insert messages in the current list + * @param {Object[]} messages : list of mail.message (formatted server side) + */ + message_insert: function(raw_messages){ + var current_messages = this.get('messages'); + current_messages = current_messages.concat(this._message_preprocess(raw_messages)); + this._message_replace(current_messages); + }, + /** + * Load history of message, with the 'history' domain, and insert them in the current list + * @returns {Deferred} resolved when the messages are fetched and returning the raw + * messages (formatted server side) + */ + message_load_history: function(){ + var self = this; + return this.message_fetch(this.get_message_domain_history()).then(function(raw_messages){ + self.message_insert(raw_messages); + return raw_messages + }); + }, + /** + * Load messages according to the current domain, and set that new list as current messages, + * erasing the old ones. + * @returns {Deferred} resolved when the messages are fetched and returning the raw + * messages (formatted server side) + */ + message_load_new: function(){ + var self = this; + return this.message_fetch(this.get_message_domain()).then(function(raw_messages){ + self._message_replace(self._message_preprocess(raw_messages)); + return raw_messages + }); + }, + /** + * Preprocess the list of messages before rendering, add 'computed' field (is_needaction, + * is_starred, ...), and apply image shortcode to the message body. + * @param {Object[]} messages : list of raw mail.message (formatted server side) + * @returns list of messages. It can be sorted, grouped, ... + */ + _message_preprocess: function(messages){ + var self = this; + _.each(messages, function(m){ + m.is_needaction = _.contains(m.needaction_partner_ids, session.partner_id); + m.is_starred = _.contains(m.starred_partner_ids, session.partner_id); + m.date = moment(time.str_to_datetime(m.date)).format('YYYY-MM-DD HH:mm:ss'); // set the date in the correct browser user timezone + if(m.body){ + m.body = mail_utils.shortcode_apply(m.body, self.emoji_substitution); + } + _.each(m.attachment_ids, function(a){ + a.url = mail_utils.get_attachment_url(session, m.id, a.id); + }); + }); + return _.sortBy(messages, 'date'); + }, + /** + * Take the current messages, render them, and insert the rendering in the DOM. + * This is triggered when the mesasge list change. + * Must be redefined, since it depends on the complete DOM widget + */ + message_render: function(){ + + }, + // Message Domains + /** + * Return the current domain to fetch the message of the thread. this should + * be redifined by the thread implementation. + */ + get_message_domain: function(){ + return []; + }, + get_message_domain_history: function(){ + return this.get_message_domain().concat([['id', '<', _.min(_.pluck(this.get('messages'), 'id'))]]); + }, + // Others + /** + * Set the list of emoji to be substituted in message body + * @param {Object[]} emoji_list : list of emoji Object + */ + emoji_set_substitution: function(emoji_list){ + this.emoji_substitution = mail_utils.shortcode_substitution(emoji_list); + }, +}; + + +return { + MailComposeMessage: MailComposeMessage, + MailThreadMixin: MailThreadMixin, + LIMIT_MESSAGE: LIMIT_MESSAGE, +} + + +}); diff --git a/addons/mail/static/src/js/mail_utils.js b/addons/mail/static/src/js/mail_utils.js index 656dc941a75..a18dc449d6b 100644 --- a/addons/mail/static/src/js/mail_utils.js +++ b/addons/mail/static/src/js/mail_utils.js @@ -5,7 +5,7 @@ odoo.define('mail.utils', function () { * ------------------------------------------------------------ * ChatterUtils * ------------------------------------------------------------ - * + * * This class holds a few tools method for Chatter. * Some regular expressions not used anymore, kept because I want to * - (^|\s)@((\w|@|\.)*): @login@log.log @@ -28,24 +28,6 @@ function parse_email(text) { } /** - * Replaces some expressions - * - :name - shortcut to an image - */ -function do_replace_expressions(string) { - var icon_list = ['al', 'pinky']; - /* special shortcut: :name, try to find an icon if in list */ - var regex_login = new RegExp(/(^|\s):((\w)*)/g); - var regex_res = regex_login.exec(string); - while (regex_res !== null) { - var icon_name = regex_res[2]; - if (_.include(icon_list, icon_name)) - string = string.replace(regex_res[0], regex_res[1] + '<img src="/mail/static/src/img/_' + icon_name + '.png" width="22px" height="22px" alt="' + icon_name + '"/>'); - regex_res = regex_login.exec(string); - } - return string; -} - -/** * Replaces textarea text into html text (add <p>, <a>) * TDE note : should be done server-side, in Python -> use mail.compose.message ? */ @@ -55,7 +37,7 @@ function get_text2html(text) { .replace(/[\n\r]/g,'<br/>'); } -/* Returns the complete domain with "&" +/* Returns the complete domain with "&" * TDE note: please add some comments to explain how/why */ function expand_domain(domain) { @@ -90,6 +72,8 @@ function breakword(str){ return out; } + +// TODO JEM : remove me in master function bindTooltipTo($el, value, position) { $el.tooltip({ 'title': value, @@ -106,13 +90,92 @@ function bindTooltipTo($el, value, position) { }); } + +/** + * ------------------------------------------------------------ + * MailChat Utils + * ------------------------------------------------------------ + */ + + /** + * Apply the given shortcode to the 'str' message. + * @param str : the text to substitute (html). + * @param shortcodes : dict where key is the shortcode, and the value is the substitution + */ +function shortcode_apply(str, shortcodes){ + var re_escape = function(str){ + return String(str).replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1'); + }; + _.each(_.keys(shortcodes), function(key){ + str = str.replace( new RegExp("(?:^|\\s|<[a-z]*>)(" + re_escape(key) + ")(?:\\s|$|</[a-z]*>)"), ' <span class="o_mail_emoji">'+shortcodes[key]+'</span> '); + }); + return str; +} + +/** + * Transform the list of emoji (shortcode Object) into a key-array representing + * the substitution to apply + * @param {Ojbect[]} emoji_list : list of emoji object + * @returns {Object} mapping between the code (as key) and the substitution (as value) + */ +function shortcode_substitution(emoji_list){ + var emoji_substitution = {}; + _.each(emoji_list, function(emoji){ + emoji_substitution[emoji.source] = emoji.substitution; + }); + return emoji_substitution; +} + +/** + * Associate a font awesome class to a attachment file type + * @param {string} file_type : file type to analyse + * @returns {string} the fa class associated + */ +function attachment_filetype_to_fa_class(file_type){ + var mapping = { + 'text': 'fa fa-file-text-o', + 'document': 'fa fa-file-word-o', + 'audio': 'fa fa-file-audio-o', + 'archive': 'fa fa-file-archive-o', + 'disk': 'fa fa-file-archive-o', + 'image': 'fa fa-file-photo-o', + 'webimage': 'fa fa-file-photo-o', + 'script': 'fa fa-file-code-o', + 'html': 'fa fa-file-code-o', + 'video': 'fa fa-file-movie-o', + 'spreadsheet': 'fa fa-file-excel-o', + 'print': 'fa fa-file-pdf-o', + 'presentation': 'fa fa-file-powerpoint-o', + } + if(_.contains(_.keys(mapping), file_type)){ + return mapping[file_type]; + } + return 'fa fa-file-o'; +} + +/** + * Play the noise 'ting' to notify user + */ +function beep(session){ + if (typeof(Audio) === "undefined") { + return; + } + var audio = new Audio(); + var ext = audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3"; + audio.src = session.url("/mail/static/src/audio/ting") + ext; + audio.play(); +} + return { parse_email: parse_email, - do_replace_expressions: do_replace_expressions, get_text2html: get_text2html, expand_domain: expand_domain, breakword: breakword, bindTooltipTo: bindTooltipTo, + shortcode_apply: shortcode_apply, + shortcode_substitution: shortcode_substitution, + attachment_filetype_to_fa_class: attachment_filetype_to_fa_class, + beep: beep, }; }); diff --git a/addons/mail/static/src/less/mail_chat_backend.less b/addons/mail/static/src/less/mail_chat_backend.less new file mode 100644 index 00000000000..80fe41c541c --- /dev/null +++ b/addons/mail/static/src/less/mail_chat_backend.less @@ -0,0 +1,212 @@ + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ +.o_mail_chat { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + height: 100%; + + @media (max-width: @screen-xs-max) { + position: static; + height: auto; + } + + .o-flex-display(); + .o-flex-flow(row, nowrap); + + .o_mail_chat_sidebar { + .o-flex(0, 0, 15%); + overflow: auto; + } + + .o_mail_chat_content { + .o-flex(0, 0, 85%); + height: 100%; + .o-flex-display(); + .o-flex-flow(column, nowrap); + @media (max-width: @screen-xs-max) { + height: auto; + } + + .o_mail_chat_messages { + .o-flex(1, 0, 0); + overflow: auto; + } + + .o_mail_chat_composer_wrapper { + .o-flex(0, 0, auto); + .o-flex-display(); + /* + > img { + .o-flex(0, 0, auto); + width: 64px; + height: 64px; + } + + > input { + .o-flex(1, 0, auto); + } + */ + } + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ +.o_mail_chat { + .o_mail_chat_sidebar { + background-color: @gray; + padding: 1%; + + > .nav-pills { + > li { + > a { + color: @odoo-brand-optional; // community compatibility: override base.css + &:hover, &:focus { + background-color: @gray-dark; + } + &.active { + background-color: @gray-dark; + } + } + + &.o_mail_chat_channel_item, &.o_mail_chat_partner_item { + > a { + color: @gray-lighter; + > .badge { + color: white; + background-color: @odoo-brand-optional; + } + + > i.fa { + padding-top: 3px; + padding-right: 3px; + + &.o_online { + color: @odoo-brand-optional; + } + } + } + } + } + } + + .o_mail_chat_channel_item.o_mail_chat_channel_unread, .o_mail_chat_partner_item.o_mail_chat_channel_unread{ + font-weight: bold; + } + + .o_mail_chat_sidebar_title { + text-transform: uppercase; + color: @gray-lighter; + margin-top: 25px; + .user-select(none); + cursor: default; + } + + .o_mail_chat_add_more_search{ + padding: @padding-base-vertical @padding-base-horizontal; + .o_mail_chat_add_more_text{ + color: @gray-lighter; + display: inline-block; + font-style: italic; + cursor: pointer; + font-size: @font-size-base; + } + .o_mail_chat_add_more_text:hover{ + text-decoration: underline; + color: white; + cursor: pointer; + } + } + + } + + .o_mail_chat_content { + background-color: white; + + .o_mail_chat_help_message{ + padding: 25px; + color: @gray-light; + } + + .o_mail_thread_date_separator{ + margin: auto; + width: 90%; + .o_mail_thread_date_wrapper { + position: relative; + height: 20px; + border-bottom: solid WhiteSmoke 2px; + margin-bottom: 15px; + .o_mail_thread_date_separator_title { + display: table; + position: relative; + top: 10px; + left: 0; + right: 0; + margin: 0 auto; + padding: 0 10px 0 10px; + font-weight: bold; + background: white; + } + } + } + + .o_mail_thread_message{ + //border-bottom: solid WhiteSmoke 1px; + } + /* + .o_mail_chat_messages { + .o_slack_message { + .o_slack_message_avatar { + > img { + + } + } + .o_slack_message_content { + .o_slack_message_author { + font-weight: bold; + .o_slack_message_time { + font-weight: normal; + color: @gray-light; + } + } + + } + } + } + */ + + .o_mail_chat_composer_wrapper { + border-top: 1px solid @gray-lighter; + background-color: @gray-lighter; + padding: 10px; + .o_mail_compose_message{ + margin-bottom: 0px; + } + /* + > img { + background-image: url(/web/static/src/img/placeholder.png); + } + > input { + padding-left: 15px; + resize: none; + border: 0; + outline: none; + } + */ + } + } +} + + +// ------------------------------------------------------------------ +// Other components +// ------------------------------------------------------------------ +.o_mail_chat_partner_invite{ + height: 50px; +} diff --git a/addons/mail/static/src/less/mail_chatter.less b/addons/mail/static/src/less/mail_chatter.less new file mode 100644 index 00000000000..1f7e9b29018 --- /dev/null +++ b/addons/mail/static/src/less/mail_chatter.less @@ -0,0 +1,54 @@ + +.o_mail_chatter{ + /* Layout for chatter header (buttons and compose message) */ + .o_mail_chatter_compose_buttons, .o_mail_chatter_compose_message_wrap{ + padding-left:15px; + margin-bottom:20px; + //background-color: lightGrey; + } + + /* Specific message style for chatter */ + .o_mail_chatter_messages{ + + .o_mail_thread_message{ + .o_mail_thread_message_type_comment{ // message comment + &:not(.o_mail_thread_message_subtype_note){ + background-color: white; + box-shadow: 0px 1px #CACACA; + } + } + .o_mail_thread_message_type_email{ // message email + background-color: white; + box-shadow: 0px 1px #CACACA; + } + .o_mail_thread_message_type_notification{ // message notification + + } + } + + .o_mail_thread_date_separator{ + .o_mail_thread_date_wrapper { + height: 20px; + .o_mail_thread_date_separator_title { + text-align: center; + font-weight: bold; + color: @gray-light; + } + } + } + } + .o_mail_chatter_show_more_message{ + text-align: center; + cursor: pointer; + } + +} + + +.o_mail_chatter_compose_message{ + .o_mail_chatter_compose_message_suggested_partners label{ + vertical-align: middle; + display: block; + line-height: 14px; + } +}
\ No newline at end of file diff --git a/addons/mail/static/src/less/mail_thread.less b/addons/mail/static/src/less/mail_thread.less new file mode 100644 index 00000000000..15cfc5cdf94 --- /dev/null +++ b/addons/mail/static/src/less/mail_thread.less @@ -0,0 +1,184 @@ +// ------------------------------------------------------------------ +// Mail Thread 'Mixin' +// ------------------------------------------------------------------ +.o_mail_thread{ + + /* Starred and Needaction on message */ + .o_mail_thread_message_star, .o_mail_thread_message_needaction { + opacity: 0.5; + color: black; + } + .o_mail_thread_message_star:hover, .o_mail_thread_message_needaction:hover{ + opacity: 1; + cursor: pointer; + } + .o_mail_thread_message_star.o_mail_message_starred{ + color: orange; + } + /* author avatar */ + .o_mail_thread_avatar{ + width: 4.5em; + max-width: 50px; + } + /* message layout */ + .o_mail_thread_message{ + margin-bottom: 10px; + } + .o_mail_thread_message_avatar{ + text-align: center; + } + .o_mail_thread_message_core{ + padding: 5px; + } + .o_mail_thread_message_info{ + margin-top: 0; + } + + /* mail redirect */ + .o_mail_redirect{ + cursor: pointer; + } +} + +// ------------------------------------------------------------------ +// Mail Compose Message +// ------------------------------------------------------------------ +.o_mail_compose_message{ + + // Emoji size in the popover + .o_mail_compose_message_emoji_img img{ + height: 26px; + } + + .o_mail_compose_message_mode_mail{ + textarea { + resize: vertical; + width: 100%; + } + } +} + +// ------------------------------------------------------------------ +// Attachment Icons (common for mail thread message and compose message) +// ------------------------------------------------------------------ + +@-webkit-keyframes oe_mail_attach_loading_anim { + 0% { background: #4BBD00 } + 50% { background: #009123 } + 100% { background: #4BBD00 } +} +@-moz-keyframes oe_mail_attach_loading_anim { + 0% { background: #4BBD00 } + 50% { background: #009123 } + 100% { background: #4BBD00 } +} +@-o-keyframes oe_mail_attach_loading_anim { + 0% { background: #4BBD00 } + 50% { background: #009123 } + 100% { background: #4BBD00 } +} +@keyframes oe_mail_attach_loading_anim { + 0% { background: #4BBD00 } + 50% { background: #009123 } + 100% { background: #4BBD00 } +} + +.o_mail_attachment{ + display: inline-block; + width: 100px; + margin: 4px 2px; + min-height: 80px; + position: relative; + border-radius: 3px; + text-align: center; + vertical-align: top; + + img { + position: absolute; + width: 48px; + height: 48px; + top: 0px; + left: 50%; + margin-left: -24px; + } + + .o_mail_attachment_name { + display: inline-block; + max-width: 100%; + padding: 1px 3px; + margin-top: 50px; + margin-bottom: 5px; + background: #F2F7FF; + overflow: hidden; + color: #4c4c4c; + text-shadow: none; + border-radius: 3px; + } + + &.o_mail_attachment_preview { + background: url( data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAJ0lEQVQYV2MsLS39z4AGLCws0IUYGIeCwrVr12J45sSJE5ieGQIKAbuZKf/EMCs7AAAAAElFTkSuQmCC ); + } + + &.o_mail_attachment_preview img { + display: block; + position: relative; + margin:0px; + width: 100px; + height: 80px; + border-radius: 1px; + border: solid 3px #FFF; + margin-left: -50px; + box-shadow: 0px 3px 10px rgba(0, 0, 0, 0.19); + } + + .o_mail_attachment_progress_bar { + display: none; + position: absolute; + top: 18px; + left: 16px; + right: 16px; + height: 17px; + line-height: 13px; + padding: 0px; + background: #4BBD00; + color: white; + text-align: center; + border-radius: 3px; + border: solid 1px rgba(0,0,0,0.2); + box-shadow: 0px 3px 10px rgba(0, 0, 0, 0.34); + -webkit-animation: oe_mail_attach_loading_anim 0.75s infinite linear; + -moz-animation: oe_mail_attach_loading_anim 0.75s infinite linear; + -o-animation: oe_mail_attach_loading_anim 0.75s infinite linear; + animation: oe_mail_attach_loading_anim 0.75s infinite linear; + } + + &.o_mail_uploading .o_mail_attachment_progress_bar { + display: block; + } + .o_mail_attachment_delete .fa { + display: none; + } + + .o_mail_attachment_delete .fa { + display: block; + position: absolute; + top: -7px; + right: 0px; + color: black; + text-shadow: 1px 0px white, -1px 0px white, 0px 1px white, 0px -1px white; + cursor: pointer; + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } + + &:hover .o_mail_attachment_delete .fa { + opacity: 1; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +} diff --git a/addons/mail/static/src/xml/mail_chat_backend.xml b/addons/mail/static/src/xml/mail_chat_backend.xml new file mode 100644 index 00000000000..9dc42749767 --- /dev/null +++ b/addons/mail/static/src/xml/mail_chat_backend.xml @@ -0,0 +1,155 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- Notification Counter of the Systray Menu --> + <t t-name="mail.chat.NotificationTopButton"> + <li t-att-title='_t("Notifications")'> + <a href="#"> + <i class="fa fa-comment"><t t-esc="widget.get('counter') || ''"/></i> + </a> + </li> + </t> + + <!-- Add More / Search Box --> + <t t-name="mail.chat.AbstractAddMoreSearch"> + <div class="o_mail_chat_add_more_search"> + <a href="#" class="o_mail_chat_add_more_text"><t t-esc="widget.label"/></a> + <div class="o_mail_chat_add_more_search_bar" style="display: none;"> + <input type="text" class="o_mail_chat_search_input"/> + </div> + </div> + </t> + + <!-- PartnerInviteDialog : content of the dialog --> + <t t-name="mail.chat.PartnerInviteDialog"> + <div class="o_mail_chat_partner_invite"> + <input type="text" class="o_mail_chat_partner_invite_input" id="mail_search_partners"/> + </div> + </t> + + + <!-- + ChatMailThread + Layout of the client action + param: widget : the widget (ChatMailThread) + --> + <t t-name="mail.chat.ChatMailThread"> + <div class="o_mail_chat o_mail_thread"> + <!-- Sidebar --> + <div class="o_mail_chat_sidebar"> + <!-- Static channel list --> + <ul class="nav nav-pills nav-stacked"> + <li class="o_mail_chat_channel_item" data-channel-id="channel_inbox"> + <a href="#">@ Inbox + <span class="badge pull-right o_mail_chat_needaction" data-channel-id="channel_inbox" t-att-style="((widget.get('needaction_inbox_counter') > 0) ? 'display:block;' : 'display:none;')"> + <t t-esc="widget.get('needaction_inbox_counter')"/> + </span> + </a> + </li> + <li class="o_mail_chat_channel_item" data-channel-id="channel_starred"> + <a href="#"><i class="fa fa-star-o"> Starred</i></a> + </li> + </ul> + <!-- Channel list --> + <h4 class="o_mail_chat_sidebar_title"><t t-esc="_t('Channels')"/></h4> + <t t-call="mail.chat.ChatMailThread.channels"> + <t t-set="channel_slot" t-value="'channel_channel'"/> + </t> + <!-- Direct Message list (res.users)--> + <h4 class="o_mail_chat_sidebar_title"><t t-esc="_t('Direct Message')"/></h4> + <t t-call="mail.chat.ChatMailThread.partners"/> + <!-- Private Group list --> + <h4 class="o_mail_chat_sidebar_title"><t t-esc="_t('Private Group')"/></h4> + <t t-call="mail.chat.ChatMailThread.channels"> + <t t-set="channel_slot" t-value="'channel_private_group'"/> + </t> + </div> + <!-- Content --> + <div class="o_mail_chat_content"> + <div class="o_mail_chat_messages"> + <div class="container-fluid o_mail_chat_messages_content"> + <t t-call="mail.chat.ChatMailThread.content"/> + </div> + </div> + <div class="o_mail_chat_composer_wrapper"> + + </div> + </div> + </div> + </t> + + <!-- + List of channel in the sidebar + param: channel_slot : the name of the channel slot + param: widget : the widget (ChatMailThread) + --> + <t t-name="mail.chat.ChatMailThread.channels"> + <ul t-att-class="'nav nav-pills nav-stacked o_mail_chat_channel_slot_' + channel_slot" data-toggle="pill"> + <t t-foreach="widget.get(channel_slot)" t-as="channel"> + <li t-att-class="'o_mail_chat_channel_item ' + (channel.message_needaction_counter > 0 ? 'o_mail_chat_channel_unread' : ' ')" t-att-data-channel-id="channel.id" role="presentation"> + <a t-att-href="_.str.sprintf('#active_id=%s&action=%s', channel.id, widget.action.id)"> + <t t-esc="channel.name"/> + <span class="badge pull-right o_mail_chat_needaction" t-att-data-channel-id="channel.id" t-att-style="((channel.message_needaction_counter > 0) ? 'display:block;' : 'display:none;')"> + <t t-esc="channel.message_needaction_counter"/> + </span> + </a> + </li> + </t> + </ul> + </t> + + <!-- + List of Partners of Direct Message channel + param: widget : the widget (ChatMailThread) + --> + <t t-name="mail.chat.ChatMailThread.partners"> + <ul class="nav nav-pills nav-stacked o_mail_chat_channel_slot_partners"> + <t t-foreach="widget.get('partners')" t-as="partner"> + <li class="o_mail_chat_partner_item" t-att-data-partner-id="partner.id" role="presentation"> + <a href="#"> + <i t-att-class="'fa ' + (partner.im_status == 'online' ? 'fa-circle' : 'fa-circle-o')"/> + <t t-esc="partner.name"/> + <span class="fa fa-times pull-right o_mail_chat_partner_unpin" t-att-title="_t('Untoggle')" t-att-data-partner-id="partner.id"/> + </a> + </li> + </t> + </ul> + </t> + + <!-- + Channel content (messages) or 'Help Message' of the client action + param: widget : the widget (ChatMailThread) + --> + <t t-name="mail.chat.ChatMailThread.content"> + <t t-if="widget.get('messages').length"> + <t t-call="mail.Thread.messages"/> + </t> + <t t-if="!widget.get('messages').length"> + <div class="o_mail_chat_help_message"> + <t t-raw="widget.help_message"/> + </div> + </t> + </t> + + <!-- + Buttons of the Control Panel + --> + <t t-name="mail.chat.ControlButtons"> + <div class="o_mail_chat_cp_buttons"> + <button type="button" class="btn btn-primary btn-sm o_mail_chat_button_invite" t-att-title='_t("Invite people")'>Invite</button> + <button type="button" class="btn btn-default btn-sm o_mail_chat_button_minimize" t-att-title='_t("Minimize the conversation")'>Minimize</button> + <div class="btn-group"> + <button type="button" class="btn btn-default btn-sm dropdown-toggle o_mail_chat_button_more" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> + More <span class="caret"></span> + </button> + <ul class="dropdown-menu"> + <li class="o_mail_chat_button_unsubscribe"><a href="#">Unsubscribe</a></li> + <li class="o_mail_chat_button_settings"><a href="#">Settings</a></li> + </ul> + </div> + + </div> + </t> + + +</templates> diff --git a/addons/mail/static/src/xml/mail_chat_common.xml b/addons/mail/static/src/xml/mail_chat_common.xml index eb479863b3e..745893c11e1 100644 --- a/addons/mail/static/src/xml/mail_chat_common.xml +++ b/addons/mail/static/src/xml/mail_chat_common.xml @@ -1,103 +1,92 @@ <?xml version="1.0" encoding="UTF-8"?> <templates xml:space="preserve"> -<t t-name="im_chat.Conversation"> - <div class="oe_im_chatview_header"> - <span class="header_name"></span> - <span class="nbr_messages"/> - <span class="right_buttons"> - <!-- options --> - <div t-if="widget.actions.length !== 0" class="btn-group button_option_group"> - <span class="dropdown-toggle" data-toggle="dropdown"> - <i class="fa fa-cogs"></i> + + <t t-name="mail.chat.im.Conversation"> + <div class="o_mail_chat_im_window openerp"> + <div class="o_mail_chat_im_window_header"> + <span class="o_mail_chat_im_window_header_name"> + <t t-raw="widget.get('session').name"/> + </span> + <span class="nbr_messages o_mail_chat_im_window_header_counter"/> + <span class="o_mail_chat_im_window_header_buttons"> + <div class="o_mail_chat_im_window_close">×</div> </span> - <ul class="dropdown-menu" role="menu"> - <t t-foreach="widget.actions" t-as="action"> - <li t-att-class="action.class"> - <i t-att-class="action.icon_class"></i> <t t-esc="action.label"/> - </li> - </t> - </ul> </div> - <div class="oe_im_chatview_close">×</div> - </span> - </div> - <div class="oe_im_chatview_content"> - <div class="oe_im_chatview_status"/> - <div class="oe_im_chatview_content_bubbles"></div> - </div> - <div class="oe_im_chatview_footer"> - <input class="oe_im_chatview_input" t-att-placeholder="widget.inputPlaceholder" /> - </div> -</t> -<t t-name="im_chat.Conversation_content"> - <t t-foreach="_.keys(list)" t-as="date"> - <div class="oe_im_chatview_date_separator"> - <t t-esc="window.moment(date).format('LL')"/> + <div class="o_mail_chat_im_window_content"> + <t t-call="mail.chat.im.Conversation.messages"/> + </div> + <div class="oe_im_chatview_footer o_mail_chat_im_window_footer"> + <input class="o_mail_chat_im_window_input" t-att-placeholder="widget.options.placeholder"/> + </div> </div> - <t t-foreach="list[date]" t-as="bubble"> - <t t-if="bubble[0].type === 'message'"> - <t t-call="im_chat.Conversation_message_bubble"> - <t t-set="messages" t-value="bubble"/> - </t> - </t> - <t t-if="bubble[0].type === 'meta'"> - <t t-call="im_chat.Conversation_technical_bubble"> - <t t-set="messages" t-value="bubble"/> - </t> - </t> - </t> </t> -</t> -<t t-name="im_chat.Conversation_message_bubble"> - <div class="oe_im_chatview_message_bubble"> - <div class="oe_im_chatview_clip"> - <img class="oe_im_chatview_avatar" t-att-src="_.last(messages).from_id[2]"/> - </div> - <div class="oe_im_chatview_from"><t t-esc="_.last(messages).from_id[1]"/></div> - <div class="oe_im_chatview_bubble_list"> - <t t-foreach="messages" t-as="m"> - <div class="oe_im_chatview_bubble_item"><t t-raw="m.message"/></div> + + <t t-name="mail.chat.im.Conversation.messages"> + <div class="o_mail_chat_im_messages"> + <t t-set="current_day" t-value="0"/> + <t t-foreach="widget.get('messages')" t-as="message"> + <div t-if="current_day != message.date.split(' ')[0]" class="row o_mail_chat_im_window_date_sperator"> + <t t-raw="moment(message.date, 'YYYY-MM-DD hh:mm:ss').format('LL')"/> + <t t-set="current_day" t-value="message.date.split(' ')[0]"/> + </div> + <div class="o_mail_chat_im_message"> + <div class="o_mail_chat_im_message_avatar"> + <t t-if="message.author_avatar"> + <img t-att-src="'data:image/png;base64,' + message.author_avatar" alt="avatar" class="img-responsive img-rounded"/> + </t> + <t t-if="!message.author_avatar"> + <img src="/mail/static/src/img/smiley/avatar.jpg" alt="avatar" class="img-responsive img-rounded"/> + </t> + </div> + <div class="o_mail_chat_im_message_core"> + <!-- author --> + <span class="text-muted"> + <t t-if="message.author_id"> + <strong><t t-esc="message.author_id[1]"/></strong> - + </t> + <t t-if="!message.author_id"> + <strong><t t-esc="message.email_from"/></strong> - + </t> + <span class="small"><t t-esc="moment(message.date, 'YYYY-MM-DD hh:mm:ss').format('hh:mm')"/></span> + </span> + <!-- body --> + <p class="o_mail_chat_im_message_body"> + <t t-if="message.body"> + <t t-raw="message.body"/> <!-- TODO JEM : tracking message and attachments ??!! --> + </t> + <t t-if="!message.body"> + <t t-call="mail.chat.im.Conversation.tracking_message"/> + </t> + </p> + <!-- Attachments --> + <div t-attf-class="o_mail_chat_im_message_attachment"> + <t t-foreach="message.attachment_ids" t-as="attachment"> + <a t-att-href='attachment.url' target="_blank"> + <i t-att-class="attachment.fa_class" t-att-title="attachment.name || attachment.filename"/> + </a> + </t> + </div> + </div> + </div> </t> </div> - <div class="oe_im_chatview_time"><t t-esc="window.moment((_.last(messages).create_date)).format('LT')"/></div> - </div> -</t> -<t t-name="im_chat.Conversation_technical_bubble"> - <div class="oe_im_chatview_technical_bubble"> - <div class="oe_im_chatview_from"><t t-esc="_.last(messages).from_id[1]"/></div> + </t> + + <t t-name="mail.chat.im.Conversation.tracking_message"> <div> - <t t-foreach="messages" t-as="m"> - <div><t t-raw="m.message"/></div> - </t> + <ul class="o_mail_message_tracking"> + <t t-foreach='message.tracking_value_ids' t-as='value'> + <li> + <t t-esc="value.changed_field"/> : + <t t-if="value.old_value"> + <span class="o_mail_message_tracking_value"> <t t-esc="value.old_value"/> </span> + <span> <i class="fa fa-long-arrow-right"></i> </span> + </t> + <span class="o_mail_message_tracking_value"> <t t-esc="value.new_value"/> </span> + </li> + </t> + </ul> </div> - <div class="oe_im_chatview_time"><t t-esc="window.moment((_.last(messages).create_date)).format('LT')"/></div> - </div> -</t> + </t> -<t t-name="im_chat.UserWidget"> - <div class="oe_im_user ui-draggable"> - <span class="oe_im_user_clip"> - <img t-att-src="widget.get('image_url')" class="oe_im_user_avatar"/> - </span> - <span class="oe_im_user_name"><t t-esc="widget.get('name')"/></span> - <img t-att-src="'/im_chat/static/src/img/green.png'" t-att-data-im-user-id="widget.get('id')" class="oe_im_user_online"/> - </div> -</t> -<t t-name="im_chat.InstantMessaging"> - <div class="oe_im"> - <div class="oe_im_frame_header"> - <i class="fa fa-search oe_im_search_icon"/> - <input class="oe_im_searchbox" placeholder="Search users..."/> - </div> - <div class="oe_im_users"></div> - <div class="oe_im_content"></div> - </div> -</t> -<t t-name="im_chat.ImTopButton"> - <li t-att-title='_t("Display Instant Messaging")'> - <a href="#"> - <i id="oe_topbar_imbutton_icon" class="fa fa-comments"/> - </a> - </li> -</t> -</templates> +</templates>
\ No newline at end of file diff --git a/addons/mail/static/src/xml/mail_chatter.xml b/addons/mail/static/src/xml/mail_chatter.xml new file mode 100644 index 00000000000..2d2c150aaba --- /dev/null +++ b/addons/mail/static/src/xml/mail_chatter.xml @@ -0,0 +1,123 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates> + + <t t-name="mail.chatter.ComposeMessage"> + <div class="o_mail_chatter_compose_message"> + <!-- Log a Note (subtypes)--> + <t t-if="widget.options.is_log"> + <div class="form-group col-md-8"> + <span class="text-muted"> + Log an internal note which will not be sent to followers.<br/> + Note that users reading the document can read internal notes. + </span> + </div> + <!-- Internal subtypes --> + <div t-att-class="'form-group col-md-8 ' + (widget.options.internal_subtypes.length <= 1 ? ' hide' : '')"> + <label class="col-sm-2 control-label">Activity</label> + <div class="col-sm-4"> + <select class="form-control o_mail_chatter_compose_message_subtype_select"> + <t t-foreach="widget.options.internal_subtypes" t-as="subtype"> + <option t-att-value="subtype['id']"><t t-raw="subtype['name']"/></option> + </t> + </select> + </div> + </div> + </t> + <!-- New Message (list of followers)--> + <t t-if="!widget.options.is_log"> + <!-- Text for followers --> + <div class="form-group col-md-8 o_mail_chatter_compose_message_all_followers"> + <span class="text-muted"> + To: + <t t-if="widget.options.record_name"> + Followers of + <t t-raw="'"' + widget.options.record_name + '"'"/> + </t> + <t t-if="!widget.options.record_name"> + Followers of this document + </t> + </span> + <!-- List of followers --> + <div class="o_mail_chatter_compose_message_suggested_partners"> + <t t-foreach='widget.suggested_partners' t-as='recipient'> + <div class="checkbox"> + <label t-attf-title="Add as recipient and follower (reason: #{recipient.reason})"> + <input type="checkbox" + t-att-checked="recipient.checked ? 'checked' : undefined" + t-att-data-fullname="recipient.full_name"/> + <t t-esc="recipient.name"/> + <t t-if="recipient.email_address">(<t t-esc="recipient.email_address"/>)</t> + <t t-if="!recipient.email_address">(no email address)</t> + </label> + </div> + </t> + </div> + </div> + </t> + <!-- Composer Message --> + <t t-call="mail.ComposeMessage"/> + </div> + </t> + + + <t t-name="mail.chatter.ChatterMailThread"> + <div class="container-fluid o_mail_chatter o_mail_thread"> + <!-- Compose buttons ('new message' and 'log a note') --> + <div class="row o_mail_chatter_compose_buttons"> + <div class="col-md-7"> + <div class="btn-group btn-group-sm"> + <button type="button" class="btn btn-primary o_mail_chatter_button_new_message" title="Send a message"> + New message + </button> + </div> + <t t-if="widget.options.display_log_button"> + <span class="text-muted">or</span> + <div class="btn-group btn-group-sm"> + <button class="btn btn-link btn-small o_mail_chatter_button_log_note" title="Log a note. Followers will not be notified."> + Log an internal note + </button> + </div> + </t> + </div> + </div> + <!-- wrap div for ChatterMailComposeMessage --> + <div class="row o_mail_chatter_compose_message_wrap" style="display:none;"> + + </div> + + <!-- Message list --> + <div class="row"> + <div class="col-md-10 col-md-offset-1 o_mail_chatter_messages"> + <t t-call="mail.chatter.ChatterMailThread.messages"/> + </div> + </div> + + <div t-if="widget.options.display_show_more_message" class="o_mail_chatter_show_more_message text-muted"> + ----------- Show more message ----------- + </div> + </div> + </t> + + <t t-name="mail.chatter.ChatterMailThread.messages"> + <t t-if="widget.get('messages').length"> + <t t-call="mail.Thread.messages"/> + </t> + <t t-if="!widget.get('messages').length"> + No Messages. + </t> + </t> + + <!-- + ComposeMessage.ButtonTopBar + render of the button on the user bar for open wizard compose message + --> + <t t-name="mail.ComposeMessageTopButton"> + <li title='Compose new Message' class="oe_topbar_item oe_topbar_compose_full_email"> + <a href="#"> + <i class="fa fa-envelope-o fa-fw"></i> + </a> + </li> + </t> + + +</templates> diff --git a/addons/mail/static/src/xml/mail_followers.xml b/addons/mail/static/src/xml/mail_followers.xml index dd6d9883abd..b7548f861fd 100644 --- a/addons/mail/static/src/xml/mail_followers.xml +++ b/addons/mail/static/src/xml/mail_followers.xml @@ -2,76 +2,76 @@ <templates> <!-- - followers main template + Mail Follower (root template) Template used to display the followers, the actions and the subtypes in a record. - --> -<div t-name="mail_followers" class="o_timeline_followers"> - <div t-attf-class="btn-group-sm o_timeline_follower_title_box"> - <button t-attf-class="btn btn-default dropdown-toggle o_timeline_partner_list" - data-toggle="dropdown" aria-expanded="false"> - <span class="o_timeline_follower_title">Followers</span> - <span class="caret"></span> - </button> - <ul class="dropdown-menu o_timeline_follower_list" role="menu"></ul> - </div> - <div class="oe_actions"> - <div t-attf-class="btn-group-sm o_timeline_subtype_list"> - <button class="btn btn-default o_timeline_follower o_timeline_notfollow"> - <span class="o_timeline_follow">Follow</span> - <span class="fa fa-times o_timeline_unfollow" aria-hidden="true"> Unfollow</span> - <span class="fa fa-check o_timeline_following" aria-hidden="true"> Following</span> - </button> - <button t-attf-class="btn btn-default dropdown-toggle o_timeline_sbtp_list" +--> +<t t-name="mail.Followers"> + <div class="o_timeline_followers"> + <div t-attf-class="btn-group-sm o_timeline_follower_title_box"> + <button t-attf-class="btn btn-default dropdown-toggle o_timeline_partner_list" data-toggle="dropdown" aria-expanded="false"> + <span class="o_timeline_follower_title">Followers</span> <span class="caret"></span> </button> - <ul class="dropdown-menu oe_subtype" role="menu"></ul> + <ul class="dropdown-menu o_timeline_follower_list" role="menu"></ul> + </div> + <div class="oe_actions"> + <div t-attf-class="btn-group-sm o_timeline_subtype_list"> + <button class="btn btn-default o_timeline_follower o_timeline_notfollow"> + <span class="o_timeline_follow">Follow</span> + <span class="fa fa-times o_timeline_unfollow" aria-hidden="true"> Unfollow</span> + <span class="fa fa-check o_timeline_following" aria-hidden="true"> Following</span> + </button> + <button t-attf-class="btn btn-default dropdown-toggle o_timeline_sbtp_list" + data-toggle="dropdown" aria-expanded="false"> + <span class="caret"></span> + </button> + <ul class="dropdown-menu oe_subtype" role="menu"></ul> + </div> </div> - - <!--<t t-if="widget.comment"> - <h5 class="oe_comment"><t t-raw="widget.comment"/></h5> - </t>--> </div> -</div> +</t> <!-- - followers.partner template + Mail Followers Partner template Template used to display a partner following the record - --> -<li t-name="mail_followers_partner" class='oe_partner'> - <span> <img class="oe_mail_thumbnail oe_mail_frame" t-att-src="record.avatar_url"/> </span> - <span class="oe_partner_name"> - <a t-attf-href="#model=res.partner&id=#{record.id}" - t-att-title="record.name" - t-att-data-res-model="record.res_model" - t-att-data-res-id="record.res_id" - t-att-data-id="record.id"> - <t t-esc="record.name"/></a> - </span> - <i t-if="record.is_editable and (widget.records_length > 1)" - class="oe_edit_subtype fa fa-pencil oe_hidden" title="Edit subscription" - t-att-data-id="record.id"/> - <i t-if="widget.view_is_editable" class="oe_remove_follower fa fa-remove" - title="Remove this follower"/> -</li> +--> +<t t-name="mail.Followers.partner"> + <li class='oe_partner'> + <span> <img class="oe_mail_thumbnail oe_mail_frame" t-att-src="record.avatar_url"/> </span> + <span class="oe_partner_name"> + <a t-attf-href="#model=res.partner&id=#{record.id}" + t-att-title="record.name" + t-att-data-res-model="record.res_model" + t-att-data-res-id="record.res_id" + t-att-data-id="record.id"> + <t t-esc="record.name"/></a> + </span> + <i t-if="record.is_editable and (widget.records_length > 1)" + class="oe_edit_subtype fa fa-pencil oe_hidden" title="Edit subscription" + t-att-data-id="record.id"/> + <i t-if="widget.view_is_editable" class="oe_remove_follower fa fa-remove" + title="Remove this follower"/> + </li> +</t> -<t t-name="mail_followers_add_more"> - <li t-if="widget.view_is_editable" class="oe_invite"> +<t t-name="mail.Followers.add_more"> + <li t-if="widget.view_is_editable" class="oe_invite"> <a href="#"> Add followers </a> </li> <li t-if="widget.view_is_editable and (widget.followers.length > 0)" class="divider"></li> </t> <!-- - followers.subtype template + Mail Followers Subtype template Template used to display message subtypes of a follower subscription --> -<t t-name="mail_followers_subtype"> +<t t-name="mail.Followers.subtype"> <li class="oe_subtype"> - <input type="checkbox" t-att-checked="record.followed" + <input type="checkbox" t-att-checked="record.followed" t-att-id="'input_mail_followers_subtype_'+record.id+(dialog ? '_in_dialog': '')" t-att-data-id="record.id" t-att-name="record.name" class="o_timeline_msg_subtype_check"/> - + <label t-att-for="'input_mail_followers_subtype_'+record.id+(dialog ? '_in_dialog': '')"> <t t-raw="record.name"/> </label> diff --git a/addons/mail/static/src/xml/mail_thread.xml b/addons/mail/static/src/xml/mail_thread.xml new file mode 100644 index 00000000000..10baacc0577 --- /dev/null +++ b/addons/mail/static/src/xml/mail_thread.xml @@ -0,0 +1,186 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates> + + <!-- + Mail Composer Message : input with 2 buttons (emoji and attachment) + --> + <t t-name="mail.ComposeMessage"> + <div class="form-group col-md-12 o_mail_compose_message"> + <!-- input text : depending on the display_mode option --> + <div t-att-class="widget.options.display_mode === 'chat' ? 'input-group o_mail_compose_message_mode_chat' : 'o_mail_compose_message_mode_mail'"> + <t t-if="widget.options.display_mode === 'chat'"> + <input type="text" class="form-control o_mail_compose_message_input" placeholder="Write something" name="message" id="send_message"/> + </t> + <t t-if="widget.options.display_mode === 'mail'"> + <textarea class="form-control o_mail_compose_message_input" placeholder="Write something" name="message" id="send_message"/> + </t> + <span class="input-group-btn o_mail_compose_message_button_group"> + <button t-if="widget.options.display_mode === 'mail'" class="btn btn-primary o_mail_compose_message_button_send" type="button"> + Send + </button> + <button class="btn btn-default o_mail_compose_message_button_emoji" type="button" data-toggle="popover"> + <i class="fa fa-smile-o"/> + </button> + <button class="btn btn-default o_mail_compose_message_button_attachment" type="button" data-toggle="popover"> + <i class="fa fa-paperclip"/> + </button> + </span> + </div> + + <span class="o_mail_attachment_file hide"> + <t t-call="HiddenInputFile"> + <t t-set="fileupload_id" t-value="widget.fileupload_id"/> + <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t> + <input type="hidden" name="model" value="mail.compose.message"/> + <input type="hidden" name="id" value="0"/> + <input type="hidden" name="session_id" t-att-value="widget.session.session_id"/> + </t> + </span> + <span class="o_mail_compose_message_emoji"></span> + + <div class="o_mail_compose_message_attachment_list"></div> + </div> + </t> + + <t t-name="mail.ComposeMessage.emoji"> + <div class="o_mail_compose_message_emoji_content"> + <t t-foreach="widget.emoji_list" t-as="emoji"> + <span t-att-data-emoji="emoji.source" class="o_mail_compose_message_emoji_img" t-att-title="emoji.description"> + <t t-raw="emoji.substitution"/> + </span> + </t> + </div> + </t> + + <t t-name="mail.ComposeMessage.attachments"> + <div class="o_mail_chat_im_attachment_files"> + <t t-foreach="widget.get('attachment_ids')" t-as='attachment'> + <t t-if="attachment.file_type_icon !== 'webimage'"> + <div t-attf-class="o_mail_attachment #{attachment.upload ? 'o_mail_uploading' : ''}"> + <a t-att-href='attachment.url' target="_blank"> + <img t-att-src="'/mail/static/src/img/mimetypes/' + (attachment.file_type_icon || 'unknown') + '.png'"></img> + <div class='o_mail_attachment_name'><t t-raw="attachment.name"/></div> + </a> + <div class="o_mail_attachment_delete"> + <i class='fa fa-times-circle' title="Delete this attachment" t-att-data-id="attachment.id"/> + </div> + <div class="o_mail_attachment_progress_bar"> + Uploading + </div> + </div> + </t> + <t t-if="attachment.file_type_icon === 'webimage'"> + <div t-attf-class="o_mail_attachment o_mail_attachment_preview #{attachment.upload ? 'o_mail_uploading' : ''}"> + <a t-att-href='attachment.url' target="_blank"> + <img t-att-src="_.str.sprintf('/web/binary/image?model=ir.attachment&field=datas&id=%s', attachment.id)"/> + <div class='o_mail_attachment_name'><t t-raw='attachment.name'/></div> + </a> + <div class="o_mail_attachment_delete"> + <i class='fa fa-times-circle' title="Delete this attachment" t-att-data-id="attachment.id"/> + </div> + <div class="o_mail_attachment_progress_bar"> + Uploading + </div> + </div> + </t> + </t> + </div> + </t> + + + <!-- + Message Thread + --> + <t t-name="mail.Thread.messages"> + <t t-set="current_day" t-value="0"/> + <t t-foreach="widget.get('messages')" t-as="message"> + <div t-if="current_day != message.date.split(' ')[0]" class="row o_mail_thread_date_separator"> + <div class="o_mail_thread_date_wrapper"> + <div class="o_mail_thread_date_separator_title"> + <t t-raw="moment(message.date, 'YYYY-MM-DD hh:mm:ss').format('LL')"/> + </div> + </div> + <t t-set="current_day" t-value="message.date.split(' ')[0]"/> + </div> + <div class="row o_mail_thread_message" t-att-data-message-id="message.id"> + <div class="col-md-1 col-sm-1 o_mail_thread_message_avatar"> + <t t-if="message.author_avatar"> + <img t-att-src="'data:image/png;base64,' + message.author_avatar" alt="avatar" class="o_mail_thread_avatar img-rounded"/> + </t> + <t t-if="!message.author_avatar"> + <img t-if="message.message_type == 'email' && (!message.author_id || !message.author_id[0]) && message.email_from" src="/mail/static/src/img/email_icon.png" alt="avatar" class="o_mail_thread_avatar img-rounded"/> + <img t-if="message.message_type == 'email' && (message.author_id && message.author_id[0]) && !message.email_from" t-att-src="'/web/binary/image?model=res.partner&field=image_small&id=' + message.author_id[0]" class="o_mail_thread_avatar img-rounded"/> + <img t-if="message.message_type !== 'email' && (message.author_id && message.author_id[0])" t-att-src="'/web/binary/image?model=res.partner&field=image_small&id=' + message.author_id[0]" class="o_mail_thread_avatar img-rounded"/> + <img t-if="message.message_type !== 'email' && (!message.author_id || !message.author_id[0])" src="/mail/static/src/img/smiley/avatar.jpg" class="o_mail_thread_avatar img-rounded"/> + </t> + </div> + <div t-att-class="'col-md-10 col-sm-10 o_mail_thread_message_core ' + 'o_mail_thread_message_type_' + message.message_type + ' ' + (message.is_note ? 'o_mail_thread_message_subtype_note' : '')"> + <!-- actions --> + <span class="pull-right o_mail_thread_message_actions"> + <i t-if="message.is_needaction && widget.options.display_needaction_button" class="fa fa-check o_mail_thread_message_needaction" t-att-data-message-id="message.id" t-att-title="_t('Mark as Done')"/> + <i t-att-class="'fa fa-star-o fa-lg o_mail_thread_message_star ' + (message.is_starred ? 'o_mail_message_starred' : '')" t-att-data-message-id="message.id" t-att-title="_t('Mark as Todo')"/> + </span> + + <p class="text-muted o_mail_thread_message_info"> + <t t-if="message.is_note"> + Note by + </t> + <!-- author : partner or email_from --> + <t t-if="message.author_id"> + <strong class="o_mail_redirect" data-oe-model="res.partner" t-att-data-oe-id="message.author_id[0]"><t t-esc="message.author_id[1]"/></strong> - + </t> + <t t-if="!message.author_id"> + <strong><t t-esc="message.email_from"/></strong> - + </t> + <small t-att-title="moment(message.date, 'YYYY-MM-DD hh:mm:ss').format('LLL')"> + <t t-esc="moment(message.date, 'YYYY-MM-DD hh:mm:ss').format('hh:mm')"/> + </small> + <t t-if="message.model && (message.model != 'mail.channel') && widget.options.display_document_link"> + on <a class="o_mail_redirect" t-att-data-oe-model="message.model" t-att-data-oe-id="message.res_id" href="#"><t t-esc="message.record_name"/></a> + </t> + </p> + <!-- tracking message--> + <t t-if="message.tracking_value_ids && message.tracking_value_ids.length > 0"> + <t t-call="mail.Thread.tracking_message"> + <t t-set="message" t-value="message"/> + </t> + </t> + <!-- normal message --> + <t t-if="!(message.tracking_value_ids && message.tracking_value_ids.length > 0)"> + <t t-raw="message.body"/> + </t> + <!-- Attachments --> + <t t-foreach="message.attachment_ids" t-as="attachment"> + <div t-attf-class="o_mail_attachment"> + <a t-att-href='attachment.url' target="_blank"> + <img t-att-src="'/mail/static/src/img/mimetypes/' + (attachment.file_type_icon || 'unknown') + '.png'"></img> + <div class='o_mail_attachment_name'><t t-raw='attachment.name'/></div> + </a> + </div> + </t> + </div> + + <div class="col-md-1"> + </div> + </div> + </t> + </t> + + <t t-name="mail.Thread.tracking_message"> + <div> + <ul class="o_mail_thread_message_tracking"> + <t t-foreach='message.tracking_value_ids' t-as='value'> + <li> + <t t-esc="value.changed_field"/> : + <t t-if="value.old_value"> + <span class="o_mail_message_tracking_value"> <t t-esc="value.old_value"/> </span> + <span> <i class="fa fa-long-arrow-right"></i> </span> + </t> + <span class="o_mail_message_tracking_value"> <t t-esc="value.new_value"/> </span> + </li> + </t> + </ul> + </div> + </t> + +</templates> diff --git a/addons/mail/views/mail_channel_views.xml b/addons/mail/views/mail_channel_views.xml index 9512e7aaf1a..b9b75112782 100644 --- a/addons/mail/views/mail_channel_views.xml +++ b/addons/mail/views/mail_channel_views.xml @@ -31,7 +31,7 @@ </form> </field> </record> - + <record id="mail_channel_partner_action" model="ir.actions.act_window"> <field name="name">Channels/Partner</field> <field name="res_model">mail.channel.partner</field> @@ -97,53 +97,52 @@ <field name="model">mail.channel</field> <field name="priority" eval="10"/> <field name="arch" type="xml"> - <form string="Group Form"> - <sheet class="openerp_mail_channel_sheet"> - <field name="image" widget='image' class="oe_avatar" options='{"preview_image": "image_small"}'/> + <form string="Mail Channel Form"> + <sheet> + <div class="oe_button_box" name="button_box"/> + <field name="image" widget="image" class="oe_avatar" options="{'preview_image': 'image_medium', 'size': [90, 90]}"/> <div class="oe_title"> <div class="oe_edit_only"> <label for="name"/> </div> - <h1><field name="name" readonly="0"/></h1> - <group colspan="2" name="group_alias" - attrs="{'invisible': [('alias_domain', '=', False)]}"> - <label for="alias_id" string=" " class="fa fa-envelope-o" style="min-width: 20px;"/> - <div name="alias_def"> - <field name="alias_id" class="oe_read_only oe_inline" - string="Email Alias" required="0"/> - <div class="oe_edit_only oe_inline" name="edit_alias" style="display: inline;" > - <field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/> - </div> - </div> - <label for="alias_contact" string=" " class="fa fa-lock" style="min-width: 20px;"/> - <field name="alias_contact" class="oe_inline" nolabel="1"/> - </group> + <h1> + <field name="name" default_focus="1" placeholder="Name" readonly="0"/> + </h1> </div> <field name="description" placeholder="Topics discussed in this group..."/> - <div class="oe_clear"/> - <group class="oe_edit_only"> - <field name="public" widget="radio" string="Who can follow the group's activities?"/> - <field name="group_public_id" - attrs="{'invisible': [('public','<>','groups')], 'required': [('public','=','groups')]}" - /> - <field name="group_ids" widget="many2many_tags"/> - </group> - <group attrs="{'invisible': [('public', 'not in', ['public', 'private'])]}"> - <div class="oe_grey" attrs="{'invisible': [('public','<>','public')]}"> - This group is visible by everyone, - including your customers if you installed - the portal module. - </div> - <div class="oe_grey" attrs="{'invisible': [('public','<>','private')]}"> - Only the invited followers can read the - discussions on this group. + <group name="group_alias" attrs="{'invisible': [('alias_domain', '=', False)]}"> + <label for="alias_id" string=" " class="fa fa-envelope-o" style="min-width: 20px;"/> + <div name="alias_def"> + <field name="alias_id" class="oe_read_only oe_inline" + string="Email Alias" required="0"/> + <div class="oe_edit_only oe_inline" name="edit_alias" style="display: inline;" > + <field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/> + </div> </div> + <label for="alias_contact" string=" " class="fa fa-lock" style="min-width: 20px;"/> + <field name="alias_contact" class="oe_inline" nolabel="1"/> </group> + <notebook> + <page string="Privacy"> + <group> + <field name="public" widget="radio" string="Who can follow the group's activities?"/> + <field name="group_public_id" + attrs="{'invisible': [('public','<>','groups')], 'required': [('public','=','groups')]}" + /> + <field name="group_ids" widget="many2many_tags"/> + </group> + </page> + <page string="Members"> + <field name="channel_partner_ids" mode="tree" readonly="1"> + <tree string="members"> + <field name="name"/> + <field name="email"/> + <field name="im_status"/> + </tree> + </field> + </page> + </notebook> </sheet> - <div class="oe_chatter"> - <field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/> - <field name="message_ids" widget="mail_thread" options='{"thread_level": 1}' placeholder="Send a message to the group"/> - </div> </form> </field> </record> @@ -187,5 +186,40 @@ name="Channels" action="mail_channel_action_view" groups="base.group_no_one"/> + + + + + <!-- TODO JEM : SLACK test client action--> + + <record id="mail_channel_action_client_slack" model="ir.actions.client"> + <field name="name">Chat</field> + <field name="tag">mail.chat.instant_messaging</field> + <field name="res_model">mail.channel</field> + <field name="params" eval=""{ + 'default_active_id': 'channel_inbox' + }""/> + <field name="help" type="html"> + <p> + There is no message in this channel. + </p><p> + Your "Inbox" contains your "needaction" also known as the messages where you are mentioned, and the messages you get when you follow a document as a person.<br/> + Your "Starred" contains your favorite messages. + <br/><br/> + In the "Channels" section, you will find the public and restricted channel.<br/> + The "Direct Message" allow you to talk directly to one person.<br/> + "Private Group" contains the private group conversation. You need to be invited to this group to see it. + </p> + </field> + </record> + + <menuitem + id="mail.mail_channel_menu_root_slack" + name="Slack" + action="mail_channel_action_client_slack" + icon="fa-envelope" + groups="base.group_user" + sequence="1"/> + </data> </openerp> diff --git a/addons/mail/views/mail_shortcode_views.xml b/addons/mail/views/mail_shortcode_views.xml index e281950d102..0b4b1085f44 100644 --- a/addons/mail/views/mail_shortcode_views.xml +++ b/addons/mail/views/mail_shortcode_views.xml @@ -2,10 +2,10 @@ <odoo> <data> - <!-- im_chat.shortcode --> - <record id="im_chat_shortcode_action" model="ir.actions.act_window"> + <!-- mail.shortcode --> + <record id="mail_shortcode_action" model="ir.actions.act_window"> <field name="name">Chat Shortcode</field> - <field name="res_model">im_chat.shortcode</field> + <field name="res_model">mail.shortcode</field> <field name="view_mode">tree,form</field> <field name="help" type="html"> <p class="oe_view_nocontent_create"> @@ -16,9 +16,9 @@ </field> </record> - <record id="im_chat_shortcode_view_tree" model="ir.ui.view"> - <field name="name">im_chat.shortcode.tree</field> - <field name="model">im_chat.shortcode</field> + <record id="mail_shortcode_view_tree" model="ir.ui.view"> + <field name="name">mail.shortcode.tree</field> + <field name="model">mail.shortcode</field> <field name="arch" type="xml"> <tree string="Shortcodes"> <field name="source"/> @@ -28,9 +28,9 @@ </field> </record> - <record id="im_chat_shortcode_view_form" model="ir.ui.view"> - <field name="name">im_chat.shortcode.form</field> - <field name="model">im_chat.shortcode</field> + <record id="mail_shortcode_view_form" model="ir.ui.view"> + <field name="name">mail.shortcode.form</field> + <field name="model">mail.shortcode</field> <field name="arch" type="xml"> <form string="Shortcodes"> <sheet> diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 8fa17f867d7..ed481cf35fd 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -12,10 +12,18 @@ <script type="text/javascript" src="/mail/static/src/js/many2many_tags_email.js"></script> <script type="text/javascript" src="/mail/static/src/js/announcement.js"></script> + <link rel="stylesheet" href="/mail/static/src/less/mail_thread.less" type="text/less"/> + <link rel="stylesheet" href="/mail/static/src/less/mail_chatter.less" type="text/less"/> + <link rel="stylesheet" href="/mail/static/src/less/mail_chat_backend.less" type="text/less"/> <link rel="stylesheet" href="/mail/static/src/css/mail_chat_common.css"/> <link rel="stylesheet" href="/mail/static/src/css/mail_chat_backend.css"/> + + <script type="text/javascript" src="/mail/static/src/js/mail_thread.js"></script> + <script type="text/javascript" src="/mail/static/src/js/mail_chatter.js"></script> + <script type="text/javascript" src="/mail/static/src/js/mail_chat_common.js"></script> <script type="text/javascript" src="/mail/static/src/js/mail_chat_backend.js"></script> + </xpath> </template> </data> diff --git a/addons/mail/wizard/invite.py b/addons/mail/wizard/invite.py index 1262b17beb1..ce29f1d600b 100644 --- a/addons/mail/wizard/invite.py +++ b/addons/mail/wizard/invite.py @@ -45,7 +45,7 @@ class Invite(models.TransientModel): model_ids = self.env['ir.model'].search([('model', '=', wizard.res_model)]) model_name = model_ids.name_get()[0][1] - + print "@@@@@", wizard.message # send an email if option checked and if a message exists (do not send void emails) if wizard.send_mail and wizard.message and not wizard.message == '<br>': # when deleting the message, cleditor keeps a <br> # TDE FIXME: use a template + _notification methods |
