Daniel Brötzmann pushed to branch mainwindow at gajim / gajim

Commits:

12 changed files:

Changes:

  • gajim/chat_control.py
    ... ... @@ -209,8 +209,6 @@ def __init__(self, parent_win, jid, acct, session, resource=None):
    209 209
                 gui_menu_builder.get_encryption_menu(
    
    210 210
                     self.control_id, self._type, self.account == 'Local'))
    
    211 211
             self.set_encryption_menu_icon()
    
    212
    -        # restore previous conversation
    
    213
    -        self.restore_conversation()
    
    214 212
             self.msg_textview.grab_focus()
    
    215 213
     
    
    216 214
             # PluginSystem: adding GUI extension point for this ChatControl
    
    ... ... @@ -562,7 +560,7 @@ def _on_message_received(self, event):
    562 560
                              additional_data=event.additional_data)
    
    563 561
     
    
    564 562
         def _on_message_error(self, event):
    
    565
    -        self.conv_textview.show_error(event.message_id, event.error)
    
    563
    +        self.conversation_view.show_error(event.message_id, event.error)
    
    566 564
     
    
    567 565
         def _on_message_sent(self, event):
    
    568 566
             if not event.message:
    
    ... ... @@ -589,10 +587,10 @@ def _on_message_sent(self, event):
    589 587
                              additional_data=event.additional_data)
    
    590 588
     
    
    591 589
         def _on_receipt_received(self, event):
    
    592
    -        self.conv_textview.show_receipt(event.receipt_id)
    
    590
    +        self.conversation_view.show_receipt(event.receipt_id)
    
    593 591
     
    
    594 592
         def _on_displayed_received(self, event):
    
    595
    -        self.conv_textview.show_displayed(event.marker_id)
    
    593
    +        self.conversation_view.set_read_marker(event.marker_id)
    
    596 594
     
    
    597 595
         def _nec_ping(self, event):
    
    598 596
             if self.contact != event.contact:
    
    ... ... @@ -1177,62 +1175,6 @@ def _on_drag_data_received(self, widget, context, x, y, selection,
    1177 1175
                                            [self.contact.jid],
    
    1178 1176
                                            [dropped_jid])
    
    1179 1177
     
    
    1180
    -    def restore_conversation(self):
    
    1181
    -        jid = self.contact.jid
    
    1182
    -        # number of messages that are in queue and are already logged, we want
    
    1183
    -        # to avoid duplication
    
    1184
    -        pending = len(app.events.get_events(self.account, jid, ['chat', 'pm']))
    
    1185
    -        if self.resource:
    
    1186
    -            pending += len(app.events.get_events(self.account,
    
    1187
    -                                                 self.contact.get_full_jid(),
    
    1188
    -                                                 ['chat', 'pm']))
    
    1189
    -
    
    1190
    -        rows = app.storage.archive.get_last_conversation_lines(
    
    1191
    -            self.account, jid, pending)
    
    1192
    -
    
    1193
    -        local_old_kind = None
    
    1194
    -        self.conv_textview.just_cleared = True
    
    1195
    -        for row in rows: # time, kind, message, subject, additional_data
    
    1196
    -            msg = row.message
    
    1197
    -            additional_data = row.additional_data
    
    1198
    -            if not msg: # message is empty, we don't print it
    
    1199
    -                continue
    
    1200
    -            if row.kind in (KindConstant.CHAT_MSG_SENT,
    
    1201
    -                            KindConstant.SINGLE_MSG_SENT):
    
    1202
    -                kind = 'outgoing'
    
    1203
    -                name = self.get_our_nick()
    
    1204
    -            elif row.kind in (KindConstant.SINGLE_MSG_RECV,
    
    1205
    -                              KindConstant.CHAT_MSG_RECV):
    
    1206
    -                kind = 'incoming'
    
    1207
    -                name = self.contact.name
    
    1208
    -            elif row.kind == KindConstant.ERROR:
    
    1209
    -                kind = 'status'
    
    1210
    -                name = self.contact.name
    
    1211
    -
    
    1212
    -            tim = float(row.time)
    
    1213
    -
    
    1214
    -            if row.subject:
    
    1215
    -                msg = _('Subject: %(subject)s\n%(message)s') % \
    
    1216
    -                    {'subject': row.subject, 'message': msg}
    
    1217
    -            ChatControlBase.add_message(self,
    
    1218
    -                                        msg,
    
    1219
    -                                        kind,
    
    1220
    -                                        name,
    
    1221
    -                                        tim,
    
    1222
    -                                        restored=True,
    
    1223
    -                                        old_kind=local_old_kind,
    
    1224
    -                                        additional_data=additional_data,
    
    1225
    -                                        message_id=row.message_id,
    
    1226
    -                                        marker=row.marker,
    
    1227
    -                                        error=row.error)
    
    1228
    -            if (row.message.startswith('/me ') or
    
    1229
    -                    row.message.startswith('/me\n')):
    
    1230
    -                local_old_kind = None
    
    1231
    -            else:
    
    1232
    -                local_old_kind = kind
    
    1233
    -        if rows:
    
    1234
    -            self.conv_textview.print_empty_line()
    
    1235
    -
    
    1236 1178
         def _on_convert_to_gc_menuitem_activate(self, _widget):
    
    1237 1179
             """
    
    1238 1180
             User wants to invite some friends to chat
    

  • gajim/chat_control_base.py
    ... ... @@ -25,6 +25,7 @@
    25 25
     
    
    26 26
     import os
    
    27 27
     import sys
    
    28
    +import datetime
    
    28 29
     import time
    
    29 30
     import uuid
    
    30 31
     import tempfile
    
    ... ... @@ -43,12 +44,12 @@
    43 44
     from gajim.common.i18n import _
    
    44 45
     from gajim.common.nec import EventHelper
    
    45 46
     from gajim.common.helpers import AdditionalDataDict
    
    47
    +from gajim.common.const import KindConstant
    
    46 48
     from gajim.common.structs import OutgoingMessage
    
    47 49
     
    
    48 50
     from gajim import gtkgui_helpers
    
    49 51
     
    
    50
    -from gajim.conversation_textview import ConversationTextview
    
    51
    -
    
    52
    +from gajim.gui.conversation_view import ConversationView
    
    52 53
     from gajim.gui.dialogs import DialogButton
    
    53 54
     from gajim.gui.dialogs import ConfirmationDialog
    
    54 55
     from gajim.gui.dialogs import PastePreviewDialog
    
    ... ... @@ -93,7 +94,7 @@
    93 94
     ################################################################################
    
    94 95
     class ChatControlBase(ChatCommandProcessor, CommandTools, EventHelper):
    
    95 96
         """
    
    96
    -    A base class containing a banner, ConversationTextview, MessageInputTextView
    
    97
    +    A base class containing a banner, ConversationView, MessageInputTextView
    
    97 98
         """
    
    98 99
     
    
    99 100
         _type = None  # type: ControlType
    
    ... ... @@ -164,14 +165,16 @@ def __init__(self, parent_win, widget_name, jid, acct,
    164 165
                 Gdk.DragAction.COPY | Gdk.DragAction.MOVE)
    
    165 166
             self.xml.overlay.drag_dest_set_target_list(dst_targets)
    
    166 167
     
    
    167
    -        # Create textviews and connect signals
    
    168
    -        self.conv_textview = ConversationTextview(self.account)
    
    169
    -
    
    170
    -        id_ = self.conv_textview.connect('quote', self.on_quote)
    
    171
    -        self.handlers[id_] = self.conv_textview
    
    172
    -
    
    173
    -        self.conv_textview.tv.connect('key-press-event',
    
    174
    -                                      self._on_conv_textview_key_press_event)
    
    168
    +        # Create ConversationView and connect signals
    
    169
    +        self.conversation_view = ConversationView(self.account, self.contact)
    
    170
    +        id_ = self.conversation_view.connect('quote', self.on_quote)
    
    171
    +        self.handlers[id_] = self.conversation_view
    
    172
    +        id_ = self.conversation_view.connect(
    
    173
    +            'load-history', self._on_load_history)
    
    174
    +        self.handlers[id_] = self.conversation_view
    
    175
    +        id_ = self.conversation_view.connect(
    
    176
    +            'key-press-event', self._on_conversation_view_key_press)
    
    177
    +        self.handlers[id_] = self.conversation_view
    
    175 178
     
    
    176 179
             # This is a workaround: as soon as a line break occurs in Gtk.TextView
    
    177 180
             # with word-char wrapping enabled, a hyphen character is automatically
    
    ... ... @@ -183,10 +186,18 @@ def __init__(self, parent_win, widget_name, jid, acct,
    183 186
             hscrollbar = self.xml.conversation_scrolledwindow.get_hscrollbar()
    
    184 187
             hscrollbar.hide()
    
    185 188
     
    
    186
    -        self.xml.conversation_scrolledwindow.add(self.conv_textview.tv)
    
    187
    -        widget = self.xml.conversation_scrolledwindow.get_vadjustment()
    
    188
    -        widget.connect('changed', self.on_conversation_vadjustment_changed)
    
    189
    +        self.xml.conversation_scrolledwindow.add(self.conversation_view)
    
    190
    +        self._scrolled_old_upper = None
    
    191
    +        self._scrolled_old_value = None
    
    192
    +        self._scrolled_previous_value = 0
    
    193
    +
    
    194
    +        self._fetching_history = False
    
    195
    +        self._initial_fetch_done = False
    
    189 196
     
    
    197
    +        vadjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
    
    198
    +        vadjustment.connect(
    
    199
    +            'changed', self._on_conversation_vadjustment_changed)
    
    200
    +        vadjustment.connect('value-changed', self._on_scroll_value_changed)
    
    190 201
             vscrollbar = self.xml.conversation_scrolledwindow.get_vscrollbar()
    
    191 202
             vscrollbar.connect('button-release-event',
    
    192 203
                                self._on_scrollbar_button_release)
    
    ... ... @@ -233,7 +244,6 @@ def __init__(self, parent_win, widget_name, jid, acct,
    233 244
     
    
    234 245
             # Attach speller
    
    235 246
             self.set_speller()
    
    236
    -        self.conv_textview.tv.show()
    
    237 247
     
    
    238 248
             # For XEP-0172
    
    239 249
             self.user_nick = None
    
    ... ... @@ -251,7 +261,7 @@ def __init__(self, parent_win, widget_name, jid, acct,
    251 261
                 self.handlers[id_] = parent_win.window
    
    252 262
     
    
    253 263
             self.encryption = self.get_encryption_state()
    
    254
    -        self.conv_textview.encryption_enabled = self.encryption is not None
    
    264
    +        self.conversation_view.encryption_enabled = self.encryption is not None
    
    255 265
     
    
    256 266
             # PluginSystem: adding GUI extension point for ChatControlBase
    
    257 267
             # instance object (also subclasses, eg. ChatControl or GroupchatControl)
    
    ... ... @@ -280,7 +290,7 @@ def process_event(self, event):
    280 290
             method_name = f'_on_{method_name}'
    
    281 291
             getattr(self, method_name)(event)
    
    282 292
     
    
    283
    -    def _on_conv_textview_key_press_event(self, textview, event):
    
    293
    +    def _on_conversation_view_key_press(self, _listbox, event):
    
    284 294
             if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
    
    285 295
                 if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
    
    286 296
                     return Gdk.EVENT_PROPAGATE
    
    ... ... @@ -292,12 +302,13 @@ def _on_conv_textview_key_press_event(self, textview, event):
    292 302
                 # focused).
    
    293 303
                 return Gdk.EVENT_PROPAGATE
    
    294 304
     
    
    295
    -        if event.get_state() & COPY_MODIFIER:
    
    296
    -            # Don’t reroute the event if it is META + c and the
    
    297
    -            # textview has a selection
    
    298
    -            if event.hardware_keycode in KEYCODES_KEY_C:
    
    299
    -                if textview.get_buffer().props.has_selection:
    
    300
    -                    return Gdk.EVENT_PROPAGATE
    
    305
    +        # if event.get_state() & COPY_MODIFIER:
    
    306
    +        # TODO
    
    307
    +        #     # Don’t reroute the event if it is META + c and the
    
    308
    +        #     # textview has a selection
    
    309
    +        #     if event.hardware_keycode in KEYCODES_KEY_C:
    
    310
    +        #         if textview.get_buffer().props.has_selection:
    
    311
    +        #             return Gdk.EVENT_PROPAGATE
    
    301 312
     
    
    302 313
             if not self.msg_textview.get_sensitive():
    
    303 314
                 # If the input textview is not sensitive it can’t get the focus.
    
    ... ... @@ -513,7 +524,7 @@ def delegate_action(self, action):
    513 524
                 return Gdk.EVENT_STOP
    
    514 525
     
    
    515 526
             if action == 'clear-chat':
    
    516
    -            self.conv_textview.clear()
    
    527
    +            self.conversation_view.clear()
    
    517 528
                 return Gdk.EVENT_STOP
    
    518 529
     
    
    519 530
             if action == 'delete-line':
    
    ... ... @@ -613,7 +624,7 @@ def _on_authentication_button_clicked(self, _button):
    613 624
     
    
    614 625
         def set_encryption_state(self, encryption):
    
    615 626
             self.encryption = encryption
    
    616
    -        self.conv_textview.encryption_enabled = encryption is not None
    
    627
    +        self.conversation_view.encryption_enabled = encryption is not None
    
    617 628
             self.contact.settings.set('encryption', self.encryption or '')
    
    618 629
     
    
    619 630
         def get_encryption_state(self):
    
    ... ... @@ -698,8 +709,7 @@ def shutdown(self):
    698 709
                     self.handlers[i].disconnect(i)
    
    699 710
             self.handlers.clear()
    
    700 711
     
    
    701
    -        self.conv_textview.del_handlers()
    
    702
    -        del self.conv_textview
    
    712
    +        del self.conversation_view
    
    703 713
             del self.msg_textview
    
    704 714
             del self.msg_scrolledwindow
    
    705 715
     
    
    ... ... @@ -853,7 +863,7 @@ def _on_message_textview_key_press_event(self, textview, event):
    853 863
                     return True
    
    854 864
     
    
    855 865
                 if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
    
    856
    -                self.conv_textview.tv.event(event)
    
    866
    +                self.conversation_view.event(event)
    
    857 867
                     self._on_scroll(None, event.keyval)
    
    858 868
                     return True
    
    859 869
     
    
    ... ... @@ -1097,11 +1107,11 @@ def save_message(self, message, msg_type):
    1097 1107
                 self.received_history_pos = pos
    
    1098 1108
     
    
    1099 1109
         def add_info_message(self, text, message_id=None):
    
    1100
    -        self.conv_textview.print_conversation_line(
    
    1110
    +        self.conversation_view.add_message(
    
    1101 1111
                 text, 'info', '', None, message_id=message_id, graphics=False)
    
    1102 1112
     
    
    1103 1113
         def add_status_message(self, text):
    
    1104
    -        self.conv_textview.print_conversation_line(
    
    1114
    +        self.conversation_view.add_message(
    
    1105 1115
                 text, 'status', '', None)
    
    1106 1116
     
    
    1107 1117
         def add_message(self,
    
    ... ... @@ -1129,9 +1139,8 @@ def add_message(self,
    1129 1139
             """
    
    1130 1140
             jid = self.contact.jid
    
    1131 1141
             full_jid = self.get_full_jid()
    
    1132
    -        textview = self.conv_textview
    
    1133 1142
             end = False
    
    1134
    -        if self.conv_textview.autoscroll or kind == 'outgoing':
    
    1143
    +        if self.conversation_view.autoscroll or kind == 'outgoing':
    
    1135 1144
                 end = True
    
    1136 1145
     
    
    1137 1146
             if other_tags_for_name is None:
    
    ... ... @@ -1143,21 +1152,18 @@ def add_message(self,
    1143 1152
             if additional_data is None:
    
    1144 1153
                 additional_data = AdditionalDataDict()
    
    1145 1154
     
    
    1146
    -        textview.print_conversation_line(text,
    
    1147
    -                                         kind,
    
    1148
    -                                         name,
    
    1149
    -                                         tim,
    
    1150
    -                                         other_tags_for_name,
    
    1151
    -                                         other_tags_for_time,
    
    1152
    -                                         other_tags_for_text,
    
    1153
    -                                         subject,
    
    1154
    -                                         old_kind,
    
    1155
    -                                         displaymarking=displaymarking,
    
    1156
    -                                         message_id=message_id,
    
    1157
    -                                         correct_id=correct_id,
    
    1158
    -                                         additional_data=additional_data,
    
    1159
    -                                         marker=marker,
    
    1160
    -                                         error=error)
    
    1155
    +        self.conversation_view.add_message(
    
    1156
    +            text,
    
    1157
    +            kind,
    
    1158
    +            name,
    
    1159
    +            tim,
    
    1160
    +            other_text_tags=other_tags_for_text,
    
    1161
    +            display_marking=displaymarking,
    
    1162
    +            message_id=message_id,
    
    1163
    +            correct_id=correct_id,
    
    1164
    +            additional_data=additional_data,
    
    1165
    +            marker=marker,
    
    1166
    +            error=error)
    
    1161 1167
     
    
    1162 1168
             if restored:
    
    1163 1169
                 return
    
    ... ... @@ -1301,7 +1307,7 @@ def _style_changed(self, *args):
    1301 1307
             self.update_tags()
    
    1302 1308
     
    
    1303 1309
         def update_tags(self):
    
    1304
    -        self.conv_textview.update_tags()
    
    1310
    +        self.conversation_view.update_text_tags()
    
    1305 1311
     
    
    1306 1312
         @staticmethod
    
    1307 1313
         def clear(tv):
    
    ... ... @@ -1372,7 +1378,7 @@ def set_control_active(self, state):
    1372 1378
             if state:
    
    1373 1379
                 self.set_emoticon_popover()
    
    1374 1380
                 jid = self.contact.jid
    
    1375
    -            if self.conv_textview.autoscroll:
    
    1381
    +            if self.conversation_view.autoscroll:
    
    1376 1382
                     # we are at the end
    
    1377 1383
                     type_ = [f'printed_{self._type}']
    
    1378 1384
                     if self._type.is_groupchat:
    
    ... ... @@ -1401,14 +1407,73 @@ def set_control_active(self, state):
    1401 1407
                     self.contact, Chatstate.INACTIVE)
    
    1402 1408
     
    
    1403 1409
         def scroll_to_end(self, force=False):
    
    1404
    -        self.conv_textview.scroll_to_end(force)
    
    1410
    +        self.conversation_view.scroll_to_end(force)
    
    1411
    +
    
    1412
    +    def _on_load_history(self, _button, lines):
    
    1413
    +        self.fetch_n_lines_history(lines)
    
    1414
    +
    
    1415
    +    def fetch_n_lines_history(self, n_lines):
    
    1416
    +        if self._fetching_history:
    
    1417
    +            return
    
    1418
    +
    
    1419
    +        timestamp_end = self.conversation_view.first_message_timestamp
    
    1420
    +        if not timestamp_end:
    
    1421
    +            timestamp_end = datetime.datetime.now()
    
    1422
    +        self._fetching_history = True
    
    1423
    +        adjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
    
    1424
    +
    
    1425
    +        # Store these values so we can restore scroll position later
    
    1426
    +        self._scrolled_old_upper = adjustment.get_upper()
    
    1427
    +        self._scrolled_old_value = adjustment.get_value()
    
    1428
    +
    
    1429
    +        if self.is_groupchat:
    
    1430
    +            messages = app.storage.archive.get_conversation_muc_before(
    
    1431
    +                self.account,
    
    1432
    +                self.contact.jid,
    
    1433
    +                timestamp_end,
    
    1434
    +                n_lines)
    
    1435
    +        else:
    
    1436
    +            messages = app.storage.archive.get_conversation_before(
    
    1437
    +                self.account,
    
    1438
    +                self.contact.jid,
    
    1439
    +                timestamp_end,
    
    1440
    +                n_lines)
    
    1441
    +
    
    1442
    +        for msg in messages:
    
    1443
    +            if not msg:
    
    1444
    +                continue
    
    1445
    +            kind = 'status'
    
    1446
    +            contact_name = msg.contact_name
    
    1447
    +            if msg.kind in (
    
    1448
    +                    KindConstant.SINGLE_MSG_RECV, KindConstant.CHAT_MSG_RECV):
    
    1449
    +                kind = 'incoming'
    
    1450
    +                contact_name = self.contact.name
    
    1451
    +            elif msg.kind == KindConstant.GC_MSG:
    
    1452
    +                kind = 'incoming'
    
    1453
    +            elif msg.kind in (
    
    1454
    +                    KindConstant.SINGLE_MSG_SENT, KindConstant.CHAT_MSG_SENT):
    
    1455
    +                kind = 'outgoing'
    
    1456
    +                contact_name = self.get_our_nick()
    
    1457
    +            if not msg.message:
    
    1458
    +                continue
    
    1459
    +            self.conversation_view.add_message(
    
    1460
    +                msg.message,
    
    1461
    +                kind,
    
    1462
    +                contact_name,
    
    1463
    +                msg.time,
    
    1464
    +                subject=msg.subject,
    
    1465
    +                additional_data=msg.additional_data,
    
    1466
    +                message_id=msg.message_id,
    
    1467
    +                marker=msg.marker,
    
    1468
    +                error=msg.error,
    
    1469
    +                history=True)
    
    1405 1470
     
    
    1406 1471
         def _on_edge_reached(self, _scrolledwindow, pos):
    
    1407 1472
             if pos != Gtk.PositionType.BOTTOM:
    
    1408 1473
                 return
    
    1409 1474
             # Remove all events and set autoscroll True
    
    1410 1475
             app.log('autoscroll').info('Autoscroll enabled')
    
    1411
    -        self.conv_textview.autoscroll = True
    
    1476
    +        self.conversation_view.autoscroll = True
    
    1412 1477
             if self.resource:
    
    1413 1478
                 jid = self.contact.get_full_jid()
    
    1414 1479
             else:
    
    ... ... @@ -1437,13 +1502,44 @@ def _on_edge_reached(self, _scrolledwindow, pos):
    1437 1502
                         self._type)
    
    1438 1503
                     self.last_msg_id = None
    
    1439 1504
     
    
    1505
    +    def _on_edge_overshot(self, _scrolledwindow, pos):
    
    1506
    +        # Fetch messages if we scroll against the top
    
    1507
    +        if pos != Gtk.PositionType.TOP:
    
    1508
    +            return
    
    1509
    +        self.fetch_n_lines_history(30)
    
    1510
    +
    
    1511
    +    def _on_scroll_value_changed(self, adjustment):
    
    1512
    +        if self.conversation_view.clearing or self._fetching_history:
    
    1513
    +            return
    
    1514
    +
    
    1515
    +        value = adjustment.get_value()
    
    1516
    +        previous_value = self._scrolled_previous_value
    
    1517
    +        self._scrolled_previous_value = value
    
    1518
    +
    
    1519
    +        if value >= previous_value:
    
    1520
    +            return
    
    1521
    +
    
    1522
    +        # Fetch messages before we reach the top
    
    1523
    +        if value <= int(0.1 * adjustment.get_upper()):
    
    1524
    +            self.fetch_n_lines_history(10)
    
    1525
    +
    
    1526
    +    def _on_scroll_realize(self, _widget):
    
    1527
    +        # Initial message fetching when ChatControl is first opened
    
    1528
    +        if not self._initial_fetch_done:
    
    1529
    +            self._initial_fetch_done = True
    
    1530
    +            restore_lines = app.settings.get('restore_lines')
    
    1531
    +            if restore_lines <= 0:
    
    1532
    +                return
    
    1533
    +            self.fetch_n_lines_history(restore_lines)
    
    1534
    +
    
    1440 1535
         def _on_scrollbar_button_release(self, scrollbar, event):
    
    1441 1536
             if event.get_button()[1] != 1:
    
    1442 1537
                 # We want only to catch the left mouse button
    
    1443 1538
                 return
    
    1539
    +
    
    1444 1540
             if not at_the_end(scrollbar.get_parent()):
    
    1445 1541
                 app.log('autoscroll').info('Autoscroll disabled')
    
    1446
    -            self.conv_textview.autoscroll = False
    
    1542
    +            self.conversation_view.autoscroll = False
    
    1447 1543
     
    
    1448 1544
         def has_focus(self):
    
    1449 1545
             if self.parent_win:
    
    ... ... @@ -1453,12 +1549,12 @@ def has_focus(self):
    1453 1549
             return False
    
    1454 1550
     
    
    1455 1551
         def _on_scroll(self, widget, event):
    
    1456
    -        if not self.conv_textview.autoscroll:
    
    1552
    +        if not self.conversation_view.autoscroll:
    
    1457 1553
                 # autoscroll is already disabled
    
    1458 1554
                 return
    
    1459 1555
     
    
    1460 1556
             if widget is None:
    
    1461
    -            # call from _conv_textview_key_press_event()
    
    1557
    +            # call from _on_conversation_view_key_press()
    
    1462 1558
                 # SHIFT + Gdk.KEY_Page_Up
    
    1463 1559
                 if event != Gdk.KEY_Page_Up:
    
    1464 1560
                     return
    
    ... ... @@ -1490,10 +1586,18 @@ def _on_scroll(self, widget, event):
    1490 1586
             adjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
    
    1491 1587
             if adjustment.get_upper() != adjustment.get_page_size():
    
    1492 1588
                 app.log('autoscroll').info('Autoscroll disabled')
    
    1493
    -            self.conv_textview.autoscroll = False
    
    1589
    +            self.conversation_view.autoscroll = False
    
    1494 1590
     
    
    1495
    -    def on_conversation_vadjustment_changed(self, _adjustment):
    
    1591
    +    def _on_conversation_vadjustment_changed(self, adjustment):
    
    1496 1592
             self.scroll_to_end()
    
    1593
    +        if self._fetching_history:
    
    1594
    +            # Make sure the scroll position is kept
    
    1595
    +            new_upper = adjustment.get_upper()
    
    1596
    +            diff = new_upper - self._scrolled_old_upper
    
    1597
    +            new_value = diff + self._scrolled_old_value
    
    1598
    +            adjustment.set_value(new_value)
    
    1599
    +            self._fetching_history = False
    
    1600
    +            return
    
    1497 1601
     
    
    1498 1602
         def scroll_messages(self, direction, msg_buf, msg_type):
    
    1499 1603
             if msg_type == 'sent':
    
    ... ... @@ -1550,7 +1654,7 @@ def got_connected(self):
    1550 1654
         def got_disconnected(self):
    
    1551 1655
             self.msg_textview.set_sensitive(False)
    
    1552 1656
             self.msg_textview.set_editable(False)
    
    1553
    -        self.conv_textview.tv.grab_focus()
    
    1657
    +        self.conversation_view.grab_focus()
    
    1554 1658
     
    
    1555 1659
             self.update_toolbar()
    
    1556 1660
     
    

  • gajim/command_system/implementation/middleware.py
    ... ... @@ -33,7 +33,7 @@
    33 33
     
    
    34 34
     from traceback import print_exc
    
    35 35
     
    
    36
    -from gi.repository import Pango
    
    36
    +# from gi.repository import Pango
    
    37 37
     
    
    38 38
     from gajim.common import app
    
    39 39
     from gajim.common.i18n import _
    
    ... ... @@ -117,29 +117,33 @@ def __init__(self):
    117 117
             self.install_tags()
    
    118 118
     
    
    119 119
         def install_tags(self):
    
    120
    -        buffer_ = self.conv_textview.tv.get_buffer()
    
    120
    +        # TODO implement this in ConversationView
    
    121
    +        # buffer_ = self.conv_textview.tv.get_buffer()
    
    121 122
     
    
    122
    -        name = "Monospace"
    
    123
    -        font = Pango.FontDescription(name)
    
    123
    +        # name = "Monospace"
    
    124
    +        # font = Pango.FontDescription(name)
    
    124 125
     
    
    125
    -        command_ok_tag = buffer_.create_tag("command_ok")
    
    126
    -        command_ok_tag.set_property("font-desc", font)
    
    127
    -        command_ok_tag.set_property("foreground", "#3465A4")
    
    126
    +        # command_ok_tag = buffer_.create_tag("command_ok")
    
    127
    +        # command_ok_tag.set_property("font-desc", font)
    
    128
    +        # command_ok_tag.set_property("foreground", "#3465A4")
    
    128 129
     
    
    129
    -        command_error_tag = buffer_.create_tag("command_error")
    
    130
    -        command_error_tag.set_property("font-desc", font)
    
    131
    -        command_error_tag.set_property("foreground", "#F57900")
    
    130
    +        # command_error_tag = buffer_.create_tag("command_error")
    
    131
    +        # command_error_tag.set_property("font-desc", font)
    
    132
    +        # command_error_tag.set_property("foreground", "#F57900")
    
    133
    +        return
    
    132 134
     
    
    133 135
         def shift_line(self):
    
    134
    -        buffer_ = self.conv_textview.tv.get_buffer()
    
    135
    -        iter_ = buffer_.get_end_iter()
    
    136
    -        if iter_.ends_line() and not iter_.is_start():
    
    137
    -            buffer_.insert_with_tags_by_name(iter_, "\n", "eol")
    
    136
    +        # buffer_ = self.conv_textview.tv.get_buffer()
    
    137
    +        # iter_ = buffer_.get_end_iter()
    
    138
    +        # if iter_.ends_line() and not iter_.is_start():
    
    139
    +        #     buffer_.insert_with_tags_by_name(iter_, "\n", "eol")
    
    140
    +        return
    
    138 141
     
    
    139 142
         def append_with_tags(self, text, *tags):
    
    140
    -        buffer_ = self.conv_textview.tv.get_buffer()
    
    141
    -        iter_ = buffer_.get_end_iter()
    
    142
    -        buffer_.insert_with_tags_by_name(iter_, text, *tags)
    
    143
    +        # buffer_ = self.conv_textview.tv.get_buffer()
    
    144
    +        # iter_ = buffer_.get_end_iter()
    
    145
    +        # buffer_.insert_with_tags_by_name(iter_, text, *tags)
    
    146
    +        return
    
    143 147
     
    
    144 148
         def echo(self, text, tag="command_ok"):
    
    145 149
             """
    

  • gajim/common/const.py
    ... ... @@ -1049,6 +1049,7 @@ def is_active(self):
    1049 1049
         'iris.xpcs:',
    
    1050 1050
         'iris.lwz:',
    
    1051 1051
         'ldap://',
    
    1052
    +    'mailto:',
    
    1052 1053
         'mid:',
    
    1053 1054
         'modem:',
    
    1054 1055
         'msrp://',
    
    ... ... @@ -1081,6 +1082,7 @@ def is_active(self):
    1081 1082
         'vemmi://',
    
    1082 1083
         'xmlrpc.beep://',
    
    1083 1084
         'xmlrpc.beeps://',
    
    1085
    +    'xmpp:',
    
    1084 1086
         'z39.50r://',
    
    1085 1087
         'z39.50s://',
    
    1086 1088
         'about:',
    
    ... ... @@ -1107,6 +1109,22 @@ def is_active(self):
    1107 1109
     }
    
    1108 1110
     
    
    1109 1111
     
    
    1112
    +TRUST_SYMBOL_DATA = {
    
    1113
    +    Trust.UNTRUSTED: ('dialog-error-symbolic',
    
    1114
    +                      _('Untrusted'),
    
    1115
    +                      'error-color'),
    
    1116
    +    Trust.UNDECIDED: ('security-low-symbolic',
    
    1117
    +                      _('Trust Not Decided'),
    
    1118
    +                      'warning-color'),
    
    1119
    +    Trust.BLIND: ('security-medium-symbolic',
    
    1120
    +                  _('Unverified'),
    
    1121
    +                  'encrypted-color'),
    
    1122
    +    Trust.VERIFIED: ('security-high-symbolic',
    
    1123
    +                     _('Verified'),
    
    1124
    +                     'encrypted-color')
    
    1125
    +}
    
    1126
    +
    
    1127
    +
    
    1110 1128
     THRESHOLD_OPTIONS = {
    
    1111 1129
         -1: _('No Sync'),
    
    1112 1130
         1: _('1 Day'),
    

  • gajim/common/storage/archive.py
    ... ... @@ -321,72 +321,72 @@ def convert_show_values_to_db_api_values(show):
    321 321
             return None
    
    322 322
     
    
    323 323
         @timeit
    
    324
    -    def load_groupchat_messages(self, account, jid):
    
    325
    -        account_id = self.get_account_id(account, type_=JIDConstant.ROOM_TYPE)
    
    324
    +    def get_conversation_before(self, account, jid, end_timestamp, n_lines):
    
    325
    +        """
    
    326
    +        Load n_lines lines of conversation with jid before end_timestamp
    
    326 327
     
    
    327
    -        sql = '''
    
    328
    -            SELECT time, contact_name, message, additional_data, message_id
    
    329
    -            FROM logs NATURAL JOIN jids WHERE jid = ?
    
    330
    -            AND account_id = ? AND kind = ?
    
    331
    -            ORDER BY time DESC, log_line_id DESC LIMIT ?'''
    
    328
    +        :param account:         The account
    
    332 329
     
    
    333
    -        messages = self._con.execute(
    
    334
    -            sql, (jid, account_id, KindConstant.GC_MSG, 50)).fetchall()
    
    330
    +        :param jid:             The jid for which we request the conversation
    
    335 331
     
    
    336
    -        messages.reverse()
    
    337
    -        return messages
    
    332
    +        :param end_timestamp:   end timestamp / datetime.datetime instance
    
    338 333
     
    
    339
    -    @timeit
    
    340
    -    def get_last_conversation_lines(self, account, jid, pending):
    
    334
    +        returns a list of namedtuples
    
    341 335
             """
    
    342
    -        Get recent messages
    
    343
    -
    
    344
    -        Pending messages are already in queue to be printed when the
    
    345
    -        ChatControl is opened, so we don’t want to request those messages.
    
    346
    -        How many messages are requested depends on the 'restore_lines'
    
    347
    -        config value. How far back in time messages are requested depends on
    
    348
    -        _get_timeout().
    
    349
    -
    
    350
    -        :param account: The account
    
    336
    +        jids = self._get_family_jids(account, jid)
    
    337
    +        account_id = self.get_account_id(account)
    
    351 338
     
    
    352
    -        :param jid:     The jid from which we request the conversation lines
    
    339
    +        sql = '''
    
    340
    +            SELECT contact_name, time, kind, show, message, subject,
    
    341
    +                   additional_data, log_line_id, message_id,
    
    342
    +                   error as "error [common_error]",
    
    343
    +                   marker as "marker [marker]"
    
    344
    +            FROM logs NATURAL JOIN jids WHERE jid IN ({jids})
    
    345
    +            AND account_id = {account_id}
    
    346
    +            AND time < ?
    
    347
    +            ORDER BY time DESC, log_line_id DESC
    
    348
    +            LIMIT ?
    
    349
    +            '''.format(jids=', '.join('?' * len(jids)),
    
    350
    +                       account_id=account_id)
    
    353 351
     
    
    354
    -        :param pending: How many messages are currently pending so we don’t
    
    355
    -                        request those messages
    
    352
    +        return self._con.execute(
    
    353
    +            sql,
    
    354
    +            tuple(jids) + (end_timestamp.timestamp(), n_lines)).fetchall()
    
    356 355
     
    
    357
    -        returns a list of namedtuples
    
    356
    +    @timeit
    
    357
    +    def get_conversation_muc_before(self, account, jid, end_timestamp,
    
    358
    +                                    n_lines):
    
    358 359
             """
    
    360
    +        Load n_lines lines of conversation with jid before end_timestamp
    
    361
    +
    
    362
    +        :param account:         The account
    
    359 363
     
    
    360
    -        restore = app.settings.get('restore_lines')
    
    361
    -        if restore <= 0:
    
    362
    -            return []
    
    364
    +        :param jid:             The jid for which we request the conversation
    
    363 365
     
    
    364
    -        kinds = map(str, [KindConstant.SINGLE_MSG_RECV,
    
    365
    -                          KindConstant.SINGLE_MSG_SENT,
    
    366
    -                          KindConstant.CHAT_MSG_RECV,
    
    367
    -                          KindConstant.CHAT_MSG_SENT,
    
    368
    -                          KindConstant.ERROR])
    
    366
    +        :param end_timestamp:   end timestamp / datetime.datetime instance
    
    369 367
     
    
    368
    +        returns a list of namedtuples
    
    369
    +        """
    
    370 370
             jids = self._get_family_jids(account, jid)
    
    371
    -        account_id = self.get_account_id(account)
    
    371
    +        # TODO: this does not load messages correctly when account_id is set
    
    372
    +        # account_id = self.get_account_id(account, type_=JIDConstant.ROOM_TYPE)
    
    372 373
     
    
    373 374
             sql = '''
    
    374
    -            SELECT time, kind, message, error as "error [common_error]",
    
    375
    -                   subject, additional_data, marker as "marker [marker]",
    
    376
    -                   message_id
    
    375
    +            SELECT contact_name, time, kind, show, message, subject,
    
    376
    +                   additional_data, log_line_id, message_id,
    
    377
    +                   error as "error [common_error]",
    
    378
    +                   marker as "marker [marker]"
    
    377 379
                 FROM logs NATURAL JOIN jids WHERE jid IN ({jids})
    
    378
    -            AND account_id = {account_id} AND kind IN ({kinds})
    
    379
    -            AND time > get_timeout()
    
    380
    -            ORDER BY time DESC, log_line_id DESC LIMIT ? OFFSET ?
    
    380
    +            AND kind = {kind}
    
    381
    +            AND time < ?
    
    382
    +            ORDER BY time DESC, log_line_id DESC
    
    383
    +            LIMIT ?
    
    381 384
                 '''.format(jids=', '.join('?' * len(jids)),
    
    382
    -                       account_id=account_id,
    
    383
    -                       kinds=', '.join(kinds))
    
    384
    -
    
    385
    -        messages = self._con.execute(
    
    386
    -            sql, tuple(jids) + (restore, pending)).fetchall()
    
    385
    +                       kind=KindConstant.GC_MSG)
    
    387 386
     
    
    388
    -        messages.reverse()
    
    389
    -        return messages
    
    387
    +        return self._con.execute(
    
    388
    +            sql,
    
    389
    +            tuple(jids) + (end_timestamp.timestamp(), n_lines)).fetchall()
    
    390 390
     
    
    391 391
         @timeit
    
    392 392
         def get_last_conversation_line(self, account, jid):
    

  • gajim/data/gui/chat_control.ui
    ... ... @@ -623,7 +623,9 @@
    623 623
                         <property name="can_focus">True</property>
    
    624 624
                         <property name="shadow_type">in</property>
    
    625 625
                         <property name="overlay_scrolling">False</property>
    
    626
    +                    <signal name="edge-overshot" handler="_on_edge_overshot" swapped="no"/>
    
    626 627
                         <signal name="edge-reached" handler="_on_edge_reached" swapped="no"/>
    
    628
    +                    <signal name="realize" handler="_on_scroll_realize" swapped="no"/>
    
    627 629
                         <signal name="scroll-event" handler="_on_scroll" swapped="no"/>
    
    628 630
                         <child>
    
    629 631
                           <placeholder/>
    

  • gajim/data/gui/groupchat_control.ui
    ... ... @@ -263,7 +263,9 @@
    263 263
                             <property name="can_focus">True</property>
    
    264 264
                             <property name="shadow_type">in</property>
    
    265 265
                             <property name="overlay_scrolling">False</property>
    
    266
    +                        <signal name="edge-overshot" handler="_on_edge_overshot" swapped="no"/>
    
    266 267
                             <signal name="edge-reached" handler="_on_edge_reached" swapped="no"/>
    
    268
    +                        <signal name="realize" handler="_on_scroll_realize" swapped="no"/>
    
    267 269
                             <signal name="scroll-event" handler="_on_scroll" swapped="no"/>
    
    268 270
                             <child>
    
    269 271
                               <placeholder/>
    

  • gajim/data/style/gajim-dark.css
    ... ... @@ -17,3 +17,14 @@ infobar.error > revealer > box {
    17 17
         color: #FCE5D5;
    
    18 18
         background-color: #D64937;
    
    19 19
     }
    
    20
    +
    
    21
    +/* ConversationView */
    
    22
    +.conversation-mention-highlight {
    
    23
    +    background-color: rgb(251, 151, 97);
    
    24
    +}
    
    25
    +.conversation-mention-highlight:hover {
    
    26
    +    background-color: rgb(251, 151, 97);
    
    27
    +}
    
    28
    +.conversation-mention-highlight grid textview text {
    
    29
    +	background-color: rgb(251, 151, 97);
    
    30
    +}

  • gajim/data/style/gajim.css
    ... ... @@ -31,6 +31,38 @@
    31 31
     
    
    32 32
     .chatcontrol-separator {margin-bottom: 5px;}
    
    33 33
     
    
    34
    +.conversation-row {
    
    35
    +    padding: 6px 3px 6px 6px;
    
    36
    +}
    
    37
    +.conversation-row grid textview {
    
    38
    +	background: transparent;
    
    39
    +}
    
    40
    +.conversation-row grid textview text {
    
    41
    +	background: transparent;
    
    42
    +}
    
    43
    +.conversation-mention-highlight {
    
    44
    +    background-color: rgb(255, 215, 194);
    
    45
    +}
    
    46
    +.conversation-system-row {
    
    47
    +    padding: 18px;
    
    48
    +}
    
    49
    +.conversation-date-row {
    
    50
    +    padding: 12px;
    
    51
    +}
    
    52
    +.conversation-meta {
    
    53
    +    color: @insensitive_fg_color;
    
    54
    +    font-size: small;
    
    55
    +}
    
    56
    +.conversation-read-marker {
    
    57
    +    color: @insensitive_fg_color;
    
    58
    +    font-size: small;
    
    59
    +    font-style: italic;
    
    60
    +}
    
    61
    +.conversation-nickname {
    
    62
    +    font-size: small;
    
    63
    +    font-weight: bold;
    
    64
    +}
    
    65
    +
    
    34 66
     .link-button { min-height: 0px; }
    
    35 67
     
    
    36 68
     /* Main Window */
    

  • gajim/groupchat_control.py
    ... ... @@ -211,7 +211,6 @@ def __init__(self, parent_win, jid, muc_data, acct):
    211 211
             # PluginSystem: adding GUI extension point for this GroupchatControl
    
    212 212
             # instance object
    
    213 213
             app.plugin_manager.gui_extension_point('groupchat_control', self)
    
    214
    -        self._restore_conversation()
    
    215 214
     
    
    216 215
         def _connect_contact_signals(self):
    
    217 216
             self.contact.multi_connect({
    
    ... ... @@ -249,6 +248,8 @@ def _on_muc_state_changed(self, _contact, _signal_name):
    249 248
     
    
    250 249
                 self.xml.formattings_button.set_sensitive(True)
    
    251 250
     
    
    251
    +            self.conversation_view.update_avatars()
    
    252
    +
    
    252 253
                 self.update_actions()
    
    253 254
     
    
    254 255
             elif state == MUCJoinedState.NOT_JOINED:
    
    ... ... @@ -416,25 +417,6 @@ def remove_actions(self):
    416 417
             for action in actions:
    
    417 418
                 self.parent_win.window.remove_action(f'{action}{self.control_id}')
    
    418 419
     
    
    419
    -    def _restore_conversation(self):
    
    420
    -        rows = app.storage.archive.load_groupchat_messages(
    
    421
    -            self.account, self.contact.jid)
    
    422
    -
    
    423
    -        for row in rows:
    
    424
    -            other_tags_for_name = ['muc_nickname_color_%s' % row.contact_name]
    
    425
    -            ChatControlBase.add_message(self,
    
    426
    -                                        row.message,
    
    427
    -                                        'incoming',
    
    428
    -                                        row.contact_name,
    
    429
    -                                        float(row.time),
    
    430
    -                                        other_tags_for_name=other_tags_for_name,
    
    431
    -                                        message_id=row.message_id,
    
    432
    -                                        restored=True,
    
    433
    -                                        additional_data=row.additional_data)
    
    434
    -
    
    435
    -        if rows:
    
    436
    -            self.conv_textview.print_empty_line()
    
    437
    -
    
    438 420
         def _is_subject_change_allowed(self):
    
    439 421
             contact = self.contact.get_resource(self.nick)
    
    440 422
             if contact.affiliation in (Affiliation.OWNER, Affiliation.ADMIN):
    
    ... ... @@ -787,7 +769,7 @@ def add_message(self, text, contact='', tim=None,
    787 769
     
    
    788 770
                 self._nick_completion.record_message(contact, highlight)
    
    789 771
     
    
    790
    -            self.check_focus_out_line()
    
    772
    +            # self.check_focus_out_line()
    
    791 773
     
    
    792 774
             ChatControlBase.add_message(self,
    
    793 775
                                         text,
    
    ... ... @@ -844,7 +826,7 @@ def check_focus_out_line(self):
    844 826
             if app.window.is_chat_active(self.account, self.room_jid):
    
    845 827
                 return
    
    846 828
     
    
    847
    -        self.conv_textview.show_focus_out_line()
    
    829
    +        # self.conv_textview.show_focus_out_line()
    
    848 830
     
    
    849 831
         def needs_visual_notification(self, text):
    
    850 832
             """
    
    ... ... @@ -1038,11 +1020,12 @@ def _on_user_nickname_changed(self, _contact, _signal_name, user_contact, proper
    1038 1020
     
    
    1039 1021
             self.add_info_message(message)
    
    1040 1022
     
    
    1041
    -        tv = self.conv_textview
    
    1042
    -        if nick in tv.last_received_message_id:
    
    1043
    -            tv.last_received_message_id[new_nick] = \
    
    1044
    -                tv.last_received_message_id[nick]
    
    1045
    -            del tv.last_received_message_id[nick]
    
    1023
    +        # TODO: What to do with this?
    
    1024
    +        # tv = self.conv_textview
    
    1025
    +        # if nick in tv.last_received_message_id:
    
    1026
    +        #     tv.last_received_message_id[new_nick] = \
    
    1027
    +        #         tv.last_received_message_id[nick]
    
    1028
    +        #     del tv.last_received_message_id[nick]
    
    1046 1029
     
    
    1047 1030
     
    
    1048 1031
         def _on_user_status_show_changed(self,
    
    ... ... @@ -1346,7 +1329,7 @@ def send_message(self, message, xhtml=None, process_commands=True):
    1346 1329
                 self.msg_textview.grab_focus()
    
    1347 1330
     
    
    1348 1331
         def _on_message_error(self, event):
    
    1349
    -        self.conv_textview.show_error(event.message_id, event.error)
    
    1332
    +        self.conversation_view.show_error(event.message_id, event.error)
    
    1350 1333
     
    
    1351 1334
         def shutdown(self, reason=None):
    
    1352 1335
             app.settings.disconnect_signals(self)
    
    ... ... @@ -1419,12 +1402,12 @@ def _close_control(self, reason=None):
    1419 1402
                 # self.parent_win.remove_tab(self, None, reason=reason, force=True)
    
    1420 1403
     
    
    1421 1404
         def set_control_active(self, state):
    
    1422
    -        self.conv_textview.allow_focus_out_line = True
    
    1405
    +        # self.conv_textview.allow_focus_out_line = True
    
    1423 1406
             self.attention_flag = False
    
    1424 1407
             ChatControlBase.set_control_active(self, state)
    
    1425
    -        if not state:
    
    1426
    -            # add the focus-out line to the tab we are leaving
    
    1427
    -            self.check_focus_out_line()
    
    1408
    +        # if not state:
    
    1409
    +        #    # add the focus-out line to the tab we are leaving
    
    1410
    +        #    self.check_focus_out_line()
    
    1428 1411
             # Sending active to undo unread state
    
    1429 1412
             self.parent_win.redraw_tab(self, 'active')
    
    1430 1413
     
    

  • gajim/gtk/conversation_textview.py
    1
    +# This file is part of Gajim.
    
    2
    +#
    
    3
    +# Gajim is free software; you can redistribute it and/or modify
    
    4
    +# it under the terms of the GNU General Public License as published
    
    5
    +# by the Free Software Foundation; version 3 only.
    
    6
    +#
    
    7
    +# Gajim is distributed in the hope that it will be useful,
    
    8
    +# but WITHOUT ANY WARRANTY; without even the implied warranty of
    
    9
    +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    
    10
    +# GNU General Public License for more details.
    
    11
    +#
    
    12
    +# You should have received a copy of the GNU General Public License
    
    13
    +# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
    
    14
    +
    
    15
    +import logging
    
    16
    +from urllib.parse import quote
    
    17
    +
    
    18
    +from gi.repository import GObject
    
    19
    +from gi.repository import Gtk
    
    20
    +from gi.repository import Pango
    
    21
    +
    
    22
    +from gajim.common import app
    
    23
    +from gajim.common import i18n
    
    24
    +from gajim.common.const import StyleAttr
    
    25
    +from gajim.common.const import URI_SCHEMES
    
    26
    +from gajim.common.helpers import AdditionalDataDict
    
    27
    +from gajim.common.helpers import open_uri
    
    28
    +from gajim.common.helpers import puny_encode_url
    
    29
    +from gajim.common.helpers import reduce_chars_newlines
    
    30
    +from gajim.common.i18n import _
    
    31
    +from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX
    
    32
    +from gajim.common.regex import BASIC_REGEX
    
    33
    +from gajim.common.regex import LINK_REGEX
    
    34
    +from gajim.common.regex import EMOT_AND_BASIC_REGEX
    
    35
    +from gajim.common.regex import EMOT_AND_LINK_REGEX
    
    36
    +
    
    37
    +from .htmltextview import HtmlTextView
    
    38
    +from .emoji_data import emoji_pixbufs
    
    39
    +from .emoji_data import is_emoji
    
    40
    +from .emoji_data import get_emoji_pixbuf
    
    41
    +from .util import get_cursor
    
    42
    +
    
    43
    +log = logging.getLogger('gajim.gui.conversation_view')
    
    44
    +
    
    45
    +
    
    46
    +class ConversationTextview(HtmlTextView):
    
    47
    +
    
    48
    +    __gsignals__ = {
    
    49
    +        'quote': (
    
    50
    +            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
    
    51
    +            None,
    
    52
    +            (str, )
    
    53
    +        ),
    
    54
    +    }
    
    55
    +
    
    56
    +    def __init__(self, account, history_mode=False):
    
    57
    +        HtmlTextView.__init__(self, account)
    
    58
    +        self.set_hexpand(True)
    
    59
    +        self.set_vexpand(True)
    
    60
    +        self.set_margin_start(0)
    
    61
    +        self.set_margin_end(0)
    
    62
    +        self.set_border_width(0)
    
    63
    +        self.set_left_margin(0)
    
    64
    +        self.set_right_margin(0)
    
    65
    +
    
    66
    +        self._history_mode = history_mode
    
    67
    +
    
    68
    +        self.handlers = {}
    
    69
    +
    
    70
    +        id_ = self.connect('query-tooltip', self._query_tooltip)
    
    71
    +        self.handlers[id_] = self
    
    72
    +        id_ = self.connect('button-press-event', self._on_button_press)
    
    73
    +        self.handlers[id_] = self
    
    74
    +        id_ = self.connect('populate-popup', self._on_populate_popup)
    
    75
    +        self.handlers[id_] = self
    
    76
    +
    
    77
    +        # Used for changing the mouse pointer when hovering clickable URIs
    
    78
    +        self._cursor_changed = False
    
    79
    +
    
    80
    +        # Keeps text selections for quoting and search actions
    
    81
    +        self._selected_text = ''
    
    82
    +
    
    83
    +        self.get_style_context().add_class('gajim-conversation-font')
    
    84
    +
    
    85
    +        buffer_ = self.get_buffer()
    
    86
    +
    
    87
    +        self.tag_in = buffer_.create_tag('incoming')
    
    88
    +        color = app.css_config.get_value(
    
    89
    +            '.gajim-incoming-nickname', StyleAttr.COLOR)
    
    90
    +        self.tag_in.set_property('foreground', color)
    
    91
    +        desc = app.css_config.get_font('.gajim-incoming-nickname')
    
    92
    +        self.tag_in.set_property('font-desc', desc)
    
    93
    +
    
    94
    +        self.tag_out = buffer_.create_tag('outgoing')
    
    95
    +        color = app.css_config.get_value(
    
    96
    +            '.gajim-outgoing-nickname', StyleAttr.COLOR)
    
    97
    +        self.tag_out.set_property('foreground', color)
    
    98
    +        desc = app.css_config.get_font('.gajim-outgoing-nickname')
    
    99
    +        self.tag_out.set_property('font-desc', desc)
    
    100
    +
    
    101
    +        self.tag_status = buffer_.create_tag('status')
    
    102
    +        color = app.css_config.get_value(
    
    103
    +            '.gajim-status-message', StyleAttr.COLOR)
    
    104
    +        self.tag_status.set_property('foreground', color)
    
    105
    +        desc = app.css_config.get_font('.gajim-status-message')
    
    106
    +        self.tag_status.set_property('font-desc', desc)
    
    107
    +
    
    108
    +        tag_in_text = buffer_.create_tag('incomingtxt')
    
    109
    +        color = app.css_config.get_value(
    
    110
    +            '.gajim-incoming-message-text', StyleAttr.COLOR)
    
    111
    +        if color:
    
    112
    +            tag_in_text.set_property('foreground', color)
    
    113
    +        desc = app.css_config.get_font('.gajim-incoming-message-text')
    
    114
    +        tag_in_text.set_property('font-desc', desc)
    
    115
    +
    
    116
    +        tag_out_text = buffer_.create_tag('outgoingtxt')
    
    117
    +        color = app.css_config.get_value(
    
    118
    +            '.gajim-outgoing-message-text', StyleAttr.COLOR)
    
    119
    +        if color:
    
    120
    +            tag_out_text.set_property('foreground', color)
    
    121
    +        desc = app.css_config.get_font('.gajim-outgoing-message-text')
    
    122
    +        tag_out_text.set_property('font-desc', desc)
    
    123
    +
    
    124
    +        self.tag_marked = buffer_.create_tag('marked')
    
    125
    +        color = app.css_config.get_value(
    
    126
    +            '.gajim-highlight-message', StyleAttr.COLOR)
    
    127
    +        self.tag_marked.set_property('foreground', color)
    
    128
    +        self.tag_marked.set_property('weight', Pango.Weight.BOLD)
    
    129
    +
    
    130
    +        tag = buffer_.create_tag('small')
    
    131
    +        tag.set_property('scale', 0.8333333333333)
    
    132
    +
    
    133
    +        tag = buffer_.create_tag('bold')
    
    134
    +        tag.set_property('weight', Pango.Weight.BOLD)
    
    135
    +
    
    136
    +        tag = buffer_.create_tag('italic')
    
    137
    +        tag.set_property('style', Pango.Style.ITALIC)
    
    138
    +
    
    139
    +        tag = buffer_.create_tag('strikethrough')
    
    140
    +        tag.set_property('strikethrough', True)
    
    141
    +
    
    142
    +        self.create_tags()
    
    143
    +
    
    144
    +        self.connect('destroy', self._on_destroy)
    
    145
    +
    
    146
    +    def _on_destroy(self, *args):
    
    147
    +        super()._on_destroy()
    
    148
    +        for i in self.handlers:
    
    149
    +            if self.handlers[i].handler_is_connected(i):
    
    150
    +                self.handlers[i].disconnect(i)
    
    151
    +        self.handlers.clear()
    
    152
    +
    
    153
    +    def update_text_tags(self):
    
    154
    +        self.tag_in.set_property('foreground', app.css_config.get_value(
    
    155
    +            '.gajim-incoming-nickname', StyleAttr.COLOR))
    
    156
    +        self.tag_out.set_property('foreground', app.css_config.get_value(
    
    157
    +            '.gajim-outgoing-nickname', StyleAttr.COLOR))
    
    158
    +        self.tag_status.set_property('foreground', app.css_config.get_value(
    
    159
    +            '.gajim-status-message', StyleAttr.COLOR))
    
    160
    +        self.tag_marked.set_property('foreground', app.css_config.get_value(
    
    161
    +            '.gajim-highlight-message', StyleAttr.COLOR))
    
    162
    +        self.update_tags()
    
    163
    +
    
    164
    +    def clear(self):
    
    165
    +        buffer_ = self.get_buffer()
    
    166
    +        start, end = buffer_.get_bounds()
    
    167
    +        buffer_.delete(start, end)
    
    168
    +
    
    169
    +    def get_text(self):
    
    170
    +        buffer_ = self.get_buffer()
    
    171
    +        start, end = buffer_.get_bounds()
    
    172
    +        return buffer_.get_text(start, end, False)
    
    173
    +
    
    174
    +    def print_text(self, text, other_text_tags=None, kind=None, graphics=True,
    
    175
    +                   name=None, additional_data=None):
    
    176
    +        if additional_data is None:
    
    177
    +            additional_data = AdditionalDataDict()
    
    178
    +
    
    179
    +        # Print XHTML if present
    
    180
    +        xhtml = additional_data.get_value('gajim', 'xhtml', False)
    
    181
    +        if xhtml and app.settings.get('show_xhtml'):
    
    182
    +            try:
    
    183
    +                if (name and (text.startswith('/me ') or
    
    184
    +                              text.startswith('/me\n'))):
    
    185
    +                    xhtml = xhtml.replace('/me', '<i>* %s</i>' % (name,), 1)
    
    186
    +                self.display_html(
    
    187
    +                    xhtml, self, self)
    
    188
    +                return
    
    189
    +            except Exception as err:
    
    190
    +                log.debug('Error processing xhtml: %s', err)
    
    191
    +                log.debug('with |%s|', xhtml)
    
    192
    +
    
    193
    +        text_tags = []
    
    194
    +        if other_text_tags:
    
    195
    +            text_tags = other_text_tags[:]  # create a new list
    
    196
    +
    
    197
    +        if (kind == 'status' or
    
    198
    +                text.startswith('/me') or text.startswith('/me\n')):
    
    199
    +            text_tags.append(kind)
    
    200
    +
    
    201
    +        if name and (text.startswith('/me ') or text.startswith('/me\n')):
    
    202
    +            text = '* ' + name + text[3:]
    
    203
    +            text_tags.append('italic')
    
    204
    +
    
    205
    +        if kind in ('incoming', 'incoming_queue'):
    
    206
    +            text_tags.append('incomingtxt')
    
    207
    +        elif kind == 'outgoing':
    
    208
    +            text_tags.append('outgoingtxt')
    
    209
    +
    
    210
    +        self.parse_formatting(
    
    211
    +            text, text_tags, graphics=graphics, additional_data=additional_data)
    
    212
    +
    
    213
    +    def parse_formatting(self, text, text_tags, graphics=True,
    
    214
    +                         additional_data=None):
    
    215
    +        '''
    
    216
    +        Parses message formatting (Emojis, URIs, Styles).
    
    217
    +        A regex is used for text matching. Each text fragment gets
    
    218
    +        passed to apply_formatting(), where respective TextTags are added.
    
    219
    +        Unformatted text (no match) will be passed through unaltered.
    
    220
    +        '''
    
    221
    +        if not text:
    
    222
    +            return
    
    223
    +
    
    224
    +        if text_tags is None:
    
    225
    +            text_tags = []
    
    226
    +
    
    227
    +        buffer_ = self.get_buffer()
    
    228
    +
    
    229
    +        if text_tags:
    
    230
    +            insert_tags_func = buffer_.insert_with_tags_by_name
    
    231
    +        else:
    
    232
    +            insert_tags_func = buffer_.insert
    
    233
    +
    
    234
    +        # TODO: Adapt HtmlHandler.handle_specials()
    
    235
    +        # detect_and_print_special_text() is used by
    
    236
    +        # HtmlHandler.handle_specials() and uses Gtk.TextTag objects,
    
    237
    +        # not strings
    
    238
    +        if text_tags and isinstance(text_tags[0], Gtk.TextTag):
    
    239
    +            insert_tags_func = buffer_.insert_with_tags
    
    240
    +
    
    241
    +        # TODO: Plugin system GUI extension point
    
    242
    +        # self.plugin_modified = False
    
    243
    +        # app.plugin_manager.extension_point('print_real_text', self,
    
    244
    +        #    text, text_tags, graphics, additional_data)
    
    245
    +        # if self.plugin_modified:
    
    246
    +        #    return
    
    247
    +
    
    248
    +        # Add XEP-0066 Out of Band text to the end
    
    249
    +        oob_url = additional_data.get_value('gajim', 'oob_url')
    
    250
    +        if oob_url is not None:
    
    251
    +            oob_desc = additional_data.get_value('gajim', 'oob_desc', 'URL:')
    
    252
    +            if oob_url != text:
    
    253
    +                text += f'\n{oob_desc} {oob_url}'
    
    254
    +
    
    255
    +        if app.settings.get('emoticons_theme') and graphics:
    
    256
    +            # Match for Emojis & URIs
    
    257
    +            if app.settings.get('ascii_formatting'):
    
    258
    +                regex = EMOT_AND_BASIC_REGEX
    
    259
    +            else:
    
    260
    +                regex = EMOT_AND_LINK_REGEX
    
    261
    +        else:
    
    262
    +            if app.settings.get('ascii_formatting'):
    
    263
    +                # Match for URIs + mail + formatting
    
    264
    +                regex = BASIC_REGEX
    
    265
    +            else:
    
    266
    +                # Match only for URIs + formatting
    
    267
    +                regex = LINK_REGEX
    
    268
    +
    
    269
    +        iterator = regex.finditer(text)
    
    270
    +        end_iter = buffer_.get_end_iter()
    
    271
    +        # TODO: Evaluate limit
    
    272
    +        # Too many fragments (emoticons, LaTeX formulas, etc)
    
    273
    +        # may cause Gajim to freeze (see #5129).
    
    274
    +        # We impose an arbitrary limit of 100 fragments per message.
    
    275
    +        fragment_limit = 100
    
    276
    +        index = 0
    
    277
    +        for match in iterator:
    
    278
    +            start, end = match.span()
    
    279
    +            fragment = text[start:end]
    
    280
    +            if start > index:
    
    281
    +                text_before_fragment = text[index:start]
    
    282
    +                end_iter = buffer_.get_end_iter()
    
    283
    +                if text_tags:
    
    284
    +                    insert_tags_func(
    
    285
    +                        end_iter, text_before_fragment, *text_tags)
    
    286
    +                else:
    
    287
    +                    buffer_.insert(end_iter, text_before_fragment)
    
    288
    +            index = end
    
    289
    +
    
    290
    +            self.apply_formatting(fragment,
    
    291
    +                                  text_tags,
    
    292
    +                                  graphics=graphics,
    
    293
    +                                  additional_data=additional_data)
    
    294
    +            fragment_limit += 1
    
    295
    +            if fragment_limit <= 0:
    
    296
    +                break
    
    297
    +
    
    298
    +        # Add remaining text after last match
    
    299
    +        insert_tags_func(buffer_.get_end_iter(), text[index:], *text_tags)
    
    300
    +
    
    301
    +    def apply_formatting(self, fragment, text_tags, graphics=True,
    
    302
    +                         additional_data=None):
    
    303
    +        # TODO: Plugin system GUI extension point
    
    304
    +        # self.plugin_modified = False
    
    305
    +        # app.plugin_manager.extension_point('print_special_text', self,
    
    306
    +        #    fragment, text_tags, graphics, additional_data)
    
    307
    +        # if self.plugin_modified:
    
    308
    +        #     return
    
    309
    +
    
    310
    +        tags = []
    
    311
    +        buffer_ = self.get_buffer()
    
    312
    +        ttt = buffer_.get_tag_table()
    
    313
    +
    
    314
    +        # Detect XHTML-IM link
    
    315
    +        is_xhtml_uri = False
    
    316
    +        tags_ = [
    
    317
    +            (ttt.lookup(t) if isinstance(t, str) else t) for t in text_tags]
    
    318
    +        for tag in tags_:
    
    319
    +            is_xhtml_uri = getattr(tag, 'href', False)
    
    320
    +            if is_xhtml_uri:
    
    321
    +                break
    
    322
    +
    
    323
    +        # Check if we accept this as an uri
    
    324
    +        is_valid_uri = fragment.startswith(tuple(URI_SCHEMES))
    
    325
    +
    
    326
    +        end_iter = buffer_.get_end_iter()
    
    327
    +
    
    328
    +        theme = app.settings.get('emoticons_theme')
    
    329
    +        show_emojis = theme and theme != 'font'
    
    330
    +
    
    331
    +        # XEP-0393 Message Styling
    
    332
    +        # * = bold
    
    333
    +        # _ = italic
    
    334
    +        # ~ = strikethrough
    
    335
    +        show_formatting_chars = app.settings.get(
    
    336
    +            'show_ascii_formatting_chars')
    
    337
    +
    
    338
    +        if show_emojis and graphics and is_emoji(fragment):
    
    339
    +            # it's an emoticon
    
    340
    +            if emoji_pixbufs.complete:
    
    341
    +                # only search for the pixbuf if we are sure
    
    342
    +                # that loading is completed
    
    343
    +                pixbuf = get_emoji_pixbuf(fragment)
    
    344
    +                if pixbuf is None:
    
    345
    +                    buffer_.insert(end_iter, fragment)
    
    346
    +                else:
    
    347
    +                    pixbuf = pixbuf.copy()
    
    348
    +                    anchor = buffer_.create_child_anchor(end_iter)
    
    349
    +                    anchor.plaintext = fragment
    
    350
    +                    img = Gtk.Image.new_from_pixbuf(pixbuf)
    
    351
    +                    img.show()
    
    352
    +                    self.add_child_at_anchor(img, anchor)
    
    353
    +            else:
    
    354
    +                # Set marks and save them so we can replace emojis
    
    355
    +                # once the loading is complete
    
    356
    +                start_mark = buffer_.create_mark(None, end_iter, True)
    
    357
    +                buffer_.insert(end_iter, fragment)
    
    358
    +                end_mark = buffer_.create_mark(None, end_iter, True)
    
    359
    +                emoji_pixbufs.append_marks(
    
    360
    +                    self, start_mark, end_mark, fragment)
    
    361
    +        elif (fragment.startswith('www.') or
    
    362
    +                fragment.startswith('ftp.') or
    
    363
    +                is_valid_uri and not is_xhtml_uri):
    
    364
    +            tags.append('url')
    
    365
    +        elif fragment.startswith('mailto:') and not is_xhtml_uri:
    
    366
    +            tags.append('mail')
    
    367
    +        elif fragment.startswith('xmpp:') and not is_xhtml_uri:
    
    368
    +            tags.append('xmpp')
    
    369
    +        elif STH_AT_STH_DOT_STH_REGEX.match(fragment) and not is_xhtml_uri:
    
    370
    +            # JID or E-Mail
    
    371
    +            tags.append('sth_at_sth')
    
    372
    +        elif fragment.startswith('*'):
    
    373
    +            tags.append('bold')
    
    374
    +            if (fragment[1] == '~' and fragment[-2] == '~' and
    
    375
    +                    len(fragment) > 4):
    
    376
    +                tags.append('strikethrough')
    
    377
    +                if not show_formatting_chars:
    
    378
    +                    fragment = fragment[2:-2]
    
    379
    +            elif (fragment[1] == '_' and fragment[-2] == '_' and
    
    380
    +                    len(fragment) > 4):
    
    381
    +                tags.append('italic')
    
    382
    +                if not show_formatting_chars:
    
    383
    +                    fragment = fragment[2:-2]
    
    384
    +            else:
    
    385
    +                if not show_formatting_chars:
    
    386
    +                    fragment = fragment[1:-1]
    
    387
    +        elif fragment.startswith('~'):
    
    388
    +            tags.append('strikethrough')
    
    389
    +            if (fragment[1] == '*' and fragment[-2] == '*' and
    
    390
    +                    len(fragment) > 4):
    
    391
    +                tags.append('bold')
    
    392
    +                if not show_formatting_chars:
    
    393
    +                    fragment = fragment[2:-2]
    
    394
    +            elif (fragment[1] == '_' and fragment[-2] == '_' and
    
    395
    +                    len(fragment) > 4):
    
    396
    +                tags.append('italic')
    
    397
    +                if not show_formatting_chars:
    
    398
    +                    fragment = fragment[2:-2]
    
    399
    +            else:
    
    400
    +                if not show_formatting_chars:
    
    401
    +                    fragment = fragment[1:-1]
    
    402
    +        elif fragment.startswith('_'):
    
    403
    +            tags.append('italic')
    
    404
    +            if (fragment[1] == '*' and fragment[-2] == '*' and
    
    405
    +                    len(fragment) > 4):
    
    406
    +                tags.append('bold')
    
    407
    +                if not show_formatting_chars:
    
    408
    +                    fragment = fragment[2:-2]
    
    409
    +            elif (fragment[1] == '~' and fragment[-2] == '~' and
    
    410
    +                    len(fragment) > 4):
    
    411
    +                tags.append('strikethrough')
    
    412
    +                if not show_formatting_chars:
    
    413
    +                    fragment = fragment[2:-2]
    
    414
    +            else:
    
    415
    +                if not show_formatting_chars:
    
    416
    +                    fragment = fragment[1:-1]
    
    417
    +        else:
    
    418
    +            insert_tags_func = buffer_.insert_with_tags_by_name
    
    419
    +            if text_tags and isinstance(text_tags[0], Gtk.TextTag):
    
    420
    +                insert_tags_func = buffer_.insert_with_tags
    
    421
    +            if text_tags:
    
    422
    +                insert_tags_func(end_iter, fragment, *text_tags)
    
    423
    +            else:
    
    424
    +                buffer_.insert(end_iter, fragment)
    
    425
    +
    
    426
    +        if tags:
    
    427
    +            all_tags = tags[:]
    
    428
    +            all_tags += text_tags
    
    429
    +            # convert all names to TextTag
    
    430
    +            all_tags = [
    
    431
    +                (ttt.lookup(t) if isinstance(t, str) else t) for t in all_tags]
    
    432
    +            buffer_.insert_with_tags(end_iter, fragment, *all_tags)
    
    433
    +            if 'url' in tags:
    
    434
    +                puny_text = puny_encode_url(fragment)
    
    435
    +                if puny_text != fragment:
    
    436
    +                    puny_tags = []
    
    437
    +                    if not puny_text:
    
    438
    +                        puny_text = _('Invalid URL')
    
    439
    +                    puny_tags = [(ttt.lookup(t) if isinstance(
    
    440
    +                        t, str) else t) for t in puny_tags]
    
    441
    +                    puny_tags += text_tags
    
    442
    +                    buffer_.insert_with_tags(
    
    443
    +                        end_iter, " (%s)" % puny_text, *puny_tags)
    
    444
    +
    
    445
    +    def _query_tooltip(self, widget, x_pos, y_pos, _keyboard_mode, tooltip):
    
    446
    +        window = widget.get_window(Gtk.TextWindowType.TEXT)
    
    447
    +        x_pos, y_pos = self.window_to_buffer_coords(
    
    448
    +            Gtk.TextWindowType.TEXT, x_pos, y_pos)
    
    449
    +
    
    450
    +        iter_ = self.get_iter_at_position(x_pos, y_pos)[1]
    
    451
    +        for tag in iter_.get_tags():
    
    452
    +            tag_name = tag.get_property('name')
    
    453
    +            if getattr(tag, 'is_anchor', False):
    
    454
    +                text = getattr(tag, 'title', False)
    
    455
    +                if text:
    
    456
    +                    if len(text) > 50:
    
    457
    +                        text = reduce_chars_newlines(text, 47, 1)
    
    458
    +                    tooltip.set_text(text)
    
    459
    +                    window.set_cursor(get_cursor('pointer'))
    
    460
    +                    self._cursor_changed = True
    
    461
    +                    return True
    
    462
    +            if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
    
    463
    +                window.set_cursor(get_cursor('pointer'))
    
    464
    +                self._cursor_changed = True
    
    465
    +                return False
    
    466
    +
    
    467
    +        if self._cursor_changed:
    
    468
    +            window.set_cursor(get_cursor('text'))
    
    469
    +            self._cursor_changed = False
    
    470
    +        return False
    
    471
    +
    
    472
    +    def _on_button_press(self, _widget, event):
    
    473
    +        '''
    
    474
    +        We don’t open the standard context menu when receiving
    
    475
    +        a click on tagged text.
    
    476
    +        If it’s untagged text, check if something is selected
    
    477
    +        '''
    
    478
    +        self._selected_text = ''
    
    479
    +
    
    480
    +        if event.button != 3:
    
    481
    +            # If it’s not a right click
    
    482
    +            return False
    
    483
    +
    
    484
    +        x_pos, y_pos = self.window_to_buffer_coords(
    
    485
    +            Gtk.TextWindowType.TEXT,
    
    486
    +            int(event.x),
    
    487
    +            int(event.y))
    
    488
    +        iter_ = self.get_iter_at_location(x_pos, y_pos)
    
    489
    +        if isinstance(iter_, tuple):
    
    490
    +            iter_ = iter_[1]
    
    491
    +        tags = iter_.get_tags()
    
    492
    +
    
    493
    +        if tags:
    
    494
    +            # A tagged text fragment has been clicked
    
    495
    +            for tag in tags:
    
    496
    +                tag_name = tag.get_property('name')
    
    497
    +                if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
    
    498
    +                    # Block regular context menu
    
    499
    +                    return True
    
    500
    +
    
    501
    +        # Check if there is a selection and make it available for
    
    502
    +        # _on_populate_popup
    
    503
    +        buffer_ = self.get_buffer()
    
    504
    +        return_val = buffer_.get_selection_bounds()
    
    505
    +        if return_val:
    
    506
    +            # Something has been selected, get the text
    
    507
    +            start_sel, finish_sel = return_val[0], return_val[1]
    
    508
    +            self._selected_text = buffer_.get_text(
    
    509
    +                start_sel, finish_sel, True)
    
    510
    +        elif iter_.get_char() and ord(iter_.get_char()) > 31:
    
    511
    +            # Clicked on a word, take whole word for selection
    
    512
    +            start_sel = iter_.copy()
    
    513
    +            if not start_sel.starts_word():
    
    514
    +                start_sel.backward_word_start()
    
    515
    +            finish_sel = iter_.copy()
    
    516
    +            if not finish_sel.ends_word():
    
    517
    +                finish_sel.forward_word_end()
    
    518
    +            self._selected_text = buffer_.get_text(
    
    519
    +                start_sel, finish_sel, True)
    
    520
    +        return False
    
    521
    +
    
    522
    +    def _on_populate_popup(self, _textview, menu):
    
    523
    +        '''
    
    524
    +        Overrides the default context menu.
    
    525
    +        If text is selected, a submenu with actions on the selection is added.
    
    526
    +        (see _on_button_press)
    
    527
    +        '''
    
    528
    +        if not self._selected_text:
    
    529
    +            menu.show_all()
    
    530
    +            return
    
    531
    +
    
    532
    +        if not self._history_mode:
    
    533
    +            item = Gtk.MenuItem.new_with_mnemonic(_('_Quote'))
    
    534
    +            id_ = item.connect('activate', self._on_quote)
    
    535
    +            self.handlers[id_] = item
    
    536
    +            menu.prepend(item)
    
    537
    +
    
    538
    +        selected_text_short = reduce_chars_newlines(
    
    539
    +            self._selected_text, 25, 2)
    
    540
    +        item = Gtk.MenuItem.new_with_mnemonic(
    
    541
    +            _('_Actions for "%s"') % selected_text_short)
    
    542
    +        menu.prepend(item)
    
    543
    +        submenu = Gtk.Menu()
    
    544
    +        item.set_submenu(submenu)
    
    545
    +        uri_text = quote(self._selected_text.encode('utf-8'))
    
    546
    +
    
    547
    +        if app.settings.get('always_english_wikipedia'):
    
    548
    +            uri = (f'https://en.wikipedia.org/wiki/'
    
    549
    +                   f'Special:Search?search={uri_text}')
    
    550
    +        else:
    
    551
    +            uri = (f'https://{i18n.get_short_lang_code()}.wikipedia.org/'
    
    552
    +                   f'wiki/Special:Search?search={uri_text}')
    
    553
    +        item = Gtk.MenuItem.new_with_mnemonic(_('Read _Wikipedia Article'))
    
    554
    +        id_ = item.connect('activate', self._visit_uri, uri)
    
    555
    +        self.handlers[id_] = item
    
    556
    +        submenu.append(item)
    
    557
    +
    
    558
    +        item = Gtk.MenuItem.new_with_mnemonic(
    
    559
    +            _('Look it up in _Dictionary'))
    
    560
    +        dict_link = app.settings.get('dictionary_url')
    
    561
    +        if dict_link == 'WIKTIONARY':
    
    562
    +            # Default is wikitionary.org
    
    563
    +            if app.settings.get('always_english_wiktionary'):
    
    564
    +                uri = (f'https://en.wiktionary.org/wiki/'
    
    565
    +                       f'Special:Search?search={uri_text}')
    
    566
    +            else:
    
    567
    +                uri = (f'https://{i18n.get_short_lang_code()}.wiktionary.org/'
    
    568
    +                       f'wiki/Special:Search?search={uri_text}')
    
    569
    +            id_ = item.connect('activate', self._visit_uri, uri)
    
    570
    +            self.handlers[id_] = item
    
    571
    +        else:
    
    572
    +            if dict_link.find('%s') == -1:
    
    573
    +                # There has to be a '%s' in the url if it’s not WIKTIONARY
    
    574
    +                item = Gtk.MenuItem.new_with_label(
    
    575
    +                    _('Dictionary URL is missing a "%s"'))
    
    576
    +                item.set_sensitive(False)
    
    577
    +            else:
    
    578
    +                uri = dict_link % uri_text
    
    579
    +                id_ = item.connect('activate', self._visit_uri, uri)
    
    580
    +                self.handlers[id_] = item
    
    581
    +        submenu.append(item)
    
    582
    +
    
    583
    +        search_link = app.settings.get('search_engine')
    
    584
    +        if search_link.find('%s') == -1:
    
    585
    +            # There has to be a '%s' in the url
    
    586
    +            item = Gtk.MenuItem.new_with_label(
    
    587
    +                _('Web Search URL is missing a "%s"'))
    
    588
    +            item.set_sensitive(False)
    
    589
    +        else:
    
    590
    +            item = Gtk.MenuItem.new_with_mnemonic(_('Web _Search for it'))
    
    591
    +            uri = search_link % uri_text
    
    592
    +            id_ = item.connect('activate', self._visit_uri, uri)
    
    593
    +            self.handlers[id_] = item
    
    594
    +        submenu.append(item)
    
    595
    +
    
    596
    +        item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link'))
    
    597
    +        id_ = item.connect('activate', self._visit_uri, uri)
    
    598
    +        self.handlers[id_] = item
    
    599
    +        submenu.append(item)
    
    600
    +
    
    601
    +        menu.show_all()
    
    602
    +
    
    603
    +    def _on_quote(self, _widget):
    
    604
    +        self.emit('quote', self._selected_text)
    
    605
    +
    
    606
    +    @staticmethod
    
    607
    +    def _visit_uri(_widget, uri):
    
    608
    +        open_uri(uri)

  • gajim/gtk/conversation_view.py
    1
    +# This file is part of Gajim.
    
    2
    +#
    
    3
    +# Gajim is free software; you can redistribute it and/or modify
    
    4
    +# it under the terms of the GNU General Public License as published
    
    5
    +# by the Free Software Foundation; version 3 only.
    
    6
    +#
    
    7
    +# Gajim is distributed in the hope that it will be useful,
    
    8
    +# but WITHOUT ANY WARRANTY; without even the implied warranty of
    
    9
    +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    
    10
    +# GNU General Public License for more details.
    
    11
    +#
    
    12
    +# You should have received a copy of the GNU General Public License
    
    13
    +# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
    
    14
    +
    
    15
    +import logging
    
    16
    +import time
    
    17
    +
    
    18
    +from bisect import bisect_left
    
    19
    +from bisect import bisect_right
    
    20
    +from collections import deque
    
    21
    +from datetime import datetime
    
    22
    +from datetime import timedelta
    
    23
    +
    
    24
    +from gi.repository import Gdk
    
    25
    +from gi.repository import GLib
    
    26
    +from gi.repository import GObject
    
    27
    +from gi.repository import Gtk
    
    28
    +from gi.repository import Pango
    
    29
    +
    
    30
    +from gajim.common import app
    
    31
    +from gajim.common.const import AvatarSize
    
    32
    +from gajim.common.const import TRUST_SYMBOL_DATA
    
    33
    +from gajim.common.helpers import from_one_line
    
    34
    +from gajim.common.helpers import reduce_chars_newlines
    
    35
    +from gajim.common.helpers import to_user_string
    
    36
    +from gajim.common.i18n import _
    
    37
    +from gajim.common.i18n import Q_
    
    38
    +
    
    39
    +from .conversation_textview import ConversationTextview
    
    40
    +from .util import convert_rgba_to_hex
    
    41
    +from .util import format_fingerprint
    
    42
    +from .util import scroll_to_end
    
    43
    +from .util import text_to_color
    
    44
    +
    
    45
    +log = logging.getLogger('gajim.gui.conversation_view')
    
    46
    +
    
    47
    +
    
    48
    +class ConversationView(Gtk.ListBox):
    
    49
    +
    
    50
    +    __gsignals__ = {
    
    51
    +        'quote': (
    
    52
    +            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
    
    53
    +            None,
    
    54
    +            (str, )
    
    55
    +        ),
    
    56
    +        'load-history': (
    
    57
    +            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
    
    58
    +            None,
    
    59
    +            (str, )
    
    60
    +        )
    
    61
    +    }
    
    62
    +
    
    63
    +    def __init__(self, account, contact, history_mode=False):
    
    64
    +        Gtk.ListBox.__init__(self)
    
    65
    +        self.set_selection_mode(Gtk.SelectionMode.NONE)
    
    66
    +        self._account = account
    
    67
    +        self._client = app.get_client(account)
    
    68
    +        self._contact = contact
    
    69
    +        self._history_mode = history_mode
    
    70
    +
    
    71
    +        self.encryption_enabled = False
    
    72
    +        self.autoscroll = True
    
    73
    +        self.clearing = False
    
    74
    +
    
    75
    +        # Both first and last DateRow (datetime)
    
    76
    +        self._first_date = None
    
    77
    +        self._last_date = None
    
    78
    +
    
    79
    +        # Keeps track of the number of rows shown in ConversationView
    
    80
    +        self._row_count = 0
    
    81
    +        self._max_row_count = 100
    
    82
    +
    
    83
    +        # Keeps inserted message IDs to avoid re-inserting the same message
    
    84
    +        self._message_ids_inserted = {}
    
    85
    +
    
    86
    +        # We keep a sorted array of all the timestamps we've inserted, which
    
    87
    +        # have a 1-to-1 mapping to the actual child elements of this ListBox
    
    88
    +        # (*all* rows are included).
    
    89
    +        # Binary-searching this array enables us to insert a message with a
    
    90
    +        # discontinuous timestamp (not less than or greater than the first or
    
    91
    +        # last message timestamp in the list), and insert it at the correct
    
    92
    +        # place in the ListBox.
    
    93
    +        self._timestamps_inserted = deque()
    
    94
    +
    
    95
    +        # Stores the timestamp of the first *chat* row inserted
    
    96
    +        # (not a system row or date row)
    
    97
    +        self.first_message_timestamp = None
    
    98
    +
    
    99
    +        # Last incoming chat message timestamp (used for ReadMarkerRows)
    
    100
    +        self._last_incoming_timestamp = datetime.fromtimestamp(0)
    
    101
    +
    
    102
    +        # Insert the very first row, containing the scroll hint and load button
    
    103
    +        self.add(ScrollHintRow(self._account))
    
    104
    +        self._timestamps_inserted.append(datetime.fromtimestamp(0))
    
    105
    +
    
    106
    +    def clear(self):
    
    107
    +        self.clearing = True
    
    108
    +        for row in self.get_children()[1:]:
    
    109
    +            self.remove(row)
    
    110
    +
    
    111
    +        GLib.idle_add(self._reset_conversation_view)
    
    112
    +
    
    113
    +    def _reset_conversation_view(self):
    
    114
    +        self._first_date = None
    
    115
    +        self._last_date = None
    
    116
    +        self._message_ids_inserted.clear()
    
    117
    +        self.first_message_timestamp = None
    
    118
    +        self._last_incoming_timestamp = datetime.fromtimestamp(0)
    
    119
    +        self._timestamps_inserted.clear()
    
    120
    +        self._row_count = 0
    
    121
    +        self.clearing = False
    
    122
    +
    
    123
    +    def add_message(self,
    
    124
    +                    text,
    
    125
    +                    kind,
    
    126
    +                    name,
    
    127
    +                    timestamp,
    
    128
    +                    other_text_tags=None,
    
    129
    +                    message_id=None,
    
    130
    +                    correct_id=None,
    
    131
    +                    display_marking=None,
    
    132
    +                    additional_data=None,
    
    133
    +                    subject=None,
    
    134
    +                    marker=None,
    
    135
    +                    error=None,
    
    136
    +                    history=False,
    
    137
    +                    graphics=True):
    
    138
    +
    
    139
    +        log.debug(
    
    140
    +            'Adding message: %s, %s, %s, %s, message_id: %s, correct_id: %s, '
    
    141
    +            'other_text_tags: %s, display_marking: %s, additional_data: %s, '
    
    142
    +            'subject: %s, marker: %s, error: %s, history: %s, graphics: %s',
    
    143
    +            text, kind, name, timestamp, message_id, correct_id,
    
    144
    +            other_text_tags, display_marking, additional_data, subject,
    
    145
    +            marker, error, history, graphics)
    
    146
    +
    
    147
    +        if message_id:
    
    148
    +            if message_id in self._message_ids_inserted:
    
    149
    +                log.warning('Rejecting insertion of duplicate message_id %s',
    
    150
    +                            str(message_id))
    
    151
    +                return
    
    152
    +            self._message_ids_inserted[message_id] = True
    
    153
    +
    
    154
    +        if not timestamp:
    
    155
    +            timestamp = time.time()
    
    156
    +        time_ = datetime.fromtimestamp(timestamp)
    
    157
    +
    
    158
    +        if other_text_tags is None:
    
    159
    +            other_text_tags = []
    
    160
    +
    
    161
    +        if kind in ('status', 'info') or subject:
    
    162
    +            message = InfoMessageRow(
    
    163
    +                self._account,
    
    164
    +                time_,
    
    165
    +                text,
    
    166
    +                other_text_tags,
    
    167
    +                kind,
    
    168
    +                subject,
    
    169
    +                graphics,
    
    170
    +                history_mode=self._history_mode)
    
    171
    +        else:
    
    172
    +            if correct_id:
    
    173
    +                self.correct_message(
    
    174
    +                    correct_id,
    
    175
    +                    message_id,
    
    176
    +                    text,
    
    177
    +                    other_text_tags,
    
    178
    +                    kind,
    
    179
    +                    name,
    
    180
    +                    additional_data=additional_data)
    
    181
    +                return
    
    182
    +
    
    183
    +            avatar = self._get_avatar(kind, name)
    
    184
    +            message = TextMessageRow(
    
    185
    +                self._account,
    
    186
    +                message_id,
    
    187
    +                time_,
    
    188
    +                kind,
    
    189
    +                name,
    
    190
    +                text,
    
    191
    +                other_text_tags,
    
    192
    +                avatar,
    
    193
    +                self._contact.is_groupchat,
    
    194
    +                additional_data=additional_data,
    
    195
    +                display_marking=display_marking,
    
    196
    +                marker=marker,
    
    197
    +                error=error,
    
    198
    +                encryption_enabled=self.encryption_enabled,
    
    199
    +                history_mode=self._history_mode)
    
    200
    +
    
    201
    +        self._insert_message(message, time_, kind, history)
    
    202
    +
    
    203
    +        # Check for maximum message count
    
    204
    +        if self.autoscroll and self._row_count > self._max_row_count:
    
    205
    +            self._reduce_message_count()
    
    206
    +
    
    207
    +    def _get_avatar(self, kind, name):
    
    208
    +        scale = self.get_scale_factor()
    
    209
    +        if self._contact.is_groupchat:
    
    210
    +            contact = self._contact.get_resource(name)
    
    211
    +            return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False)
    
    212
    +
    
    213
    +        if kind == 'outgoing':
    
    214
    +            contact = self._client.get_module('Contacts').get_contact(
    
    215
    +                str(self._client.get_own_jid().bare))
    
    216
    +        else:
    
    217
    +            contact = self._contact
    
    218
    +
    
    219
    +        return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False)
    
    220
    +
    
    221
    +    def _insert_message(self, message, time_, kind, history):
    
    222
    +        current_date = time_.strftime('%a, %d %b %Y')
    
    223
    +
    
    224
    +        if self._is_out_of_order(time_, history):
    
    225
    +            insertion_point = bisect_left(self._timestamps_inserted, time_)
    
    226
    +            date_check_point = min(len(
    
    227
    +                self._timestamps_inserted) - 1, insertion_point - 1)
    
    228
    +            date_at_dcp = self._timestamps_inserted[date_check_point].strftime(
    
    229
    +                '%a, %d %b %Y')
    
    230
    +            if date_at_dcp != current_date:
    
    231
    +                associated_timestamp = time_ - timedelta(
    
    232
    +                    hours=time_.hour,
    
    233
    +                    minutes=time_.minute,
    
    234
    +                    seconds=time_.second)
    
    235
    +                date_row = DateRow(
    
    236
    +                    self._account, current_date, associated_timestamp)
    
    237
    +                self.insert(date_row, insertion_point)
    
    238
    +                self._timestamps_inserted.insert(
    
    239
    +                    insertion_point, associated_timestamp)
    
    240
    +                self._row_count += 1
    
    241
    +                insertion_point += 1
    
    242
    +            if (kind in ('incoming', 'incoming_queue') and
    
    243
    +                    time_ > self._last_incoming_timestamp):
    
    244
    +                self._last_incoming_timestamp = time_
    
    245
    +            self.insert(message, insertion_point)
    
    246
    +            self._timestamps_inserted.insert(insertion_point, time_)
    
    247
    +            current_timestamp = time_
    
    248
    +            self._row_count += 1
    
    249
    +        elif history:
    
    250
    +            if current_date != self._first_date:
    
    251
    +                associated_timestamp = time_ - timedelta(
    
    252
    +                    hours=time_.hour,
    
    253
    +                    minutes=time_.minute,
    
    254
    +                    seconds=time_.second)
    
    255
    +                date_row = DateRow(
    
    256
    +                    self._account, current_date, associated_timestamp)
    
    257
    +                self.insert(date_row, 1)
    
    258
    +                self._timestamps_inserted.insert(1, associated_timestamp)
    
    259
    +                self._row_count += 1
    
    260
    +            self._first_date = current_date
    
    261
    +            if kind in ('incoming', 'incoming_queue', 'outgoing'):
    
    262
    +                self.first_message_timestamp = time_
    
    263
    +            if (kind in ('incoming', 'incoming_queue') and
    
    264
    +                    time_ > self._last_incoming_timestamp):
    
    265
    +                self._last_incoming_timestamp = time_
    
    266
    +            self.insert(message, 2)
    
    267
    +            self._timestamps_inserted.insert(2, time_)
    
    268
    +            if self._last_date is None:
    
    269
    +                self._last_date = current_date
    
    270
    +            current_timestamp = time_
    
    271
    +            self._row_count += 1
    
    272
    +        else:
    
    273
    +            if current_date != self._last_date:
    
    274
    +                associated_timestamp = time_ - timedelta(
    
    275
    +                    hours=time_.hour,
    
    276
    +                    minutes=time_.minute,
    
    277
    +                    seconds=time_.second)
    
    278
    +                date_row = DateRow(
    
    279
    +                    self._account, current_date, associated_timestamp)
    
    280
    +                self.add(date_row)
    
    281
    +                self._timestamps_inserted.append(associated_timestamp)
    
    282
    +                self._row_count += 1
    
    283
    +            if self._first_date is None:
    
    284
    +                self._first_date = current_date
    
    285
    +            if (kind in ('incoming', 'incoming_queue', 'outgoing') and not
    
    286
    +                    self.first_message_timestamp):
    
    287
    +                self.first_message_timestamp = time_
    
    288
    +            if (kind in ('incoming', 'incoming_queue') and
    
    289
    +                    time_ > self._last_incoming_timestamp):
    
    290
    +                self._last_incoming_timestamp = time_
    
    291
    +            self._last_date = current_date
    
    292
    +            self.add(message)
    
    293
    +            self._timestamps_inserted.append(time_)
    
    294
    +            current_timestamp = time_
    
    295
    +            self._row_count += 1
    
    296
    +
    
    297
    +        if message.type == 'chat':
    
    298
    +            self._merge_message(current_timestamp)
    
    299
    +            self._update_read_marker(current_timestamp)
    
    300
    +
    
    301
    +        self.show_all()
    
    302
    +
    
    303
    +    def _is_out_of_order(self, time_: datetime, history: bool) -> bool:
    
    304
    +        if history:
    
    305
    +            if self.first_message_timestamp:
    
    306
    +                return time_ > self.first_message_timestamp
    
    307
    +            return False
    
    308
    +        if len(self._timestamps_inserted) > 1:
    
    309
    +            return time_ < self._timestamps_inserted[-1]
    
    310
    +        return False
    
    311
    +
    
    312
    +    def _merge_message(self, timestamp):
    
    313
    +        # 'Merge' message rows if they both meet certain conditions
    
    314
    +        # (see _is_mergeable). A merged message row does not display any
    
    315
    +        # avatar or meta info, and makes it look merged with the previous row.
    
    316
    +        if self._contact.is_groupchat:
    
    317
    +            return
    
    318
    +
    
    319
    +        current_index = self._timestamps_inserted.index(timestamp)
    
    320
    +        previous_row = self.get_row_at_index(current_index - 1)
    
    321
    +        current_row = self.get_row_at_index(current_index)
    
    322
    +        next_row = self.get_row_at_index(current_index + 1)
    
    323
    +        if next_row is not None:
    
    324
    +            if self._is_mergeable(current_row, next_row):
    
    325
    +                next_row.set_merged(True)
    
    326
    +
    
    327
    +        if self._is_mergeable(current_row, previous_row):
    
    328
    +            current_row.set_merged(True)
    
    329
    +            if next_row is not None:
    
    330
    +                if self._is_mergeable(current_row, next_row):
    
    331
    +                    next_row.set_merged(True)
    
    332
    +
    
    333
    +    @staticmethod
    
    334
    +    def _is_mergeable(row1, row2):
    
    335
    +        # TODO: Check for same encryption
    
    336
    +        timestamp1 = row1.timestamp.strftime('%H:%M')
    
    337
    +        timestamp2 = row2.timestamp.strftime('%H:%M')
    
    338
    +        kind1 = row1.kind
    
    339
    +        kind2 = row2.kind
    
    340
    +        if timestamp1 == timestamp2 and kind1 == kind2:
    
    341
    +            return True
    
    342
    +        return False
    
    343
    +
    
    344
    +    def _reduce_message_count(self):
    
    345
    +        while self._row_count > self._max_row_count:
    
    346
    +            # We want to keep relevant DateRows when removing rows
    
    347
    +            row1 = self.get_row_at_index(1)
    
    348
    +            row2 = self.get_row_at_index(2)
    
    349
    +
    
    350
    +            if row1.type == row2.type == 'date':
    
    351
    +                # First two rows are date rows,
    
    352
    +                # it’s safe to remove the fist row
    
    353
    +                self.remove(row1)
    
    354
    +                self._timestamps_inserted.remove(row1.timestamp)
    
    355
    +                self._first_date = row2.timestamp.strftime('%a, %d %b %Y')
    
    356
    +                self._row_count -= 1
    
    357
    +                continue
    
    358
    +
    
    359
    +            if row1.type == 'date' and row2.type != 'date':
    
    360
    +                # First one is a date row, keep it and
    
    361
    +                # remove the second row instead
    
    362
    +                self.remove(row2)
    
    363
    +                self._timestamps_inserted.remove(row2.timestamp)
    
    364
    +                if row2.message_id:
    
    365
    +                    self._message_ids_inserted.pop(row2.message_id)
    
    366
    +                chat_row = self._get_first_chat_row()
    
    367
    +                if chat_row is not None:
    
    368
    +                    self.first_message_timestamp = chat_row.timestamp
    
    369
    +                else:
    
    370
    +                    self.first_message_timestamp = None
    
    371
    +                self._row_count -= 1
    
    372
    +                continue
    
    373
    +
    
    374
    +            if row1.type != 'date':
    
    375
    +                # Not a date row, safe to remove
    
    376
    +                self.remove(row1)
    
    377
    +                self._timestamps_inserted.remove(row1.timestamp)
    
    378
    +                if row1.message_id:
    
    379
    +                    self._message_ids_inserted.pop(row1.message_id)
    
    380
    +                if row2.type == 'chat':
    
    381
    +                    self.first_message_timestamp = row2.timestamp
    
    382
    +                else:
    
    383
    +                    chat_row = self._get_first_chat_row()
    
    384
    +                    if chat_row is not None:
    
    385
    +                        self.first_message_timestamp = chat_row.timestamp
    
    386
    +                    else:
    
    387
    +                        self.first_message_timestamp = None
    
    388
    +                self._row_count -= 1
    
    389
    +
    
    390
    +    def _get_row_by_id(self, id_):
    
    391
    +        for row in self.get_children():
    
    392
    +            if row.message_id == id_:
    
    393
    +                return row
    
    394
    +        return None
    
    395
    +
    
    396
    +    def _get_first_chat_row(self):
    
    397
    +        for row in self.get_children():
    
    398
    +            if row.type == 'chat':
    
    399
    +                return row
    
    400
    +        return None
    
    401
    +
    
    402
    +    def set_read_marker(self, id_):
    
    403
    +        message_row = self._get_row_by_id(id_)
    
    404
    +        if message_row is None:
    
    405
    +            return
    
    406
    +
    
    407
    +        message_row.set_displayed()
    
    408
    +        self._update_read_marker(message_row.timestamp)
    
    409
    +
    
    410
    +    def _update_read_marker(self, current_timestamp):
    
    411
    +        marker_shown = False
    
    412
    +
    
    413
    +        for row in self.get_children():
    
    414
    +            if row.type == 'read_marker':
    
    415
    +                # We already have a ReadMarkerRow, decide if we keep it
    
    416
    +                marker_shown = True
    
    417
    +
    
    418
    +                if self._last_incoming_timestamp > row.timestamp:
    
    419
    +                    # Last incoming message is newer than read marker
    
    420
    +                    self.remove(row)
    
    421
    +                    self._timestamps_inserted.remove(row.timestamp)
    
    422
    +                    marker_shown = False
    
    423
    +                    break
    
    424
    +
    
    425
    +                if self._last_incoming_timestamp > current_timestamp:
    
    426
    +                    # Last incoming message is newer than current message
    
    427
    +                    break
    
    428
    +
    
    429
    +                if current_timestamp > row.timestamp:
    
    430
    +                    # Message is newer than current ReadMarkerRow
    
    431
    +                    current_row = self.get_row_at_index(
    
    432
    +                        self._timestamps_inserted.index(current_timestamp))
    
    433
    +                    if current_row.has_displayed:
    
    434
    +                        # Current row has a displayed marker, which means
    
    435
    +                        # that the current ReadMarkerRow is out of date
    
    436
    +                        self.remove(row)
    
    437
    +                        self._timestamps_inserted.remove(row.timestamp)
    
    438
    +                        marker_shown = False
    
    439
    +                        break
    
    440
    +                break
    
    441
    +
    
    442
    +        if self._last_incoming_timestamp >= current_timestamp:
    
    443
    +            # Don’t add ReadMarkerRow if last incoming message is newer
    
    444
    +            return
    
    445
    +
    
    446
    +        if marker_shown:
    
    447
    +            # There is a ReadMarkerRow which has not been removed by previous
    
    448
    +            # rules, thus it’s the most current one (nothing more to do)
    
    449
    +            return
    
    450
    +
    
    451
    +        current_row = self.get_row_at_index(
    
    452
    +            self._timestamps_inserted.index(current_timestamp))
    
    453
    +        if current_row.type != 'chat':
    
    454
    +            return
    
    455
    +
    
    456
    +        if current_row.has_displayed:
    
    457
    +            # Add a new ReadMarkerRow, if there is a marker for the current row
    
    458
    +            self._insert_read_marker(current_timestamp)
    
    459
    +
    
    460
    +    def _insert_read_marker(self, timestamp):
    
    461
    +        insertion_point = bisect_right(
    
    462
    +            self._timestamps_inserted, timestamp)
    
    463
    +        read_marker_row = ReadMarkerRow(
    
    464
    +            self._account, self._contact, timestamp)
    
    465
    +        self.insert(read_marker_row, insertion_point)
    
    466
    +        self._timestamps_inserted.insert(insertion_point, timestamp)
    
    467
    +
    
    468
    +    def update_avatars(self):
    
    469
    +        for row in self.get_children():
    
    470
    +            if row.type == 'chat':
    
    471
    +                avatar = self._get_avatar(row.kind, row.name)
    
    472
    +                row.update_avatar(avatar)
    
    473
    +
    
    474
    +    def update_text_tags(self):
    
    475
    +        for row in self.get_children():
    
    476
    +            row.update_text_tags()
    
    477
    +
    
    478
    +    def scroll_to_end(self, force=False):
    
    479
    +        if self.autoscroll or force:
    
    480
    +            GLib.idle_add(scroll_to_end, self.get_parent().get_parent())
    
    481
    +
    
    482
    +    def correct_message(self, correct_id, message_id, text,
    
    483
    +                        other_text_tags, kind, name, additional_data=None):
    
    484
    +        message_row = self._get_row_by_id(correct_id)
    
    485
    +        if message_row is not None:
    
    486
    +            message_row.set_correction(
    
    487
    +                message_id, text, other_text_tags, kind, name,
    
    488
    +                additional_data=additional_data)
    
    489
    +            message_row.set_merged(False)
    
    490
    +
    
    491
    +    def show_receipt(self, id_):
    
    492
    +        message_row = self._get_row_by_id(id_)
    
    493
    +        if message_row is not None:
    
    494
    +            message_row.set_receipt()
    
    495
    +
    
    496
    +    def show_error(self, id_, error):
    
    497
    +        message_row = self._get_row_by_id(id_)
    
    498
    +        if message_row is not None:
    
    499
    +            message_row.set_error(to_user_string(error))
    
    500
    +            message_row.set_merged(False)
    
    501
    +
    
    502
    +    def on_quote(self, text):
    
    503
    +        self.emit('quote', text)
    
    504
    +
    
    505
    +    # TODO: focus_out_line for group chats
    
    506
    +
    
    507
    +
    
    508
    +class ConversationRow(Gtk.ListBoxRow):
    
    509
    +    def __init__(self, account, widget='label', history_mode=False):
    
    510
    +        Gtk.ListBoxRow.__init__(self)
    
    511
    +        self.type = ''
    
    512
    +        self.timestamp = None
    
    513
    +        self.kind = None
    
    514
    +        self.name = None
    
    515
    +        self.message_id = None
    
    516
    +        self.text = ''
    
    517
    +
    
    518
    +        self.get_style_context().add_class('conversation-row')
    
    519
    +
    
    520
    +        self.grid = Gtk.Grid(row_spacing=3, column_spacing=12)
    
    521
    +        self.add(self.grid)
    
    522
    +
    
    523
    +        if widget == 'textview':
    
    524
    +            self.label = None
    
    525
    +            self.textview = ConversationTextview(
    
    526
    +                account, history_mode=history_mode)
    
    527
    +        else:
    
    528
    +            self.textview = None
    
    529
    +            self.label = Gtk.Label()
    
    530
    +            self.label.set_selectable(True)
    
    531
    +            self.label.set_line_wrap(True)
    
    532
    +            self.label.set_xalign(0)
    
    533
    +            self.label.set_line_wrap_mode(
    
    534
    +                Pango.WrapMode.WORD_CHAR)
    
    535
    +
    
    536
    +    def update_text_tags(self):
    
    537
    +        if self.textview is not None:
    
    538
    +            self.textview.update_text_tags()
    
    539
    +
    
    540
    +    @staticmethod
    
    541
    +    def create_timestamp_widget(timestamp: datetime) -> Gtk.Label:
    
    542
    +        # TODO: maybe change default to '%H:%M'
    
    543
    +        time_format = from_one_line(app.settings.get('time_stamp'))
    
    544
    +        timestamp_formatted = timestamp.strftime(time_format)
    
    545
    +        label = Gtk.Label(label=timestamp_formatted)
    
    546
    +        label.set_halign(Gtk.Align.START)
    
    547
    +        label.set_valign(Gtk.Align.END)
    
    548
    +        label.get_style_context().add_class('conversation-meta')
    
    549
    +        label.set_tooltip_text(timestamp.strftime('%a, %d %b %Y - %H:%M:%S'))
    
    550
    +        return label
    
    551
    +
    
    552
    +    @staticmethod
    
    553
    +    def create_name_widget(name: str, kind: str,
    
    554
    +                           is_groupchat: bool) -> Gtk.Label:
    
    555
    +        label = Gtk.Label()
    
    556
    +        label.set_selectable(True)
    
    557
    +        label.get_style_context().add_class('conversation-nickname')
    
    558
    +
    
    559
    +        # TODO: Maybe set default for 'after_nickname' to empty string
    
    560
    +        before_name = from_one_line(app.settings.get('before_nickname'))
    
    561
    +        after_name = from_one_line(app.settings.get('after_nickname'))
    
    562
    +        new_name = f'{before_name}{GLib.markup_escape_text(name)}{after_name}'
    
    563
    +
    
    564
    +        if is_groupchat:
    
    565
    +            rgba = Gdk.RGBA(*text_to_color(name))
    
    566
    +            nick_color = convert_rgba_to_hex(rgba)
    
    567
    +            label.set_markup(
    
    568
    +                f'<span foreground="{nick_color}">{new_name}</span>')
    
    569
    +        else:
    
    570
    +            if kind in ('incoming', 'incoming_queue'):
    
    571
    +                label.get_style_context().add_class(
    
    572
    +                    'gajim-incoming-nickname')
    
    573
    +            elif kind == 'outgoing':
    
    574
    +                label.get_style_context().add_class(
    
    575
    +                    'gajim-outgoing-nickname')
    
    576
    +            label.set_markup(new_name)
    
    577
    +        return label
    
    578
    +
    
    579
    +
    
    580
    +class ScrollHintRow(ConversationRow):
    
    581
    +    def __init__(self, account):
    
    582
    +        ConversationRow.__init__(self, account)
    
    583
    +        self.type = 'system'
    
    584
    +        self.timestamp = datetime.fromtimestamp(0)
    
    585
    +        self.get_style_context().add_class('conversation-system-row')
    
    586
    +
    
    587
    +        self._button = Gtk.Button.new_from_icon_name(
    
    588
    +            'go-up-symbolic', Gtk.IconSize.BUTTON)
    
    589
    +        self._button.set_tooltip_text(_('Load more messages'))
    
    590
    +        self._button.connect('clicked', self._on_load_history)
    
    591
    +        self.grid.attach(self._button, 0, 0, 1, 1)
    
    592
    +
    
    593
    +        self.label.set_text(_('Scroll up to load more chat history…'))
    
    594
    +        self.label.set_halign(Gtk.Align.CENTER)
    
    595
    +        self.label.set_hexpand(True)
    
    596
    +        self.label.get_style_context().add_class(
    
    597
    +            'conversation-meta')
    
    598
    +        self.grid.attach(self.label, 0, 1, 1, 1)
    
    599
    +
    
    600
    +    def _on_load_history(self, _button):
    
    601
    +        self.get_parent().emit('load-history', 30)
    
    602
    +
    
    603
    +
    
    604
    +class ReadMarkerRow(ConversationRow):
    
    605
    +    def __init__(self, account, contact, timestamp):
    
    606
    +        ConversationRow.__init__(self, account)
    
    607
    +        self.type = 'read_marker'
    
    608
    +        self.timestamp = timestamp
    
    609
    +
    
    610
    +        text = _('%s has read up to this point') % contact.name
    
    611
    +        self.label.set_text(text)
    
    612
    +        self.label.set_halign(Gtk.Align.CENTER)
    
    613
    +        self.label.set_hexpand(True)
    
    614
    +        self.label.get_style_context().add_class(
    
    615
    +            'conversation-read-marker')
    
    616
    +        self.grid.attach(self.label, 0, 0, 1, 1)
    
    617
    +        self.show_all()
    
    618
    +
    
    619
    +
    
    620
    +class DateRow(ConversationRow):
    
    621
    +    def __init__(self, account, date_string, timestamp):
    
    622
    +        ConversationRow.__init__(self, account)
    
    623
    +        self.type = 'date'
    
    624
    +        self.timestamp = timestamp
    
    625
    +        self.get_style_context().add_class('conversation-date-row')
    
    626
    +
    
    627
    +        self.label.set_text(date_string)
    
    628
    +        self.label.set_halign(Gtk.Align.CENTER)
    
    629
    +        self.label.set_hexpand(True)
    
    630
    +        self.label.get_style_context().add_class(
    
    631
    +            'conversation-meta')
    
    632
    +        self.grid.attach(self.label, 0, 0, 1, 1)
    
    633
    +
    
    634
    +
    
    635
    +class InfoMessageRow(ConversationRow):
    
    636
    +    def __init__(self,
    
    637
    +                 account,
    
    638
    +                 timestamp,
    
    639
    +                 text,
    
    640
    +                 other_text_tags,
    
    641
    +                 kind,
    
    642
    +                 subject,
    
    643
    +                 graphics,
    
    644
    +                 history_mode=False):
    
    645
    +        ConversationRow.__init__(self, account, widget='textview',
    
    646
    +                                 history_mode=history_mode)
    
    647
    +        self.type = 'info'
    
    648
    +        self.timestamp = timestamp
    
    649
    +        self.kind = kind
    
    650
    +
    
    651
    +        if subject:
    
    652
    +            subject_title = _('Subject:')
    
    653
    +            text = (f'{subject_title}\n'
    
    654
    +                    f'{GLib.markup_escape_text(subject)}\n'
    
    655
    +                    f'{GLib.markup_escape_text(text)}')
    
    656
    +        else:
    
    657
    +            text = GLib.markup_escape_text(text)
    
    658
    +
    
    659
    +        other_text_tags.append('status')
    
    660
    +
    
    661
    +        avatar_placeholder = Gtk.Box()
    
    662
    +        avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1)
    
    663
    +        self.grid.attach(avatar_placeholder, 0, 0, 1, 2)
    
    664
    +        timestamp_widget = self.create_timestamp_widget(timestamp)
    
    665
    +        timestamp_widget.set_valign(Gtk.Align.START)
    
    666
    +        self.grid.attach(timestamp_widget, 2, 0, 1, 1)
    
    667
    +
    
    668
    +        self.textview.set_justification(Gtk.Justification.CENTER)
    
    669
    +        self.textview.print_text(
    
    670
    +            text,
    
    671
    +            other_text_tags=other_text_tags,
    
    672
    +            kind=kind,
    
    673
    +            graphics=graphics)
    
    674
    +
    
    675
    +        self.grid.attach(self.textview, 1, 0, 1, 1)
    
    676
    +
    
    677
    +
    
    678
    +class TextMessageRow(ConversationRow):
    
    679
    +    def __init__(self,
    
    680
    +                 account,
    
    681
    +                 message_id,
    
    682
    +                 timestamp,
    
    683
    +                 kind,
    
    684
    +                 name,
    
    685
    +                 text,
    
    686
    +                 other_text_tags,
    
    687
    +                 avatar,
    
    688
    +                 is_groupchat,
    
    689
    +                 additional_data=None,
    
    690
    +                 display_marking=None,
    
    691
    +                 marker=None,
    
    692
    +                 error=None,
    
    693
    +                 encryption_enabled=False,
    
    694
    +                 history_mode=False):
    
    695
    +
    
    696
    +        # other_tags_for_name contained 'marked', 'bold' and
    
    697
    +        # 'muc_nickname_color_', which are now derived from
    
    698
    +        # other_text_tags ('marked')
    
    699
    +
    
    700
    +        # other_tags_for_time were always empty?
    
    701
    +
    
    702
    +        ConversationRow.__init__(self, account, widget='textview',
    
    703
    +                                 history_mode=history_mode)
    
    704
    +        self.type = 'chat'
    
    705
    +        self.timestamp = timestamp
    
    706
    +        self.message_id = message_id
    
    707
    +        self.kind = kind
    
    708
    +        self.name = name or ''
    
    709
    +        self.text = text
    
    710
    +
    
    711
    +        self._corrections = []
    
    712
    +        self._has_receipt = marker == 'received'
    
    713
    +        self._has_displayed = marker == 'displayed'
    
    714
    +        self._button_icon = None
    
    715
    +
    
    716
    +        if is_groupchat:
    
    717
    +            if other_text_tags and 'marked' in other_text_tags:
    
    718
    +                self.get_style_context().add_class(
    
    719
    +                    'conversation-mention-highlight')
    
    720
    +
    
    721
    +        self.textview.connect('quote', self._on_quote_selection)
    
    722
    +        self.textview.print_text(
    
    723
    +            text,
    
    724
    +            other_text_tags=other_text_tags,
    
    725
    +            kind=kind,
    
    726
    +            name=name,
    
    727
    +            additional_data=additional_data)
    
    728
    +
    
    729
    +        self._meta_box = Gtk.Box(spacing=6)
    
    730
    +        self._meta_box.pack_start(
    
    731
    +            self.create_name_widget(name, kind, is_groupchat), False, True, 0)
    
    732
    +        timestamp_label = self.create_timestamp_widget(timestamp)
    
    733
    +        timestamp_label.set_margin_start(6)
    
    734
    +        self._meta_box.pack_end(timestamp_label, False, True, 0)
    
    735
    +        # TODO: implement app.settings.get('print_time') 'always', 'sometimes'?
    
    736
    +
    
    737
    +        if kind in ('incoming', 'incoming_queue', 'outgoing'):
    
    738
    +            encryption_img = self._get_encryption_image(
    
    739
    +                additional_data, encryption_enabled)
    
    740
    +            if encryption_img:
    
    741
    +                self._meta_box.pack_end(encryption_img, False, True, 0)
    
    742
    +
    
    743
    +        if display_marking:
    
    744
    +            label_text = GLib.markup_escape_text(display_marking.name)
    
    745
    +            if label_text:
    
    746
    +                bgcolor = display_marking.bgcolor
    
    747
    +                fgcolor = display_marking.fgcolor
    
    748
    +                label_text = (
    
    749
    +                    f'<span size="small" bgcolor="{bgcolor}" '
    
    750
    +                    f'fgcolor="{fgcolor}"><tt>[{label_text}]</tt></span>')
    
    751
    +                display_marking_label = Gtk.Label()
    
    752
    +                display_marking_label.set_markup(label_text)
    
    753
    +                self._meta_box.add(display_marking_label)
    
    754
    +
    
    755
    +        self._message_icons = MessageIcons()
    
    756
    +
    
    757
    +        if error is not None:
    
    758
    +            self.set_error(to_user_string(error))
    
    759
    +
    
    760
    +        if marker is not None:
    
    761
    +            if marker in ('received', 'displayed'):
    
    762
    +                self.set_receipt()
    
    763
    +
    
    764
    +        self._meta_box.pack_end(self._message_icons, False, True, 0)
    
    765
    +
    
    766
    +        self._avatar_surface = Gtk.Image.new_from_surface(avatar)
    
    767
    +        avatar_placeholder = Gtk.Box()
    
    768
    +        avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1)
    
    769
    +        avatar_placeholder.set_valign(Gtk.Align.START)
    
    770
    +        avatar_placeholder.add(self._avatar_surface)
    
    771
    +
    
    772
    +        bottom_box = Gtk.Box(spacing=6)
    
    773
    +        bottom_box.add(self.textview)
    
    774
    +        bottom_box.add(self._create_more_button())
    
    775
    +
    
    776
    +        self.grid.attach(avatar_placeholder, 0, 0, 1, 2)
    
    777
    +        self.grid.attach(self._meta_box, 1, 0, 1, 1)
    
    778
    +        self.grid.attach(bottom_box, 1, 1, 1, 1)
    
    779
    +
    
    780
    +    def _create_more_button(self):
    
    781
    +        more_button = Gtk.MenuButton()
    
    782
    +        more_button.connect(
    
    783
    +            'enter-notify-event', self._on_more_button_enter)
    
    784
    +        more_button.connect(
    
    785
    +            'leave-notify-event', self._on_more_button_leave)
    
    786
    +        more_button.set_valign(Gtk.Align.START)
    
    787
    +        more_button.set_halign(Gtk.Align.END)
    
    788
    +        self._button_icon = Gtk.Image.new_from_icon_name(
    
    789
    +            'view-more-symbolic', Gtk.IconSize.BUTTON)
    
    790
    +        self._button_icon.set_no_show_all(True)
    
    791
    +        more_button.add(self._button_icon)
    
    792
    +        more_button.set_relief(Gtk.ReliefStyle.NONE)
    
    793
    +
    
    794
    +        menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
    
    795
    +        menu_box.get_style_context().add_class('padding-6')
    
    796
    +
    
    797
    +        quote_button = Gtk.ModelButton()
    
    798
    +        quote_button.set_halign(Gtk.Align.START)
    
    799
    +        quote_button.connect('clicked', self._on_quote_message)
    
    800
    +        quote_button.set_label(_('Quote…'))
    
    801
    +        quote_button.set_image(Gtk.Image.new_from_icon_name(
    
    802
    +            'mail-reply-sender-symbolic', Gtk.IconSize.MENU))
    
    803
    +        menu_box.add(quote_button)
    
    804
    +
    
    805
    +        copy_button = Gtk.ModelButton()
    
    806
    +        copy_button.set_halign(Gtk.Align.START)
    
    807
    +        copy_button.connect('clicked', self._on_copy_message)
    
    808
    +        copy_button.set_label(_('Copy'))
    
    809
    +        copy_button.set_image(Gtk.Image.new_from_icon_name(
    
    810
    +            'edit-copy-symbolic', Gtk.IconSize.MENU))
    
    811
    +        menu_box.add(copy_button)
    
    812
    +
    
    813
    +        menu_box.show_all()
    
    814
    +
    
    815
    +        popover = Gtk.PopoverMenu()
    
    816
    +        popover.add(menu_box)
    
    817
    +        more_button.set_popover(popover)
    
    818
    +        return more_button
    
    819
    +
    
    820
    +    def _on_copy_message(self, _widget):
    
    821
    +        timestamp = self.timestamp.strftime('%x, %X')
    
    822
    +        clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
    
    823
    +        clip.set_text(
    
    824
    +            f'{timestamp} - {self.name}: {self.textview.get_text()}', -1)
    
    825
    +
    
    826
    +    def _on_quote_message(self, _widget):
    
    827
    +        self.get_parent().on_quote(self.textview.get_text())
    
    828
    +
    
    829
    +    def _on_more_button_enter(self, _widget, _event):
    
    830
    +        self._button_icon.show()
    
    831
    +
    
    832
    +    def _on_more_button_leave(self, widget, _event):
    
    833
    +        if not widget.get_active():
    
    834
    +            self._button_icon.hide()
    
    835
    +
    
    836
    +    def _get_encryption_image(self, additional_data, encryption_enabled=None):
    
    837
    +        details = self._get_encryption_details(additional_data)
    
    838
    +        if details is None:
    
    839
    +            # Message was not encrypted
    
    840
    +            if not encryption_enabled:
    
    841
    +                return None
    
    842
    +            icon = 'channel-insecure-symbolic'
    
    843
    +            color = 'unencrypted-color'
    
    844
    +            tooltip = _('Not encrypted')
    
    845
    +        else:
    
    846
    +            name, fingerprint, trust = details
    
    847
    +            tooltip = _('Encrypted (%s)') % (name)
    
    848
    +            if trust is None:
    
    849
    +                # The encryption plugin did not pass trust information
    
    850
    +                icon = 'channel-secure-symbolic'
    
    851
    +                color = 'encrypted-color'
    
    852
    +            else:
    
    853
    +                icon, trust_tooltip, color = TRUST_SYMBOL_DATA[trust]
    
    854
    +                tooltip = '%s\n%s' % (tooltip, trust_tooltip)
    
    855
    +            if fingerprint is not None:
    
    856
    +                fingerprint = format_fingerprint(fingerprint)
    
    857
    +                tooltip = '%s\n<tt>%s</tt>' % (tooltip, fingerprint)
    
    858
    +
    
    859
    +        image = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU)
    
    860
    +        image.set_tooltip_markup(tooltip)
    
    861
    +        image.get_style_context().add_class(color)
    
    862
    +        image.show()
    
    863
    +        return image
    
    864
    +
    
    865
    +    @staticmethod
    
    866
    +    def _get_encryption_details(additional_data):
    
    867
    +        name = additional_data.get_value('encrypted', 'name')
    
    868
    +        if name is None:
    
    869
    +            return None
    
    870
    +
    
    871
    +        fingerprint = additional_data.get_value('encrypted', 'fingerprint')
    
    872
    +        trust = additional_data.get_value('encrypted', 'trust')
    
    873
    +        return name, fingerprint, trust
    
    874
    +
    
    875
    +    def _on_quote_selection(self, _widget, text):
    
    876
    +        self.get_parent().on_quote(text)
    
    877
    +
    
    878
    +    @property
    
    879
    +    def has_receipt(self):
    
    880
    +        return self._has_receipt
    
    881
    +
    
    882
    +    @property
    
    883
    +    def has_displayed(self):
    
    884
    +        return self._has_displayed
    
    885
    +
    
    886
    +    def set_receipt(self):
    
    887
    +        self._has_receipt = True
    
    888
    +        self._message_icons.set_receipt_icon_visible(True)
    
    889
    +
    
    890
    +    def set_displayed(self):
    
    891
    +        self._has_displayed = True
    
    892
    +
    
    893
    +    def set_correction(self, message_id, text, other_text_tags, kind, name,
    
    894
    +                       additional_data=None):
    
    895
    +        self._corrections.append(self.textview.get_text())
    
    896
    +        self.textview.clear()
    
    897
    +        self._has_receipt = False
    
    898
    +        self._message_icons.set_receipt_icon_visible(False)
    
    899
    +        self._message_icons.set_correction_icon_visible(True)
    
    900
    +
    
    901
    +        self.textview.print_text(
    
    902
    +            text,
    
    903
    +            other_text_tags=other_text_tags,
    
    904
    +            kind=kind,
    
    905
    +            name=name,
    
    906
    +            additional_data=additional_data)
    
    907
    +
    
    908
    +        corrections = '\n'.join(line for line in self._corrections)
    
    909
    +        corrections = reduce_chars_newlines(
    
    910
    +            corrections, max_chars=150, max_lines=10)
    
    911
    +        self._message_icons.set_correction_tooltip(
    
    912
    +            _('Message corrected. Original message:\n%s') % corrections)
    
    913
    +        # Update message_id for this row
    
    914
    +        self.message_id = message_id
    
    915
    +
    
    916
    +    def set_error(self, tooltip):
    
    917
    +        self._message_icons.set_error_icon_visible(True)
    
    918
    +        self._message_icons.set_error_tooltip(tooltip)
    
    919
    +
    
    920
    +    def update_avatar(self, avatar):
    
    921
    +        self._avatar_surface.set_from_surface(avatar)
    
    922
    +
    
    923
    +    def set_merged(self, merged):
    
    924
    +        if merged:
    
    925
    +            self._avatar_surface.set_no_show_all(True)
    
    926
    +            self._avatar_surface.hide()
    
    927
    +            self._meta_box.set_no_show_all(True)
    
    928
    +            self._meta_box.hide()
    
    929
    +        else:
    
    930
    +            self._avatar_surface.set_no_show_all(False)
    
    931
    +            self._avatar_surface.show()
    
    932
    +            self._meta_box.set_no_show_all(False)
    
    933
    +            self._meta_box.show()
    
    934
    +
    
    935
    +
    
    936
    +class MessageIcons(Gtk.Box):
    
    937
    +    def __init__(self):
    
    938
    +        Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
    
    939
    +
    
    940
    +        self._correction_image = Gtk.Image.new_from_icon_name(
    
    941
    +            'document-edit-symbolic', Gtk.IconSize.MENU)
    
    942
    +        self._correction_image.set_no_show_all(True)
    
    943
    +        self._correction_image.get_style_context().add_class('dim-label')
    
    944
    +
    
    945
    +        self._marker_image = Gtk.Image()
    
    946
    +        self._marker_image.set_no_show_all(True)
    
    947
    +        self._marker_image.get_style_context().add_class('dim-label')
    
    948
    +
    
    949
    +        self._error_image = Gtk.Image.new_from_icon_name(
    
    950
    +            'dialog-warning-symbolic', Gtk.IconSize.MENU)
    
    951
    +        self._error_image.get_style_context().add_class('warning-color')
    
    952
    +        self._error_image.set_no_show_all(True)
    
    953
    +
    
    954
    +        self.add(self._correction_image)
    
    955
    +        self.add(self._marker_image)
    
    956
    +        self.add(self._error_image)
    
    957
    +        self.show_all()
    
    958
    +
    
    959
    +    def set_receipt_icon_visible(self, visible):
    
    960
    +        if not app.settings.get('positive_184_ack'):
    
    961
    +            return
    
    962
    +        self._marker_image.set_visible(visible)
    
    963
    +        self._marker_image.set_from_icon_name(
    
    964
    +            'feather-check-symbolic', Gtk.IconSize.MENU)
    
    965
    +        self._marker_image.set_tooltip_text(Q_('?Message state:Received'))
    
    966
    +
    
    967
    +    def set_correction_icon_visible(self, visible):
    
    968
    +        self._correction_image.set_visible(visible)
    
    969
    +
    
    970
    +    def set_correction_tooltip(self, text):
    
    971
    +        self._correction_image.set_tooltip_markup(text)
    
    972
    +
    
    973
    +    def set_error_icon_visible(self, visible):
    
    974
    +        self._error_image.set_visible(visible)
    
    975
    +
    
    976
    +    def set_error_tooltip(self, text):
    
    977
    +        self._error_image.set_tooltip_markup(text)