From 5999b71c416f02dc11803bf52a406b9109ddc3c1 Mon Sep 17 00:00:00 2001
From: mathieui <mathieui@mathieui.net>
Date: Sun, 27 Apr 2014 16:32:03 +0200
Subject: Fix #2106 (implement message delivery receipts)

- two options request/ack_message_receipts
- two new theme parameters : CHAR_ACK_RECEIVED and COLOR_CHAR_ACK
- if a message has a receipt, the character is displayed between the
  timestamp and the nick, using the color
---
 CHANGELOG                    |  1 +
 data/default_config.cfg      |  6 +++
 doc/source/configuration.rst | 13 ++++++
 doc/source/dev/xep.rst       |  2 +
 src/connection.py            |  7 ++++
 src/core/core.py             | 18 ++++++++
 src/core/handlers.py         | 18 ++++++++
 src/tabs/basetabs.py         |  9 ++++
 src/tabs/conversationtab.py  |  4 +-
 src/text_buffer.py           | 97 ++++++++++++++++++++++++++++----------------
 src/theming.py               |  3 ++
 src/windows.py               | 39 +++++++++++++++---
 12 files changed, 176 insertions(+), 41 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG
index 339346fd..9bcab2f6 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,7 @@ For more detailed changelog, see the roadmap:
 http://dev.louiz.org/projects/poezio/roadmap
 
 * Poezio 0.8.3 - dev
+- Implement XEP-0184 (message delivery receipts)
 - better setup scripts (use setuptools)
 - Better timezone handling
 - Better alias plugin, with permanent alias storage
diff --git a/data/default_config.cfg b/data/default_config.cfg
index f2e45e46..ef863ee4 100644
--- a/data/default_config.cfg
+++ b/data/default_config.cfg
@@ -370,6 +370,12 @@ display_tune_notifications = false
 # other resources with carbons enabled.
 enable_carbons = false
 
+# Acknowledge message delivery receipts (XEP-0184)
+ack_message_receipts = true
+
+# Ask for message delivery receipts (XEP-0184)
+request_message_receipts = true
+
 # Receive the tune notifications or not (in order to display informations
 # in the roster).
 # If this is set to false, then the display_tune_notifications
diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst
index 50b9fc71..cb2fbddb 100644
--- a/doc/source/configuration.rst
+++ b/doc/source/configuration.rst
@@ -364,6 +364,19 @@ to understand what is :ref:`carbons <carbons-details>` or
         XHTML and CSS formating. We can use this to make colored text for example.
         Set to ``true`` if you want to see colored (and otherwise formatted) messages.
 
+    request_message_receipts
+
+        **Default value:** ``true``
+
+        Request message receipts when sending messages (except in groupchats).
+
+    ack_message_receipts
+
+        **Default value:** ``true``
+
+        Acknowledge message receipts requested by the other party.
+
+
     send_chat_states
 
         **Default value:** ``true``
diff --git a/doc/source/dev/xep.rst b/doc/source/dev/xep.rst
index 7d303a03..268ee932 100644
--- a/doc/source/dev/xep.rst
+++ b/doc/source/dev/xep.rst
@@ -37,6 +37,8 @@ Table of all XEPs implemented in poezio.
 +----------+-------------------------+---------------------+
 |0172      |User Nickname            |100%                 |
 +----------+-------------------------+---------------------+
+|0184      |Message Delivery Receipts|100%                 |
++----------+-------------------------+---------------------+
 |0191      |Simple Communication     |95%                  |
 |          |Blocking                 |                     |
 +----------+-------------------------+---------------------+
diff --git a/src/connection.py b/src/connection.py
index da26f25b..0af2b228 100644
--- a/src/connection.py
+++ b/src/connection.py
@@ -84,6 +84,13 @@ class Connection(sleekxmpp.ClientXMPP):
         self.plugin['xep_0077'].create_account = False
         self.register_plugin('xep_0085')
         self.register_plugin('xep_0115')
+
+        self.register_plugin('xep_0184')
+        self.plugin['xep_0184'].auto_ack = config.get('ack_message_receipts',
+                                                      True)
+        self.plugin['xep_0184'].auto_request = config.get(
+                'request_message_receipts', True)
+
         self.register_plugin('xep_0191')
         self.register_plugin('xep_0199')
         self.set_keepalive_values()
diff --git a/src/core/core.py b/src/core/core.py
index 4bb0725b..95adc067 100644
--- a/src/core/core.py
+++ b/src/core/core.py
@@ -208,6 +208,7 @@ class Core(object):
         self.xmpp.add_event_handler("groupchat_subject",
                                     self.on_groupchat_subject)
         self.xmpp.add_event_handler("message", self.on_message)
+        self.xmpp.add_event_handler("receipt_received", self.on_receipt)
         self.xmpp.add_event_handler("got_online", self.on_got_online)
         self.xmpp.add_event_handler("got_offline", self.on_got_offline)
         self.xmpp.add_event_handler("roster_update", self.on_roster_update)
@@ -277,6 +278,10 @@ class Core(object):
         self.configuration_change_handlers = {"": []}
         self.add_configuration_handler("create_gaps",
                                        self.on_gaps_config_change)
+        self.add_configuration_handler("request_message_receipts",
+                                       self.on_request_receipts_config_change)
+        self.add_configuration_handler("ack_message_receipts",
+                                       self.on_ack_receipts_config_change)
         self.add_configuration_handler("plugins_dir",
                                        self.on_plugins_dir_config_change)
         self.add_configuration_handler("plugins_conf_dir",
@@ -331,6 +336,18 @@ class Core(object):
         if value.lower() == "false":
             self.tabs = list(tab for tab in self.tabs if tab)
 
+    def on_request_receipts_config_change(self, option, value):
+        """
+        Called when the request_message_receipts option changes
+        """
+        self.xmpp.plugin['xep_0184'].auto_request = config.get(option, True)
+
+    def on_ack_receipts_config_change(self, option, value):
+        """
+        Called when the ack_message_receipts option changes
+        """
+        self.xmpp.plugin['xep_0184'].auto_ack = config.get(option, True)
+
     def on_plugins_dir_config_change(self, option, value):
         """
         Called when the plugins_dir option is changed
@@ -1850,6 +1867,7 @@ class Core(object):
     on_status_codes = handlers.on_status_codes
     on_groupchat_subject = handlers.on_groupchat_subject
     on_data_form = handlers.on_data_form
+    on_receipt = handlers.on_receipt
     on_attention = handlers.on_attention
     room_error = handlers.room_error
     outgoing_stanza = handlers.outgoing_stanza
diff --git a/src/core/handlers.py b/src/core/handlers.py
index 7ce14c65..58217e8f 100644
--- a/src/core/handlers.py
+++ b/src/core/handlers.py
@@ -59,6 +59,8 @@ def on_carbon_received(self, message):
         else:
             return
     recv['to'] = self.xmpp.boundjid.full
+    if recv['receipt']:
+        return self.on_receipt(recv)
     self.on_normal_message(recv)
 
 def on_carbon_sent(self, message):
@@ -955,6 +957,22 @@ def on_groupchat_subject(self, message):
     if self.get_tab_by_name(room_from, tabs.MucTab) is self.current_tab():
         self.refresh_window()
 
+def on_receipt(self, message):
+    """
+    When a delivery receipt is received (XEP-0184)
+    """
+    jid = message['from']
+    msg_id = message['receipt']
+    if not msg_id:
+        return
+
+    conversation = self.get_tab_by_name(jid)
+    conversation = conversation or self.get_tab_by_name(jid.bare)
+    if not conversation:
+        return
+
+    conversation.ack_message(msg_id)
+
 def on_data_form(self, message):
     """
     When a data form is received
diff --git a/src/tabs/basetabs.py b/src/tabs/basetabs.py
index 1e7564dc..2811ba66 100644
--- a/src/tabs/basetabs.py
+++ b/src/tabs/basetabs.py
@@ -496,6 +496,15 @@ class ChatTab(Tab):
                 identifier=identifier,
                 jid=jid)
 
+    def ack_message(self, msg_id):
+        """
+        Ack a message
+        """
+        new_msg = self._text_buffer.ack_message(msg_id)
+        if new_msg:
+            self.text_win.modify_message(msg_id, new_msg)
+            self.core.refresh_window()
+
     def modify_message(self, txt, old_id, new_id, user=None, jid=None, nickname=None):
         self.log_message(txt, nickname, typ=1)
         message = self._text_buffer.modify_message(txt, old_id, new_id, time=time, user=user, jid=jid)
diff --git a/src/tabs/conversationtab.py b/src/tabs/conversationtab.py
index ce60689c..51262db0 100644
--- a/src/tabs/conversationtab.py
+++ b/src/tabs/conversationtab.py
@@ -219,7 +219,9 @@ class ConversationTab(ChatTab):
             msg.send()
 
     def check_attention(self):
-        self.core.xmpp.plugin['xep_0030'].get_info(jid=self.get_dest_jid(), block=False, timeout=5, callback=self.on_attention_checked)
+        self.core.xmpp.plugin['xep_0030'].get_info(
+                jid=self.get_dest_jid(), block=False, timeout=5,
+                callback=self.on_attention_checked)
 
     def on_attention_checked(self, iq):
         if 'urn:xmpp:attention:0' in iq['disco_info'].get_features():
diff --git a/src/text_buffer.py b/src/text_buffer.py
index ed213d2d..4a41fd97 100644
--- a/src/text_buffer.py
+++ b/src/text_buffer.py
@@ -18,7 +18,7 @@ from config import config
 from theming import get_theme, dump_tuple
 
 message_fields = ('txt nick_color time str_time nickname user identifier'
-                  ' highlight me old_message revisions jid')
+                  ' highlight me old_message revisions jid ack')
 Message = collections.namedtuple('Message', message_fields)
 
 class CorrectionError(Exception):
@@ -84,7 +84,7 @@ class TextBuffer(object):
     @staticmethod
     def make_message(txt, time, nickname, nick_color, history, user,
                      identifier, str_time=None, highlight=False,
-                     old_message=None, revisions=0, jid=None):
+                     old_message=None, revisions=0, jid=None, ack=None):
         """
         Create a new Message object with parameters, check for /me messages,
         and delayed messages
@@ -118,19 +118,20 @@ class TextBuffer(object):
                 me=me,
                 old_message=old_message,
                 revisions=revisions,
-                jid=jid)
+                jid=jid,
+                ack=ack)
         log.debug('Set message %s with %s.', identifier, msg)
         return msg
 
     def add_message(self, txt, time=None, nickname=None,
                     nick_color=None, history=None, user=None, highlight=False,
-                    identifier=None, str_time=None, jid=None):
+                    identifier=None, str_time=None, jid=None, ack=None):
         """
         Create a message and add it to the text buffer
         """
         msg = self.make_message(txt, time, nickname, nick_color, history,
                                 user, identifier, str_time=str_time,
-                                highlight=highlight, jid=jid)
+                                highlight=highlight, jid=jid, ack=ack)
         self.messages.append(msg)
 
         while len(self.messages) > self.messages_nb_limit:
@@ -150,42 +151,68 @@ class TextBuffer(object):
 
         return ret_val or 1
 
+    def _find_message(self, old_id):
+        """
+        Find a message in the text buffer from its message id
+        """
+        for i in range(len(self.messages) -1, -1, -1):
+            msg = self.messages[i]
+            if msg.identifier == old_id:
+                return i
+        return -1
+
+    def ack_message(self, old_id):
+        """
+        Ack a message
+        """
+        i = self._find_message(old_id)
+        if i == -1:
+            return
+        msg = self.messages[i]
+        new_msg = list(msg)
+        new_msg[12] = True
+        new_msg = Message(*new_msg)
+        self.messages[i] = new_msg
+        return new_msg
+
     def modify_message(self, txt, old_id, new_id, highlight=False,
                        time=None, user=None, jid=None):
         """
         Correct a message in a text buffer.
         """
 
-        for i in range(len(self.messages) -1, -1, -1):
-            msg = self.messages[i]
-
-            if msg.identifier == old_id:
-                if msg.user and msg.user is not user:
-                    raise CorrectionError("Different users")
-                elif len(msg.str_time) > 8: # ugly
-                    raise CorrectionError("Delayed message")
-                elif not msg.user and (msg.jid is None or jid is None):
-                    raise CorrectionError('Could not check the '
-                                          'identity of the sender')
-                elif not msg.user and msg.jid != jid:
-                    raise CorrectionError('Messages %s and %s have not been '
-                                          'sent by the same fullJID' %
-                                              (old_id, new_id))
-
-                if not time:
-                    time = msg.time
-                message = self.make_message(txt, time, msg.nickname,
-                                            msg.nick_color, None, msg.user,
-                                            new_id, highlight=highlight,
-                                            old_message=msg,
-                                            revisions=msg.revisions + 1,
-                                            jid=jid)
-                self.messages[i] = message
-                log.debug('Replacing message %s with %s.', old_id, new_id)
-                return message
-        log.debug('Message %s not found in text_buffer, abort replacement.',
-                  old_id)
-        raise CorrectionError("nothing to replace")
+        i = self._find_message(old_id)
+
+        if i == -1:
+            log.debug('Message %s not found in text_buffer, abort replacement.',
+                      old_id)
+            raise CorrectionError("nothing to replace")
+
+        msg = self.messages[i]
+
+        if msg.user and msg.user is not user:
+            raise CorrectionError("Different users")
+        elif len(msg.str_time) > 8: # ugly
+            raise CorrectionError("Delayed message")
+        elif not msg.user and (msg.jid is None or jid is None):
+            raise CorrectionError('Could not check the '
+                                  'identity of the sender')
+        elif not msg.user and msg.jid != jid:
+            raise CorrectionError('Messages %s and %s have not been '
+                                  'sent by the same fullJID' %
+                                      (old_id, new_id))
+
+        if not time:
+            time = msg.time
+        message = self.make_message(txt, time, msg.nickname,
+                                    msg.nick_color, None, msg.user,
+                                    new_id, highlight=highlight,
+                                    old_message=msg,
+                                    revisions=msg.revisions + 1,
+                                    jid=jid)
+        self.messages[i] = message
+        log.debug('Replacing message %s with %s.', old_id, new_id)
+        return message
 
     def del_window(self, win):
         self.windows.remove(win)
diff --git a/src/theming.py b/src/theming.py
index 71ed760c..093fa553 100644
--- a/src/theming.py
+++ b/src/theming.py
@@ -301,6 +301,7 @@ class Theme(object):
     CHAR_QUIT = '<---'
     CHAR_KICK = '-!-'
     CHAR_NEW_TEXT_SEPARATOR = '- '
+    CHAR_ACK_RECEIVED = '✔'
     CHAR_COLUMN_ASC = ' ▲'
     CHAR_COLUMN_DESC = ' ▼'
     CHAR_ROSTER_ERROR = '✖'
@@ -314,6 +315,8 @@ class Theme(object):
     CHAR_ROSTER_TO = '→'
     CHAR_ROSTER_NONE = '⇹'
 
+    COLOR_CHAR_ACK = (2, -1)
+
     COLOR_ROSTER_GAMING = (6, -1)
     COLOR_ROSTER_MOOD = (2, -1)
     COLOR_ROSTER_ACTIVITY = (3, -1)
diff --git a/src/windows.py b/src/windows.py
index 4dd0c242..fb901f19 100644
--- a/src/windows.py
+++ b/src/windows.py
@@ -914,6 +914,8 @@ class TextWin(Win):
         ret = []
         nick = truncate_nick(message.nickname)
         offset = 0
+        if message.ack:
+            offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
         if nick:
             offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length
         if message.revisions > 0:
@@ -967,6 +969,8 @@ class TextWin(Win):
                             color = None
                         if with_timestamps:
                             self.write_time(msg.str_time)
+                        if msg.ack:
+                            self.write_ack()
                         if msg.me:
                             self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE))
                             self.addstr('* ')
@@ -990,11 +994,29 @@ class TextWin(Win):
                 if not line:
                     self.write_line_separator(y)
                 else:
-                    self.write_text(y,
-                                    # Offset for the timestamp (if any) plus a space after it
-                                    (0 if not with_timestamps else (len(line.msg.str_time) + (1 if line.msg.str_time else 0) )) +
-                                    # Offset for the nickname (if any) plus a space and a > after it
-                                    (0 if not line.msg.nickname else (poopt.wcswidth(truncate_nick(line.msg.nickname)) + (3 if line.msg.me else 2) + ceil(log10(line.msg.revisions + 1)))),
+                    offset = 0
+                    # Offset for the timestamp (if any) plus a space after it
+                    if with_timestamps:
+                        offset += len(line.msg.str_time)
+                        if offset:
+                            offset += 1
+
+                    # Offset for the nickname (if any)
+                    # plus a space and a > after it
+                    if line.msg.nickname:
+                        offset += poopt.wcswidth(
+                                    truncate_nick(line.msg.nickname))
+                        if line.msg.me:
+                            offset += 3
+                        else:
+                            offset += 2
+                        offset += ceil(log10(line.msg.revisions + 1))
+
+                        if line.msg.ack:
+                            offset += 1 + poopt.wcswidth(
+                                        get_theme().CHAR_ACK_RECEIVED)
+
+                    self.write_text(y, offset,
                             line.prepend+line.msg.txt[line.start_pos:line.end_pos])
                 if y != self.height-1:
                     self.addstr('\n')
@@ -1014,6 +1036,13 @@ class TextWin(Win):
         """
         self.addstr_colored(txt, y, x)
 
+    def write_ack(self):
+        color = get_theme().COLOR_CHAR_ACK
+        self._win.attron(to_curses_attr(color))
+        self.addstr(get_theme().CHAR_ACK_RECEIVED)
+        self._win.attroff(to_curses_attr(color))
+        self.addstr(' ')
+
     def write_nickname(self, nickname, color, highlight=False):
         """
         Write the nickname, using the user's color
-- 
cgit v1.2.3