diff --git a/gajim/chat_control.py b/gajim/chat_control.py index af4abff009669c16e809e523a58fd4e51944096a..bb753bff48be466a615c5c52be5e1912561e2e51 100644 --- a/gajim/chat_control.py +++ b/gajim/chat_control.py @@ -209,8 +209,6 @@ def __init__(self, parent_win, jid, acct, session, resource=None): gui_menu_builder.get_encryption_menu( self.control_id, self._type, self.account == 'Local')) self.set_encryption_menu_icon() - # restore previous conversation - self.restore_conversation() self.msg_textview.grab_focus() # PluginSystem: adding GUI extension point for this ChatControl @@ -562,7 +560,7 @@ def _on_message_received(self, event): additional_data=event.additional_data) def _on_message_error(self, event): - self.conv_textview.show_error(event.message_id, event.error) + self.conversation_view.show_error(event.message_id, event.error) def _on_message_sent(self, event): if not event.message: @@ -589,10 +587,10 @@ def _on_message_sent(self, event): additional_data=event.additional_data) def _on_receipt_received(self, event): - self.conv_textview.show_receipt(event.receipt_id) + self.conversation_view.show_receipt(event.receipt_id) def _on_displayed_received(self, event): - self.conv_textview.show_displayed(event.marker_id) + self.conversation_view.set_read_marker(event.marker_id) def _nec_ping(self, event): if self.contact != event.contact: @@ -1177,62 +1175,6 @@ def _on_drag_data_received(self, widget, context, x, y, selection, [self.contact.jid], [dropped_jid]) - def restore_conversation(self): - jid = self.contact.jid - # number of messages that are in queue and are already logged, we want - # to avoid duplication - pending = len(app.events.get_events(self.account, jid, ['chat', 'pm'])) - if self.resource: - pending += len(app.events.get_events(self.account, - self.contact.get_full_jid(), - ['chat', 'pm'])) - - rows = app.storage.archive.get_last_conversation_lines( - self.account, jid, pending) - - local_old_kind = None - self.conv_textview.just_cleared = True - for row in rows: # time, kind, message, subject, additional_data - msg = row.message - additional_data = row.additional_data - if not msg: # message is empty, we don't print it - continue - if row.kind in (KindConstant.CHAT_MSG_SENT, - KindConstant.SINGLE_MSG_SENT): - kind = 'outgoing' - name = self.get_our_nick() - elif row.kind in (KindConstant.SINGLE_MSG_RECV, - KindConstant.CHAT_MSG_RECV): - kind = 'incoming' - name = self.contact.name - elif row.kind == KindConstant.ERROR: - kind = 'status' - name = self.contact.name - - tim = float(row.time) - - if row.subject: - msg = _('Subject: %(subject)s\n%(message)s') % \ - {'subject': row.subject, 'message': msg} - ChatControlBase.add_message(self, - msg, - kind, - name, - tim, - restored=True, - old_kind=local_old_kind, - additional_data=additional_data, - message_id=row.message_id, - marker=row.marker, - error=row.error) - if (row.message.startswith('/me ') or - row.message.startswith('/me\n')): - local_old_kind = None - else: - local_old_kind = kind - if rows: - self.conv_textview.print_empty_line() - def _on_convert_to_gc_menuitem_activate(self, _widget): """ User wants to invite some friends to chat diff --git a/gajim/chat_control_base.py b/gajim/chat_control_base.py index c2daa9b55171e6fa7afb6eb20b03a566aca6fb16..3941224ea8b537f7e5a94ce068772a6c20c427be 100644 --- a/gajim/chat_control_base.py +++ b/gajim/chat_control_base.py @@ -25,6 +25,7 @@ import os import sys +import datetime import time import uuid import tempfile @@ -43,12 +44,12 @@ from gajim.common.i18n import _ from gajim.common.nec import EventHelper from gajim.common.helpers import AdditionalDataDict +from gajim.common.const import KindConstant from gajim.common.structs import OutgoingMessage from gajim import gtkgui_helpers -from gajim.conversation_textview import ConversationTextview - +from gajim.gui.conversation_view import ConversationView from gajim.gui.dialogs import DialogButton from gajim.gui.dialogs import ConfirmationDialog from gajim.gui.dialogs import PastePreviewDialog @@ -93,7 +94,7 @@ ################################################################################ class ChatControlBase(ChatCommandProcessor, CommandTools, EventHelper): """ - A base class containing a banner, ConversationTextview, MessageInputTextView + A base class containing a banner, ConversationView, MessageInputTextView """ _type = None # type: ControlType @@ -164,14 +165,16 @@ def __init__(self, parent_win, widget_name, jid, acct, Gdk.DragAction.COPY | Gdk.DragAction.MOVE) self.xml.overlay.drag_dest_set_target_list(dst_targets) - # Create textviews and connect signals - self.conv_textview = ConversationTextview(self.account) - - id_ = self.conv_textview.connect('quote', self.on_quote) - self.handlers[id_] = self.conv_textview - - self.conv_textview.tv.connect('key-press-event', - self._on_conv_textview_key_press_event) + # Create ConversationView and connect signals + self.conversation_view = ConversationView(self.account, self.contact) + id_ = self.conversation_view.connect('quote', self.on_quote) + self.handlers[id_] = self.conversation_view + id_ = self.conversation_view.connect( + 'load-history', self._on_load_history) + self.handlers[id_] = self.conversation_view + id_ = self.conversation_view.connect( + 'key-press-event', self._on_conversation_view_key_press) + self.handlers[id_] = self.conversation_view # This is a workaround: as soon as a line break occurs in Gtk.TextView # with word-char wrapping enabled, a hyphen character is automatically @@ -183,10 +186,18 @@ def __init__(self, parent_win, widget_name, jid, acct, hscrollbar = self.xml.conversation_scrolledwindow.get_hscrollbar() hscrollbar.hide() - self.xml.conversation_scrolledwindow.add(self.conv_textview.tv) - widget = self.xml.conversation_scrolledwindow.get_vadjustment() - widget.connect('changed', self.on_conversation_vadjustment_changed) + self.xml.conversation_scrolledwindow.add(self.conversation_view) + self._scrolled_old_upper = None + self._scrolled_old_value = None + self._scrolled_previous_value = 0 + + self._fetching_history = False + self._initial_fetch_done = False + vadjustment = self.xml.conversation_scrolledwindow.get_vadjustment() + vadjustment.connect( + 'changed', self._on_conversation_vadjustment_changed) + vadjustment.connect('value-changed', self._on_scroll_value_changed) vscrollbar = self.xml.conversation_scrolledwindow.get_vscrollbar() vscrollbar.connect('button-release-event', self._on_scrollbar_button_release) @@ -237,7 +248,6 @@ def __init__(self, parent_win, widget_name, jid, acct, # Attach speller self.set_speller() - self.conv_textview.tv.show() # For XEP-0172 self.user_nick = None @@ -255,7 +265,7 @@ def __init__(self, parent_win, widget_name, jid, acct, self.handlers[id_] = parent_win.window self.encryption = self.get_encryption_state() - self.conv_textview.encryption_enabled = self.encryption is not None + self.conversation_view.encryption_enabled = self.encryption is not None # PluginSystem: adding GUI extension point for ChatControlBase # instance object (also subclasses, eg. ChatControl or GroupchatControl) @@ -284,7 +294,7 @@ def process_event(self, event): method_name = f'_on_{method_name}' getattr(self, method_name)(event) - def _on_conv_textview_key_press_event(self, textview, event): + def _on_conversation_view_key_press(self, _listbox, event): if event.get_state() & Gdk.ModifierType.SHIFT_MASK: if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): return Gdk.EVENT_PROPAGATE @@ -296,12 +306,13 @@ def _on_conv_textview_key_press_event(self, textview, event): # focused). return Gdk.EVENT_PROPAGATE - if event.get_state() & COPY_MODIFIER: - # Don’t reroute the event if it is META + c and the - # textview has a selection - if event.hardware_keycode in KEYCODES_KEY_C: - if textview.get_buffer().props.has_selection: - return Gdk.EVENT_PROPAGATE + # if event.get_state() & COPY_MODIFIER: + # TODO + # # Don’t reroute the event if it is META + c and the + # # textview has a selection + # if event.hardware_keycode in KEYCODES_KEY_C: + # if textview.get_buffer().props.has_selection: + # return Gdk.EVENT_PROPAGATE if not self.msg_textview.get_sensitive(): # If the input textview is not sensitive it can’t get the focus. @@ -517,7 +528,7 @@ def delegate_action(self, action): return Gdk.EVENT_STOP if action == 'clear-chat': - self.conv_textview.clear() + self.conversation_view.clear() return Gdk.EVENT_STOP if action == 'delete-line': @@ -617,7 +628,7 @@ def _on_authentication_button_clicked(self, _button): def set_encryption_state(self, encryption): self.encryption = encryption - self.conv_textview.encryption_enabled = encryption is not None + self.conversation_view.encryption_enabled = encryption is not None self.contact.settings.set('encryption', self.encryption or '') def get_encryption_state(self): @@ -702,8 +713,7 @@ def shutdown(self): self.handlers[i].disconnect(i) self.handlers.clear() - self.conv_textview.del_handlers() - del self.conv_textview + del self.conversation_view del self.msg_textview del self.msg_scrolledwindow @@ -856,7 +866,7 @@ def _on_message_textview_key_press_event(self, textview, event): return True if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): - self.conv_textview.tv.event(event) + self.conversation_view.event(event) self._on_scroll(None, event.keyval) return True @@ -1098,11 +1108,11 @@ def save_message(self, message, msg_type): self.received_history_pos = pos def add_info_message(self, text, message_id=None): - self.conv_textview.print_conversation_line( + self.conversation_view.add_message( text, 'info', '', None, message_id=message_id, graphics=False) def add_status_message(self, text): - self.conv_textview.print_conversation_line( + self.conversation_view.add_message( text, 'status', '', None) def add_message(self, @@ -1130,9 +1140,8 @@ def add_message(self, """ jid = self.contact.jid full_jid = self.get_full_jid() - textview = self.conv_textview end = False - if self.conv_textview.autoscroll or kind == 'outgoing': + if self.conversation_view.autoscroll or kind == 'outgoing': end = True if other_tags_for_name is None: @@ -1144,21 +1153,18 @@ def add_message(self, if additional_data is None: additional_data = AdditionalDataDict() - textview.print_conversation_line(text, - kind, - name, - tim, - other_tags_for_name, - other_tags_for_time, - other_tags_for_text, - subject, - old_kind, - displaymarking=displaymarking, - message_id=message_id, - correct_id=correct_id, - additional_data=additional_data, - marker=marker, - error=error) + self.conversation_view.add_message( + text, + kind, + name, + tim, + other_text_tags=other_tags_for_text, + display_marking=displaymarking, + message_id=message_id, + correct_id=correct_id, + additional_data=additional_data, + marker=marker, + error=error) if restored: return @@ -1301,7 +1307,7 @@ def _style_changed(self, *args): self.update_tags() def update_tags(self): - self.conv_textview.update_tags() + self.conversation_view.update_text_tags() @staticmethod def clear(tv): @@ -1372,7 +1378,7 @@ def set_control_active(self, state): if state: self.set_emoticon_popover() jid = self.contact.jid - if self.conv_textview.autoscroll: + if self.conversation_view.autoscroll: # we are at the end type_ = [f'printed_{self._type}'] if self._type.is_groupchat: @@ -1401,14 +1407,73 @@ def set_control_active(self, state): self.contact, Chatstate.INACTIVE) def scroll_to_end(self, force=False): - self.conv_textview.scroll_to_end(force) + self.conversation_view.scroll_to_end(force) + + def _on_load_history(self, _button, lines): + self.fetch_n_lines_history(lines) + + def fetch_n_lines_history(self, n_lines): + if self._fetching_history: + return + + timestamp_end = self.conversation_view.first_message_timestamp + if not timestamp_end: + timestamp_end = datetime.datetime.now() + self._fetching_history = True + adjustment = self.xml.conversation_scrolledwindow.get_vadjustment() + + # Store these values so we can restore scroll position later + self._scrolled_old_upper = adjustment.get_upper() + self._scrolled_old_value = adjustment.get_value() + + if self.is_groupchat: + messages = app.storage.archive.get_conversation_muc_before( + self.account, + self.contact.jid, + timestamp_end, + n_lines) + else: + messages = app.storage.archive.get_conversation_before( + self.account, + self.contact.jid, + timestamp_end, + n_lines) + + for msg in messages: + if not msg: + continue + kind = 'status' + contact_name = msg.contact_name + if msg.kind in ( + KindConstant.SINGLE_MSG_RECV, KindConstant.CHAT_MSG_RECV): + kind = 'incoming' + contact_name = self.contact.name + elif msg.kind == KindConstant.GC_MSG: + kind = 'incoming' + elif msg.kind in ( + KindConstant.SINGLE_MSG_SENT, KindConstant.CHAT_MSG_SENT): + kind = 'outgoing' + contact_name = self.get_our_nick() + if not msg.message: + continue + self.conversation_view.add_message( + msg.message, + kind, + contact_name, + msg.time, + subject=msg.subject, + additional_data=msg.additional_data, + message_id=msg.message_id, + marker=msg.marker, + error=msg.error, + history=True) def _on_edge_reached(self, _scrolledwindow, pos): if pos != Gtk.PositionType.BOTTOM: return # Remove all events and set autoscroll True app.log('autoscroll').info('Autoscroll enabled') - self.conv_textview.autoscroll = True + self.conversation_view.autoscroll = True if self.resource: jid = self.contact.get_full_jid() else: @@ -1437,13 +1502,44 @@ def _on_edge_reached(self, _scrolledwindow, pos): self._type) self.last_msg_id = None + def _on_edge_overshot(self, _scrolledwindow, pos): + # Fetch messages if we scroll against the top + if pos != Gtk.PositionType.TOP: + return + self.fetch_n_lines_history(30) + + def _on_scroll_value_changed(self, adjustment): + if self.conversation_view.clearing or self._fetching_history: + return + + value = adjustment.get_value() + previous_value = self._scrolled_previous_value + self._scrolled_previous_value = value + + if value >= previous_value: + return + + # Fetch messages before we reach the top + if value <= int(0.1 * adjustment.get_upper()): + self.fetch_n_lines_history(10) + + def _on_scroll_realize(self, _widget): + # Initial message fetching when ChatControl is first opened + if not self._initial_fetch_done: + self._initial_fetch_done = True + restore_lines = app.settings.get('restore_lines') + if restore_lines <= 0: + return + self.fetch_n_lines_history(restore_lines) + def _on_scrollbar_button_release(self, scrollbar, event): if event.get_button()[1] != 1: # We want only to catch the left mouse button return + if not at_the_end(scrollbar.get_parent()): app.log('autoscroll').info('Autoscroll disabled') - self.conv_textview.autoscroll = False + self.conversation_view.autoscroll = False def has_focus(self): if self.parent_win: @@ -1453,12 +1549,12 @@ def has_focus(self): return False def _on_scroll(self, widget, event): - if not self.conv_textview.autoscroll: + if not self.conversation_view.autoscroll: # autoscroll is already disabled return if widget is None: - # call from _conv_textview_key_press_event() + # call from _on_conversation_view_key_press() # SHIFT + Gdk.KEY_Page_Up if event != Gdk.KEY_Page_Up: return @@ -1490,10 +1586,18 @@ def _on_scroll(self, widget, event): adjustment = self.xml.conversation_scrolledwindow.get_vadjustment() if adjustment.get_upper() != adjustment.get_page_size(): app.log('autoscroll').info('Autoscroll disabled') - self.conv_textview.autoscroll = False + self.conversation_view.autoscroll = False - def on_conversation_vadjustment_changed(self, _adjustment): + def _on_conversation_vadjustment_changed(self, adjustment): self.scroll_to_end() + if self._fetching_history: + # Make sure the scroll position is kept + new_upper = adjustment.get_upper() + diff = new_upper - self._scrolled_old_upper + new_value = diff + self._scrolled_old_value + adjustment.set_value(new_value) + self._fetching_history = False + return def scroll_messages(self, direction, msg_buf, msg_type): if msg_type == 'sent': @@ -1550,7 +1654,7 @@ def got_connected(self): def got_disconnected(self): self.msg_textview.set_sensitive(False) self.msg_textview.set_editable(False) - self.conv_textview.tv.grab_focus() + self.conversation_view.grab_focus() self.update_toolbar() diff --git a/gajim/command_system/implementation/middleware.py b/gajim/command_system/implementation/middleware.py index ebec45c557ce2389df2b7e23a6926ce7e4eefc8f..296387776ff39152bad68f4a434eb52583426a40 100644 --- a/gajim/command_system/implementation/middleware.py +++ b/gajim/command_system/implementation/middleware.py @@ -33,7 +33,7 @@ from traceback import print_exc -from gi.repository import Pango +# from gi.repository import Pango from gajim.common import app from gajim.common.i18n import _ @@ -117,29 +117,33 @@ def __init__(self): self.install_tags() def install_tags(self): - buffer_ = self.conv_textview.tv.get_buffer() + # TODO implement this in ConversationView + # buffer_ = self.conv_textview.tv.get_buffer() - name = "Monospace" - font = Pango.FontDescription(name) + # name = "Monospace" + # font = Pango.FontDescription(name) - command_ok_tag = buffer_.create_tag("command_ok") - command_ok_tag.set_property("font-desc", font) - command_ok_tag.set_property("foreground", "#3465A4") + # command_ok_tag = buffer_.create_tag("command_ok") + # command_ok_tag.set_property("font-desc", font) + # command_ok_tag.set_property("foreground", "#3465A4") - command_error_tag = buffer_.create_tag("command_error") - command_error_tag.set_property("font-desc", font) - command_error_tag.set_property("foreground", "#F57900") + # command_error_tag = buffer_.create_tag("command_error") + # command_error_tag.set_property("font-desc", font) + # command_error_tag.set_property("foreground", "#F57900") + return def shift_line(self): - buffer_ = self.conv_textview.tv.get_buffer() - iter_ = buffer_.get_end_iter() - if iter_.ends_line() and not iter_.is_start(): - buffer_.insert_with_tags_by_name(iter_, "\n", "eol") + # buffer_ = self.conv_textview.tv.get_buffer() + # iter_ = buffer_.get_end_iter() + # if iter_.ends_line() and not iter_.is_start(): + # buffer_.insert_with_tags_by_name(iter_, "\n", "eol") + return def append_with_tags(self, text, *tags): - buffer_ = self.conv_textview.tv.get_buffer() - iter_ = buffer_.get_end_iter() - buffer_.insert_with_tags_by_name(iter_, text, *tags) + # buffer_ = self.conv_textview.tv.get_buffer() + # iter_ = buffer_.get_end_iter() + # buffer_.insert_with_tags_by_name(iter_, text, *tags) + return def echo(self, text, tag="command_ok"): """ diff --git a/gajim/common/const.py b/gajim/common/const.py index 2ce553f11fe94fbec04e16ad26023a8bd62c6ef1..aeb6302b19b2077bfa0a2dd44a2600d0033b9bd6 100644 --- a/gajim/common/const.py +++ b/gajim/common/const.py @@ -1049,6 +1049,7 @@ def is_active(self): 'iris.xpcs:', 'iris.lwz:', 'ldap://', + 'mailto:', 'mid:', 'modem:', 'msrp://', @@ -1081,6 +1082,7 @@ def is_active(self): 'vemmi://', 'xmlrpc.beep://', 'xmlrpc.beeps://', + 'xmpp:', 'z39.50r://', 'z39.50s://', 'about:', @@ -1107,6 +1109,22 @@ def is_active(self): } +TRUST_SYMBOL_DATA = { + Trust.UNTRUSTED: ('dialog-error-symbolic', + _('Untrusted'), + 'error-color'), + Trust.UNDECIDED: ('security-low-symbolic', + _('Trust Not Decided'), + 'warning-color'), + Trust.BLIND: ('security-medium-symbolic', + _('Unverified'), + 'encrypted-color'), + Trust.VERIFIED: ('security-high-symbolic', + _('Verified'), + 'encrypted-color') +} + + THRESHOLD_OPTIONS = { -1: _('No Sync'), 1: _('1 Day'), diff --git a/gajim/common/storage/archive.py b/gajim/common/storage/archive.py index 80277b621570b634da570ec854f5e4dc38659439..a78131022598eeba4ad8a109b5a3a4a6aab9dcbf 100644 --- a/gajim/common/storage/archive.py +++ b/gajim/common/storage/archive.py @@ -321,72 +321,72 @@ def convert_show_values_to_db_api_values(show): return None @timeit - def load_groupchat_messages(self, account, jid): - account_id = self.get_account_id(account, type_=JIDConstant.ROOM_TYPE) + def get_conversation_before(self, account, jid, end_timestamp, n_lines): + """ + Load n_lines lines of conversation with jid before end_timestamp - sql = ''' - SELECT time, contact_name, message, additional_data, message_id - FROM logs NATURAL JOIN jids WHERE jid = ? - AND account_id = ? AND kind = ? - ORDER BY time DESC, log_line_id DESC LIMIT ?''' + :param account: The account - messages = self._con.execute( - sql, (jid, account_id, KindConstant.GC_MSG, 50)).fetchall() + :param jid: The jid for which we request the conversation - messages.reverse() - return messages + :param end_timestamp: end timestamp / datetime.datetime instance - @timeit - def get_last_conversation_lines(self, account, jid, pending): + returns a list of namedtuples """ - Get recent messages - - Pending messages are already in queue to be printed when the - ChatControl is opened, so we don’t want to request those messages. - How many messages are requested depends on the 'restore_lines' - config value. How far back in time messages are requested depends on - _get_timeout(). - - :param account: The account + jids = self._get_family_jids(account, jid) + account_id = self.get_account_id(account) - :param jid: The jid from which we request the conversation lines + sql = ''' + SELECT contact_name, time, kind, show, message, subject, + additional_data, log_line_id, message_id, + error as "error [common_error]", + marker as "marker [marker]" + FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) + AND account_id = {account_id} + AND time < ? + ORDER BY time DESC, log_line_id DESC + LIMIT ? + '''.format(jids=', '.join('?' * len(jids)), + account_id=account_id) - :param pending: How many messages are currently pending so we don’t - request those messages + return self._con.execute( + sql, + tuple(jids) + (end_timestamp.timestamp(), n_lines)).fetchall() - returns a list of namedtuples + @timeit + def get_conversation_muc_before(self, account, jid, end_timestamp, + n_lines): """ + Load n_lines lines of conversation with jid before end_timestamp + + :param account: The account - restore = app.settings.get('restore_lines') - if restore <= 0: - return [] + :param jid: The jid for which we request the conversation - kinds = map(str, [KindConstant.SINGLE_MSG_RECV, - KindConstant.SINGLE_MSG_SENT, - KindConstant.CHAT_MSG_RECV, - KindConstant.CHAT_MSG_SENT, - KindConstant.ERROR]) + :param end_timestamp: end timestamp / datetime.datetime instance + returns a list of namedtuples + """ jids = self._get_family_jids(account, jid) - account_id = self.get_account_id(account) + # TODO: this does not load messages correctly when account_id is set + # account_id = self.get_account_id(account, type_=JIDConstant.ROOM_TYPE) sql = ''' - SELECT time, kind, message, error as "error [common_error]", - subject, additional_data, marker as "marker [marker]", - message_id + SELECT contact_name, time, kind, show, message, subject, + additional_data, log_line_id, message_id, + error as "error [common_error]", + marker as "marker [marker]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) - AND account_id = {account_id} AND kind IN ({kinds}) - AND time > get_timeout() - ORDER BY time DESC, log_line_id DESC LIMIT ? OFFSET ? + AND kind = {kind} + AND time < ? + ORDER BY time DESC, log_line_id DESC + LIMIT ? '''.format(jids=', '.join('?' * len(jids)), - account_id=account_id, - kinds=', '.join(kinds)) - - messages = self._con.execute( - sql, tuple(jids) + (restore, pending)).fetchall() + kind=KindConstant.GC_MSG) - messages.reverse() - return messages + return self._con.execute( + sql, + tuple(jids) + (end_timestamp.timestamp(), n_lines)).fetchall() @timeit def get_last_conversation_line(self, account, jid): diff --git a/gajim/data/gui/chat_control.ui b/gajim/data/gui/chat_control.ui index f47c1419027d016cf993d7adf507c57f0b8654fc..a402f01708a18783afaf2defc7b4e5871d5cbf24 100644 --- a/gajim/data/gui/chat_control.ui +++ b/gajim/data/gui/chat_control.ui @@ -623,7 +623,9 @@ <property name="can_focus">True</property> <property name="shadow_type">in</property> <property name="overlay_scrolling">False</property> + <signal name="edge-overshot" handler="_on_edge_overshot" swapped="no"/> <signal name="edge-reached" handler="_on_edge_reached" swapped="no"/> + <signal name="realize" handler="_on_scroll_realize" swapped="no"/> <signal name="scroll-event" handler="_on_scroll" swapped="no"/> <child> <placeholder/> diff --git a/gajim/data/gui/groupchat_control.ui b/gajim/data/gui/groupchat_control.ui index 0695290b957759c321482de42db81627c7fed13e..5e11b173c0568d72dd89a8294dee11612b9eb224 100644 --- a/gajim/data/gui/groupchat_control.ui +++ b/gajim/data/gui/groupchat_control.ui @@ -348,7 +348,9 @@ <property name="can_focus">True</property> <property name="shadow_type">in</property> <property name="overlay_scrolling">False</property> + <signal name="edge-overshot" handler="_on_edge_overshot" swapped="no"/> <signal name="edge-reached" handler="_on_edge_reached" swapped="no"/> + <signal name="realize" handler="_on_scroll_realize" swapped="no"/> <signal name="scroll-event" handler="_on_scroll" swapped="no"/> <child> <placeholder/> diff --git a/gajim/data/style/gajim-dark.css b/gajim/data/style/gajim-dark.css index 54a04099beab5bfa85693246db6255860302843c..90c4f1b4bcc1652f2e68781012f79ac6c323f41c 100644 --- a/gajim/data/style/gajim-dark.css +++ b/gajim/data/style/gajim-dark.css @@ -17,3 +17,14 @@ infobar.error > revealer > box { color: #FCE5D5; background-color: #D64937; } + +/* ConversationView */ +.conversation-mention-highlight { + background-color: rgb(251, 151, 97); +} +.conversation-mention-highlight:hover { + background-color: rgb(251, 151, 97); +} +.conversation-mention-highlight grid textview text { + background-color: rgb(251, 151, 97); +} diff --git a/gajim/data/style/gajim.css b/gajim/data/style/gajim.css index 5988c679e5fbf5efe99e529d8b6d04427f3c3714..63de50cc7d4d4114353ad282548d1a21ba198ca9 100644 --- a/gajim/data/style/gajim.css +++ b/gajim/data/style/gajim.css @@ -40,6 +40,38 @@ .message-input-focus { border-radius: 4px; } +.conversation-row { + padding: 6px 3px 6px 6px; +} +.conversation-row grid textview { + background: transparent; +} +.conversation-row grid textview text { + background: transparent; +} +.conversation-mention-highlight { + background-color: rgb(255, 215, 194); +} +.conversation-system-row { + padding: 18px; +} +.conversation-date-row { + padding: 12px; +} +.conversation-meta { + color: @insensitive_fg_color; + font-size: small; +} +.conversation-read-marker { + color: @insensitive_fg_color; + font-size: small; + font-style: italic; +} +.conversation-nickname { + font-size: small; + font-weight: bold; +} + .link-button { min-height: 0px; } /* Main Window */ diff --git a/gajim/groupchat_control.py b/gajim/groupchat_control.py index 2c96f7492526156e99664ddd33e43f74c76a246b..34ea24319640bd0955bb5ca8a9a4fc01fc74a9c1 100644 --- a/gajim/groupchat_control.py +++ b/gajim/groupchat_control.py @@ -221,7 +221,6 @@ def __init__(self, parent_win, jid, muc_data, acct): # PluginSystem: adding GUI extension point for this GroupchatControl # instance object app.plugin_manager.gui_extension_point('groupchat_control', self) - self._restore_conversation() def _connect_contact_signals(self): self.contact.multi_connect({ @@ -259,6 +258,8 @@ def _on_muc_state_changed(self, _contact, _signal_name): self.xml.formattings_button.set_sensitive(True) + self.conversation_view.update_avatars() + self.update_actions() elif state == MUCJoinedState.NOT_JOINED: @@ -425,25 +426,6 @@ def remove_actions(self): for action in actions: self.parent_win.window.remove_action(f'{action}{self.control_id}') - def _restore_conversation(self): - rows = app.storage.archive.load_groupchat_messages( - self.account, self.contact.jid) - - for row in rows: - other_tags_for_name = ['muc_nickname_color_%s' % row.contact_name] - ChatControlBase.add_message(self, - row.message, - 'incoming', - row.contact_name, - float(row.time), - other_tags_for_name=other_tags_for_name, - message_id=row.message_id, - restored=True, - additional_data=row.additional_data) - - if rows: - self.conv_textview.print_empty_line() - def _is_subject_change_allowed(self): contact = self.contact.get_resource(self.nick) if contact.affiliation in (Affiliation.OWNER, Affiliation.ADMIN): @@ -851,7 +833,7 @@ def add_message(self, text, contact='', tim=None, self._nick_completion.record_message(contact, highlight) - self.check_focus_out_line() + # self.check_focus_out_line() ChatControlBase.add_message(self, text, @@ -908,7 +890,7 @@ def check_focus_out_line(self): if app.window.is_chat_active(self.account, self.room_jid): return - self.conv_textview.show_focus_out_line() + # self.conv_textview.show_focus_out_line() def needs_visual_notification(self, text): """ @@ -1102,11 +1084,12 @@ def _on_user_nickname_changed(self, _contact, _signal_name, user_contact, proper self.add_info_message(message) - tv = self.conv_textview - if nick in tv.last_received_message_id: - tv.last_received_message_id[new_nick] = \ - tv.last_received_message_id[nick] - del tv.last_received_message_id[nick] + # TODO: What to do with this? + # tv = self.conv_textview + # if nick in tv.last_received_message_id: + # tv.last_received_message_id[new_nick] = \ + # tv.last_received_message_id[nick] + # del tv.last_received_message_id[nick] def _on_user_status_show_changed(self, @@ -1410,7 +1393,7 @@ def send_message(self, message, xhtml=None, process_commands=True): self.msg_textview.grab_focus() def _on_message_error(self, event): - self.conv_textview.show_error(event.message_id, event.error) + self.conversation_view.show_error(event.message_id, event.error) def shutdown(self, reason=None): app.settings.disconnect_signals(self) @@ -1483,12 +1466,12 @@ def _close_control(self, reason=None): # self.parent_win.remove_tab(self, None, reason=reason, force=True) def set_control_active(self, state): - self.conv_textview.allow_focus_out_line = True + # self.conv_textview.allow_focus_out_line = True self.attention_flag = False ChatControlBase.set_control_active(self, state) - if not state: - # add the focus-out line to the tab we are leaving - self.check_focus_out_line() + # if not state: + # # add the focus-out line to the tab we are leaving + # self.check_focus_out_line() # Sending active to undo unread state self.parent_win.redraw_tab(self, 'active') diff --git a/gajim/gtk/conversation_textview.py b/gajim/gtk/conversation_textview.py new file mode 100644 index 0000000000000000000000000000000000000000..d9b34b2230cfbc7bdd65cfdebfbb45f2c56b8156 --- /dev/null +++ b/gajim/gtk/conversation_textview.py @@ -0,0 +1,608 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see <http://www.gnu.org/licenses/>. + +import logging +from urllib.parse import quote + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Pango + +from gajim.common import app +from gajim.common import i18n +from gajim.common.const import StyleAttr +from gajim.common.const import URI_SCHEMES +from gajim.common.helpers import AdditionalDataDict +from gajim.common.helpers import open_uri +from gajim.common.helpers import puny_encode_url +from gajim.common.helpers import reduce_chars_newlines +from gajim.common.i18n import _ +from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX +from gajim.common.regex import BASIC_REGEX +from gajim.common.regex import LINK_REGEX +from gajim.common.regex import EMOT_AND_BASIC_REGEX +from gajim.common.regex import EMOT_AND_LINK_REGEX + +from .htmltextview import HtmlTextView +from .emoji_data import emoji_pixbufs +from .emoji_data import is_emoji +from .emoji_data import get_emoji_pixbuf +from .util import get_cursor + +log = logging.getLogger('gajim.gui.conversation_view') + + +class ConversationTextview(HtmlTextView): + + __gsignals__ = { + 'quote': ( + GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, + None, + (str, ) + ), + } + + def __init__(self, account, history_mode=False): + HtmlTextView.__init__(self, account) + self.set_hexpand(True) + self.set_vexpand(True) + self.set_margin_start(0) + self.set_margin_end(0) + self.set_border_width(0) + self.set_left_margin(0) + self.set_right_margin(0) + + self._history_mode = history_mode + + self.handlers = {} + + id_ = self.connect('query-tooltip', self._query_tooltip) + self.handlers[id_] = self + id_ = self.connect('button-press-event', self._on_button_press) + self.handlers[id_] = self + id_ = self.connect('populate-popup', self._on_populate_popup) + self.handlers[id_] = self + + # Used for changing the mouse pointer when hovering clickable URIs + self._cursor_changed = False + + # Keeps text selections for quoting and search actions + self._selected_text = '' + + self.get_style_context().add_class('gajim-conversation-font') + + buffer_ = self.get_buffer() + + self.tag_in = buffer_.create_tag('incoming') + color = app.css_config.get_value( + '.gajim-incoming-nickname', StyleAttr.COLOR) + self.tag_in.set_property('foreground', color) + desc = app.css_config.get_font('.gajim-incoming-nickname') + self.tag_in.set_property('font-desc', desc) + + self.tag_out = buffer_.create_tag('outgoing') + color = app.css_config.get_value( + '.gajim-outgoing-nickname', StyleAttr.COLOR) + self.tag_out.set_property('foreground', color) + desc = app.css_config.get_font('.gajim-outgoing-nickname') + self.tag_out.set_property('font-desc', desc) + + self.tag_status = buffer_.create_tag('status') + color = app.css_config.get_value( + '.gajim-status-message', StyleAttr.COLOR) + self.tag_status.set_property('foreground', color) + desc = app.css_config.get_font('.gajim-status-message') + self.tag_status.set_property('font-desc', desc) + + tag_in_text = buffer_.create_tag('incomingtxt') + color = app.css_config.get_value( + '.gajim-incoming-message-text', StyleAttr.COLOR) + if color: + tag_in_text.set_property('foreground', color) + desc = app.css_config.get_font('.gajim-incoming-message-text') + tag_in_text.set_property('font-desc', desc) + + tag_out_text = buffer_.create_tag('outgoingtxt') + color = app.css_config.get_value( + '.gajim-outgoing-message-text', StyleAttr.COLOR) + if color: + tag_out_text.set_property('foreground', color) + desc = app.css_config.get_font('.gajim-outgoing-message-text') + tag_out_text.set_property('font-desc', desc) + + self.tag_marked = buffer_.create_tag('marked') + color = app.css_config.get_value( + '.gajim-highlight-message', StyleAttr.COLOR) + self.tag_marked.set_property('foreground', color) + self.tag_marked.set_property('weight', Pango.Weight.BOLD) + + tag = buffer_.create_tag('small') + tag.set_property('scale', 0.8333333333333) + + tag = buffer_.create_tag('bold') + tag.set_property('weight', Pango.Weight.BOLD) + + tag = buffer_.create_tag('italic') + tag.set_property('style', Pango.Style.ITALIC) + + tag = buffer_.create_tag('strikethrough') + tag.set_property('strikethrough', True) + + self.create_tags() + + self.connect('destroy', self._on_destroy) + + def _on_destroy(self, *args): + super()._on_destroy() + for i in self.handlers: + if self.handlers[i].handler_is_connected(i): + self.handlers[i].disconnect(i) + self.handlers.clear() + + def update_text_tags(self): + self.tag_in.set_property('foreground', app.css_config.get_value( + '.gajim-incoming-nickname', StyleAttr.COLOR)) + self.tag_out.set_property('foreground', app.css_config.get_value( + '.gajim-outgoing-nickname', StyleAttr.COLOR)) + self.tag_status.set_property('foreground', app.css_config.get_value( + '.gajim-status-message', StyleAttr.COLOR)) + self.tag_marked.set_property('foreground', app.css_config.get_value( + '.gajim-highlight-message', StyleAttr.COLOR)) + self.update_tags() + + def clear(self): + buffer_ = self.get_buffer() + start, end = buffer_.get_bounds() + buffer_.delete(start, end) + + def get_text(self): + buffer_ = self.get_buffer() + start, end = buffer_.get_bounds() + return buffer_.get_text(start, end, False) + + def print_text(self, text, other_text_tags=None, kind=None, graphics=True, + name=None, additional_data=None): + if additional_data is None: + additional_data = AdditionalDataDict() + + # Print XHTML if present + xhtml = additional_data.get_value('gajim', 'xhtml', False) + if xhtml and app.settings.get('show_xhtml'): + try: + if (name and (text.startswith('/me ') or + text.startswith('/me\n'))): + xhtml = xhtml.replace('/me', '<i>* %s</i>' % (name,), 1) + self.display_html( + xhtml, self, self) + return + except Exception as err: + log.debug('Error processing xhtml: %s', err) + log.debug('with |%s|', xhtml) + + text_tags = [] + if other_text_tags: + text_tags = other_text_tags[:] # create a new list + + if (kind == 'status' or + text.startswith('/me') or text.startswith('/me\n')): + text_tags.append(kind) + + if name and (text.startswith('/me ') or text.startswith('/me\n')): + text = '* ' + name + text[3:] + text_tags.append('italic') + + if kind in ('incoming', 'incoming_queue'): + text_tags.append('incomingtxt') + elif kind == 'outgoing': + text_tags.append('outgoingtxt') + + self.parse_formatting( + text, text_tags, graphics=graphics, additional_data=additional_data) + + def parse_formatting(self, text, text_tags, graphics=True, + additional_data=None): + ''' + Parses message formatting (Emojis, URIs, Styles). + A regex is used for text matching. Each text fragment gets + passed to apply_formatting(), where respective TextTags are added. + Unformatted text (no match) will be passed through unaltered. + ''' + if not text: + return + + if text_tags is None: + text_tags = [] + + buffer_ = self.get_buffer() + + if text_tags: + insert_tags_func = buffer_.insert_with_tags_by_name + else: + insert_tags_func = buffer_.insert + + # TODO: Adapt HtmlHandler.handle_specials() + # detect_and_print_special_text() is used by + # HtmlHandler.handle_specials() and uses Gtk.TextTag objects, + # not strings + if text_tags and isinstance(text_tags[0], Gtk.TextTag): + insert_tags_func = buffer_.insert_with_tags + + # TODO: Plugin system GUI extension point + # self.plugin_modified = False + # app.plugin_manager.extension_point('print_real_text', self, + # text, text_tags, graphics, additional_data) + # if self.plugin_modified: + # return + + # Add XEP-0066 Out of Band text to the end + oob_url = additional_data.get_value('gajim', 'oob_url') + if oob_url is not None: + oob_desc = additional_data.get_value('gajim', 'oob_desc', 'URL:') + if oob_url != text: + text += f'\n{oob_desc} {oob_url}' + + if app.settings.get('emoticons_theme') and graphics: + # Match for Emojis & URIs + if app.settings.get('ascii_formatting'): + regex = EMOT_AND_BASIC_REGEX + else: + regex = EMOT_AND_LINK_REGEX + else: + if app.settings.get('ascii_formatting'): + # Match for URIs + mail + formatting + regex = BASIC_REGEX + else: + # Match only for URIs + formatting + regex = LINK_REGEX + + iterator = regex.finditer(text) + end_iter = buffer_.get_end_iter() + # TODO: Evaluate limit + # Too many fragments (emoticons, LaTeX formulas, etc) + # may cause Gajim to freeze (see #5129). + # We impose an arbitrary limit of 100 fragments per message. + fragment_limit = 100 + index = 0 + for match in iterator: + start, end = match.span() + fragment = text[start:end] + if start > index: + text_before_fragment = text[index:start] + end_iter = buffer_.get_end_iter() + if text_tags: + insert_tags_func( + end_iter, text_before_fragment, *text_tags) + else: + buffer_.insert(end_iter, text_before_fragment) + index = end + + self.apply_formatting(fragment, + text_tags, + graphics=graphics, + additional_data=additional_data) + fragment_limit += 1 + if fragment_limit <= 0: + break + + # Add remaining text after last match + insert_tags_func(buffer_.get_end_iter(), text[index:], *text_tags) + + def apply_formatting(self, fragment, text_tags, graphics=True, + additional_data=None): + # TODO: Plugin system GUI extension point + # self.plugin_modified = False + # app.plugin_manager.extension_point('print_special_text', self, + # fragment, text_tags, graphics, additional_data) + # if self.plugin_modified: + # return + + tags = [] + buffer_ = self.get_buffer() + ttt = buffer_.get_tag_table() + + # Detect XHTML-IM link + is_xhtml_uri = False + tags_ = [ + (ttt.lookup(t) if isinstance(t, str) else t) for t in text_tags] + for tag in tags_: + is_xhtml_uri = getattr(tag, 'href', False) + if is_xhtml_uri: + break + + # Check if we accept this as an uri + is_valid_uri = fragment.startswith(tuple(URI_SCHEMES)) + + end_iter = buffer_.get_end_iter() + + theme = app.settings.get('emoticons_theme') + show_emojis = theme and theme != 'font' + + # XEP-0393 Message Styling + # * = bold + # _ = italic + # ~ = strikethrough + show_formatting_chars = app.settings.get( + 'show_ascii_formatting_chars') + + if show_emojis and graphics and is_emoji(fragment): + # it's an emoticon + if emoji_pixbufs.complete: + # only search for the pixbuf if we are sure + # that loading is completed + pixbuf = get_emoji_pixbuf(fragment) + if pixbuf is None: + buffer_.insert(end_iter, fragment) + else: + pixbuf = pixbuf.copy() + anchor = buffer_.create_child_anchor(end_iter) + anchor.plaintext = fragment + img = Gtk.Image.new_from_pixbuf(pixbuf) + img.show() + self.add_child_at_anchor(img, anchor) + else: + # Set marks and save them so we can replace emojis + # once the loading is complete + start_mark = buffer_.create_mark(None, end_iter, True) + buffer_.insert(end_iter, fragment) + end_mark = buffer_.create_mark(None, end_iter, True) + emoji_pixbufs.append_marks( + self, start_mark, end_mark, fragment) + elif (fragment.startswith('www.') or + fragment.startswith('ftp.') or + is_valid_uri and not is_xhtml_uri): + tags.append('url') + elif fragment.startswith('mailto:') and not is_xhtml_uri: + tags.append('mail') + elif fragment.startswith('xmpp:') and not is_xhtml_uri: + tags.append('xmpp') + elif STH_AT_STH_DOT_STH_REGEX.match(fragment) and not is_xhtml_uri: + # JID or E-Mail + tags.append('sth_at_sth') + elif fragment.startswith('*'): + tags.append('bold') + if (fragment[1] == '~' and fragment[-2] == '~' and + len(fragment) > 4): + tags.append('strikethrough') + if not show_formatting_chars: + fragment = fragment[2:-2] + elif (fragment[1] == '_' and fragment[-2] == '_' and + len(fragment) > 4): + tags.append('italic') + if not show_formatting_chars: + fragment = fragment[2:-2] + else: + if not show_formatting_chars: + fragment = fragment[1:-1] + elif fragment.startswith('~'): + tags.append('strikethrough') + if (fragment[1] == '*' and fragment[-2] == '*' and + len(fragment) > 4): + tags.append('bold') + if not show_formatting_chars: + fragment = fragment[2:-2] + elif (fragment[1] == '_' and fragment[-2] == '_' and + len(fragment) > 4): + tags.append('italic') + if not show_formatting_chars: + fragment = fragment[2:-2] + else: + if not show_formatting_chars: + fragment = fragment[1:-1] + elif fragment.startswith('_'): + tags.append('italic') + if (fragment[1] == '*' and fragment[-2] == '*' and + len(fragment) > 4): + tags.append('bold') + if not show_formatting_chars: + fragment = fragment[2:-2] + elif (fragment[1] == '~' and fragment[-2] == '~' and + len(fragment) > 4): + tags.append('strikethrough') + if not show_formatting_chars: + fragment = fragment[2:-2] + else: + if not show_formatting_chars: + fragment = fragment[1:-1] + else: + insert_tags_func = buffer_.insert_with_tags_by_name + if text_tags and isinstance(text_tags[0], Gtk.TextTag): + insert_tags_func = buffer_.insert_with_tags + if text_tags: + insert_tags_func(end_iter, fragment, *text_tags) + else: + buffer_.insert(end_iter, fragment) + + if tags: + all_tags = tags[:] + all_tags += text_tags + # convert all names to TextTag + all_tags = [ + (ttt.lookup(t) if isinstance(t, str) else t) for t in all_tags] + buffer_.insert_with_tags(end_iter, fragment, *all_tags) + if 'url' in tags: + puny_text = puny_encode_url(fragment) + if puny_text != fragment: + puny_tags = [] + if not puny_text: + puny_text = _('Invalid URL') + puny_tags = [(ttt.lookup(t) if isinstance( + t, str) else t) for t in puny_tags] + puny_tags += text_tags + buffer_.insert_with_tags( + end_iter, " (%s)" % puny_text, *puny_tags) + + def _query_tooltip(self, widget, x_pos, y_pos, _keyboard_mode, tooltip): + window = widget.get_window(Gtk.TextWindowType.TEXT) + x_pos, y_pos = self.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, x_pos, y_pos) + + iter_ = self.get_iter_at_position(x_pos, y_pos)[1] + for tag in iter_.get_tags(): + tag_name = tag.get_property('name') + if getattr(tag, 'is_anchor', False): + text = getattr(tag, 'title', False) + if text: + if len(text) > 50: + text = reduce_chars_newlines(text, 47, 1) + tooltip.set_text(text) + window.set_cursor(get_cursor('pointer')) + self._cursor_changed = True + return True + if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'): + window.set_cursor(get_cursor('pointer')) + self._cursor_changed = True + return False + + if self._cursor_changed: + window.set_cursor(get_cursor('text')) + self._cursor_changed = False + return False + + def _on_button_press(self, _widget, event): + ''' + We don’t open the standard context menu when receiving + a click on tagged text. + If it’s untagged text, check if something is selected + ''' + self._selected_text = '' + + if event.button != 3: + # If it’s not a right click + return False + + x_pos, y_pos = self.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, + int(event.x), + int(event.y)) + iter_ = self.get_iter_at_location(x_pos, y_pos) + if isinstance(iter_, tuple): + iter_ = iter_[1] + tags = iter_.get_tags() + + if tags: + # A tagged text fragment has been clicked + for tag in tags: + tag_name = tag.get_property('name') + if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'): + # Block regular context menu + return True + + # Check if there is a selection and make it available for + # _on_populate_popup + buffer_ = self.get_buffer() + return_val = buffer_.get_selection_bounds() + if return_val: + # Something has been selected, get the text + start_sel, finish_sel = return_val[0], return_val[1] + self._selected_text = buffer_.get_text( + start_sel, finish_sel, True) + elif iter_.get_char() and ord(iter_.get_char()) > 31: + # Clicked on a word, take whole word for selection + start_sel = iter_.copy() + if not start_sel.starts_word(): + start_sel.backward_word_start() + finish_sel = iter_.copy() + if not finish_sel.ends_word(): + finish_sel.forward_word_end() + self._selected_text = buffer_.get_text( + start_sel, finish_sel, True) + return False + + def _on_populate_popup(self, _textview, menu): + ''' + Overrides the default context menu. + If text is selected, a submenu with actions on the selection is added. + (see _on_button_press) + ''' + if not self._selected_text: + menu.show_all() + return + + if not self._history_mode: + item = Gtk.MenuItem.new_with_mnemonic(_('_Quote')) + id_ = item.connect('activate', self._on_quote) + self.handlers[id_] = item + menu.prepend(item) + + selected_text_short = reduce_chars_newlines( + self._selected_text, 25, 2) + item = Gtk.MenuItem.new_with_mnemonic( + _('_Actions for "%s"') % selected_text_short) + menu.prepend(item) + submenu = Gtk.Menu() + item.set_submenu(submenu) + uri_text = quote(self._selected_text.encode('utf-8')) + + if app.settings.get('always_english_wikipedia'): + uri = (f'https://en.wikipedia.org/wiki/' + f'Special:Search?search={uri_text}') + else: + uri = (f'https://{i18n.get_short_lang_code()}.wikipedia.org/' + f'wiki/Special:Search?search={uri_text}') + item = Gtk.MenuItem.new_with_mnemonic(_('Read _Wikipedia Article')) + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + item = Gtk.MenuItem.new_with_mnemonic( + _('Look it up in _Dictionary')) + dict_link = app.settings.get('dictionary_url') + if dict_link == 'WIKTIONARY': + # Default is wikitionary.org + if app.settings.get('always_english_wiktionary'): + uri = (f'https://en.wiktionary.org/wiki/' + f'Special:Search?search={uri_text}') + else: + uri = (f'https://{i18n.get_short_lang_code()}.wiktionary.org/' + f'wiki/Special:Search?search={uri_text}') + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + else: + if dict_link.find('%s') == -1: + # There has to be a '%s' in the url if it’s not WIKTIONARY + item = Gtk.MenuItem.new_with_label( + _('Dictionary URL is missing a "%s"')) + item.set_sensitive(False) + else: + uri = dict_link % uri_text + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + search_link = app.settings.get('search_engine') + if search_link.find('%s') == -1: + # There has to be a '%s' in the url + item = Gtk.MenuItem.new_with_label( + _('Web Search URL is missing a "%s"')) + item.set_sensitive(False) + else: + item = Gtk.MenuItem.new_with_mnemonic(_('Web _Search for it')) + uri = search_link % uri_text + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link')) + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + menu.show_all() + + def _on_quote(self, _widget): + self.emit('quote', self._selected_text) + + @staticmethod + def _visit_uri(_widget, uri): + open_uri(uri) diff --git a/gajim/gtk/conversation_view.py b/gajim/gtk/conversation_view.py new file mode 100644 index 0000000000000000000000000000000000000000..c6fdf3cdde01aeed7f57a660accfc20fe3ef5f5d --- /dev/null +++ b/gajim/gtk/conversation_view.py @@ -0,0 +1,977 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see <http://www.gnu.org/licenses/>. + +import logging +import time + +from bisect import bisect_left +from bisect import bisect_right +from collections import deque +from datetime import datetime +from datetime import timedelta + +from gi.repository import Gdk +from gi.repository import GLib +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Pango + +from gajim.common import app +from gajim.common.const import AvatarSize +from gajim.common.const import TRUST_SYMBOL_DATA +from gajim.common.helpers import from_one_line +from gajim.common.helpers import reduce_chars_newlines +from gajim.common.helpers import to_user_string +from gajim.common.i18n import _ +from gajim.common.i18n import Q_ + +from .conversation_textview import ConversationTextview +from .util import convert_rgba_to_hex +from .util import format_fingerprint +from .util import scroll_to_end +from .util import text_to_color + +log = logging.getLogger('gajim.gui.conversation_view') + + +class ConversationView(Gtk.ListBox): + + __gsignals__ = { + 'quote': ( + GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, + None, + (str, ) + ), + 'load-history': ( + GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, + None, + (str, ) + ) + } + + def __init__(self, account, contact, history_mode=False): + Gtk.ListBox.__init__(self) + self.set_selection_mode(Gtk.SelectionMode.NONE) + self._account = account + self._client = app.get_client(account) + self._contact = contact + self._history_mode = history_mode + + self.encryption_enabled = False + self.autoscroll = True + self.clearing = False + + # Both first and last DateRow (datetime) + self._first_date = None + self._last_date = None + + # Keeps track of the number of rows shown in ConversationView + self._row_count = 0 + self._max_row_count = 100 + + # Keeps inserted message IDs to avoid re-inserting the same message + self._message_ids_inserted = {} + + # We keep a sorted array of all the timestamps we've inserted, which + # have a 1-to-1 mapping to the actual child elements of this ListBox + # (*all* rows are included). + # Binary-searching this array enables us to insert a message with a + # discontinuous timestamp (not less than or greater than the first or + # last message timestamp in the list), and insert it at the correct + # place in the ListBox. + self._timestamps_inserted = deque() + + # Stores the timestamp of the first *chat* row inserted + # (not a system row or date row) + self.first_message_timestamp = None + + # Last incoming chat message timestamp (used for ReadMarkerRows) + self._last_incoming_timestamp = datetime.fromtimestamp(0) + + # Insert the very first row, containing the scroll hint and load button + self.add(ScrollHintRow(self._account)) + self._timestamps_inserted.append(datetime.fromtimestamp(0)) + + def clear(self): + self.clearing = True + for row in self.get_children()[1:]: + self.remove(row) + + GLib.idle_add(self._reset_conversation_view) + + def _reset_conversation_view(self): + self._first_date = None + self._last_date = None + self._message_ids_inserted.clear() + self.first_message_timestamp = None + self._last_incoming_timestamp = datetime.fromtimestamp(0) + self._timestamps_inserted.clear() + self._row_count = 0 + self.clearing = False + + def add_message(self, + text, + kind, + name, + timestamp, + other_text_tags=None, + message_id=None, + correct_id=None, + display_marking=None, + additional_data=None, + subject=None, + marker=None, + error=None, + history=False, + graphics=True): + + log.debug( + 'Adding message: %s, %s, %s, %s, message_id: %s, correct_id: %s, ' + 'other_text_tags: %s, display_marking: %s, additional_data: %s, ' + 'subject: %s, marker: %s, error: %s, history: %s, graphics: %s', + text, kind, name, timestamp, message_id, correct_id, + other_text_tags, display_marking, additional_data, subject, + marker, error, history, graphics) + + if message_id: + if message_id in self._message_ids_inserted: + log.warning('Rejecting insertion of duplicate message_id %s', + str(message_id)) + return + self._message_ids_inserted[message_id] = True + + if not timestamp: + timestamp = time.time() + time_ = datetime.fromtimestamp(timestamp) + + if other_text_tags is None: + other_text_tags = [] + + if kind in ('status', 'info') or subject: + message = InfoMessageRow( + self._account, + time_, + text, + other_text_tags, + kind, + subject, + graphics, + history_mode=self._history_mode) + else: + if correct_id: + self.correct_message( + correct_id, + message_id, + text, + other_text_tags, + kind, + name, + additional_data=additional_data) + return + + avatar = self._get_avatar(kind, name) + message = TextMessageRow( + self._account, + message_id, + time_, + kind, + name, + text, + other_text_tags, + avatar, + self._contact.is_groupchat, + additional_data=additional_data, + display_marking=display_marking, + marker=marker, + error=error, + encryption_enabled=self.encryption_enabled, + history_mode=self._history_mode) + + self._insert_message(message, time_, kind, history) + + # Check for maximum message count + if self.autoscroll and self._row_count > self._max_row_count: + self._reduce_message_count() + + def _get_avatar(self, kind, name): + scale = self.get_scale_factor() + if self._contact.is_groupchat: + contact = self._contact.get_resource(name) + return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False) + + if kind == 'outgoing': + contact = self._client.get_module('Contacts').get_contact( + str(self._client.get_own_jid().bare)) + else: + contact = self._contact + + return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False) + + def _insert_message(self, message, time_, kind, history): + current_date = time_.strftime('%a, %d %b %Y') + + if self._is_out_of_order(time_, history): + insertion_point = bisect_left(self._timestamps_inserted, time_) + date_check_point = min(len( + self._timestamps_inserted) - 1, insertion_point - 1) + date_at_dcp = self._timestamps_inserted[date_check_point].strftime( + '%a, %d %b %Y') + if date_at_dcp != current_date: + associated_timestamp = time_ - timedelta( + hours=time_.hour, + minutes=time_.minute, + seconds=time_.second) + date_row = DateRow( + self._account, current_date, associated_timestamp) + self.insert(date_row, insertion_point) + self._timestamps_inserted.insert( + insertion_point, associated_timestamp) + self._row_count += 1 + insertion_point += 1 + if (kind in ('incoming', 'incoming_queue') and + time_ > self._last_incoming_timestamp): + self._last_incoming_timestamp = time_ + self.insert(message, insertion_point) + self._timestamps_inserted.insert(insertion_point, time_) + current_timestamp = time_ + self._row_count += 1 + elif history: + if current_date != self._first_date: + associated_timestamp = time_ - timedelta( + hours=time_.hour, + minutes=time_.minute, + seconds=time_.second) + date_row = DateRow( + self._account, current_date, associated_timestamp) + self.insert(date_row, 1) + self._timestamps_inserted.insert(1, associated_timestamp) + self._row_count += 1 + self._first_date = current_date + if kind in ('incoming', 'incoming_queue', 'outgoing'): + self.first_message_timestamp = time_ + if (kind in ('incoming', 'incoming_queue') and + time_ > self._last_incoming_timestamp): + self._last_incoming_timestamp = time_ + self.insert(message, 2) + self._timestamps_inserted.insert(2, time_) + if self._last_date is None: + self._last_date = current_date + current_timestamp = time_ + self._row_count += 1 + else: + if current_date != self._last_date: + associated_timestamp = time_ - timedelta( + hours=time_.hour, + minutes=time_.minute, + seconds=time_.second) + date_row = DateRow( + self._account, current_date, associated_timestamp) + self.add(date_row) + self._timestamps_inserted.append(associated_timestamp) + self._row_count += 1 + if self._first_date is None: + self._first_date = current_date + if (kind in ('incoming', 'incoming_queue', 'outgoing') and not + self.first_message_timestamp): + self.first_message_timestamp = time_ + if (kind in ('incoming', 'incoming_queue') and + time_ > self._last_incoming_timestamp): + self._last_incoming_timestamp = time_ + self._last_date = current_date + self.add(message) + self._timestamps_inserted.append(time_) + current_timestamp = time_ + self._row_count += 1 + + if message.type == 'chat': + self._merge_message(current_timestamp) + self._update_read_marker(current_timestamp) + + self.show_all() + + def _is_out_of_order(self, time_: datetime, history: bool) -> bool: + if history: + if self.first_message_timestamp: + return time_ > self.first_message_timestamp + return False + if len(self._timestamps_inserted) > 1: + return time_ < self._timestamps_inserted[-1] + return False + + def _merge_message(self, timestamp): + # 'Merge' message rows if they both meet certain conditions + # (see _is_mergeable). A merged message row does not display any + # avatar or meta info, and makes it look merged with the previous row. + if self._contact.is_groupchat: + return + + current_index = self._timestamps_inserted.index(timestamp) + previous_row = self.get_row_at_index(current_index - 1) + current_row = self.get_row_at_index(current_index) + next_row = self.get_row_at_index(current_index + 1) + if next_row is not None: + if self._is_mergeable(current_row, next_row): + next_row.set_merged(True) + + if self._is_mergeable(current_row, previous_row): + current_row.set_merged(True) + if next_row is not None: + if self._is_mergeable(current_row, next_row): + next_row.set_merged(True) + + @staticmethod + def _is_mergeable(row1, row2): + # TODO: Check for same encryption + timestamp1 = row1.timestamp.strftime('%H:%M') + timestamp2 = row2.timestamp.strftime('%H:%M') + kind1 = row1.kind + kind2 = row2.kind + if timestamp1 == timestamp2 and kind1 == kind2: + return True + return False + + def _reduce_message_count(self): + while self._row_count > self._max_row_count: + # We want to keep relevant DateRows when removing rows + row1 = self.get_row_at_index(1) + row2 = self.get_row_at_index(2) + + if row1.type == row2.type == 'date': + # First two rows are date rows, + # it’s safe to remove the fist row + self.remove(row1) + self._timestamps_inserted.remove(row1.timestamp) + self._first_date = row2.timestamp.strftime('%a, %d %b %Y') + self._row_count -= 1 + continue + + if row1.type == 'date' and row2.type != 'date': + # First one is a date row, keep it and + # remove the second row instead + self.remove(row2) + self._timestamps_inserted.remove(row2.timestamp) + if row2.message_id: + self._message_ids_inserted.pop(row2.message_id) + chat_row = self._get_first_chat_row() + if chat_row is not None: + self.first_message_timestamp = chat_row.timestamp + else: + self.first_message_timestamp = None + self._row_count -= 1 + continue + + if row1.type != 'date': + # Not a date row, safe to remove + self.remove(row1) + self._timestamps_inserted.remove(row1.timestamp) + if row1.message_id: + self._message_ids_inserted.pop(row1.message_id) + if row2.type == 'chat': + self.first_message_timestamp = row2.timestamp + else: + chat_row = self._get_first_chat_row() + if chat_row is not None: + self.first_message_timestamp = chat_row.timestamp + else: + self.first_message_timestamp = None + self._row_count -= 1 + + def _get_row_by_id(self, id_): + for row in self.get_children(): + if row.message_id == id_: + return row + return None + + def _get_first_chat_row(self): + for row in self.get_children(): + if row.type == 'chat': + return row + return None + + def set_read_marker(self, id_): + message_row = self._get_row_by_id(id_) + if message_row is None: + return + + message_row.set_displayed() + self._update_read_marker(message_row.timestamp) + + def _update_read_marker(self, current_timestamp): + marker_shown = False + + for row in self.get_children(): + if row.type == 'read_marker': + # We already have a ReadMarkerRow, decide if we keep it + marker_shown = True + + if self._last_incoming_timestamp > row.timestamp: + # Last incoming message is newer than read marker + self.remove(row) + self._timestamps_inserted.remove(row.timestamp) + marker_shown = False + break + + if self._last_incoming_timestamp > current_timestamp: + # Last incoming message is newer than current message + break + + if current_timestamp > row.timestamp: + # Message is newer than current ReadMarkerRow + current_row = self.get_row_at_index( + self._timestamps_inserted.index(current_timestamp)) + if current_row.has_displayed: + # Current row has a displayed marker, which means + # that the current ReadMarkerRow is out of date + self.remove(row) + self._timestamps_inserted.remove(row.timestamp) + marker_shown = False + break + break + + if self._last_incoming_timestamp >= current_timestamp: + # Don’t add ReadMarkerRow if last incoming message is newer + return + + if marker_shown: + # There is a ReadMarkerRow which has not been removed by previous + # rules, thus it’s the most current one (nothing more to do) + return + + current_row = self.get_row_at_index( + self._timestamps_inserted.index(current_timestamp)) + if current_row.type != 'chat': + return + + if current_row.has_displayed: + # Add a new ReadMarkerRow, if there is a marker for the current row + self._insert_read_marker(current_timestamp) + + def _insert_read_marker(self, timestamp): + insertion_point = bisect_right( + self._timestamps_inserted, timestamp) + read_marker_row = ReadMarkerRow( + self._account, self._contact, timestamp) + self.insert(read_marker_row, insertion_point) + self._timestamps_inserted.insert(insertion_point, timestamp) + + def update_avatars(self): + for row in self.get_children(): + if row.type == 'chat': + avatar = self._get_avatar(row.kind, row.name) + row.update_avatar(avatar) + + def update_text_tags(self): + for row in self.get_children(): + row.update_text_tags() + + def scroll_to_end(self, force=False): + if self.autoscroll or force: + GLib.idle_add(scroll_to_end, self.get_parent().get_parent()) + + def correct_message(self, correct_id, message_id, text, + other_text_tags, kind, name, additional_data=None): + message_row = self._get_row_by_id(correct_id) + if message_row is not None: + message_row.set_correction( + message_id, text, other_text_tags, kind, name, + additional_data=additional_data) + message_row.set_merged(False) + + def show_receipt(self, id_): + message_row = self._get_row_by_id(id_) + if message_row is not None: + message_row.set_receipt() + + def show_error(self, id_, error): + message_row = self._get_row_by_id(id_) + if message_row is not None: + message_row.set_error(to_user_string(error)) + message_row.set_merged(False) + + def on_quote(self, text): + self.emit('quote', text) + + # TODO: focus_out_line for group chats + + +class ConversationRow(Gtk.ListBoxRow): + def __init__(self, account, widget='label', history_mode=False): + Gtk.ListBoxRow.__init__(self) + self.type = '' + self.timestamp = None + self.kind = None + self.name = None + self.message_id = None + self.text = '' + + self.get_style_context().add_class('conversation-row') + + self.grid = Gtk.Grid(row_spacing=3, column_spacing=12) + self.add(self.grid) + + if widget == 'textview': + self.label = None + self.textview = ConversationTextview( + account, history_mode=history_mode) + else: + self.textview = None + self.label = Gtk.Label() + self.label.set_selectable(True) + self.label.set_line_wrap(True) + self.label.set_xalign(0) + self.label.set_line_wrap_mode( + Pango.WrapMode.WORD_CHAR) + + def update_text_tags(self): + if self.textview is not None: + self.textview.update_text_tags() + + @staticmethod + def create_timestamp_widget(timestamp: datetime) -> Gtk.Label: + # TODO: maybe change default to '%H:%M' + time_format = from_one_line(app.settings.get('time_stamp')) + timestamp_formatted = timestamp.strftime(time_format) + label = Gtk.Label(label=timestamp_formatted) + label.set_halign(Gtk.Align.START) + label.set_valign(Gtk.Align.END) + label.get_style_context().add_class('conversation-meta') + label.set_tooltip_text(timestamp.strftime('%a, %d %b %Y - %H:%M:%S')) + return label + + @staticmethod + def create_name_widget(name: str, kind: str, + is_groupchat: bool) -> Gtk.Label: + label = Gtk.Label() + label.set_selectable(True) + label.get_style_context().add_class('conversation-nickname') + + # TODO: Maybe set default for 'after_nickname' to empty string + before_name = from_one_line(app.settings.get('before_nickname')) + after_name = from_one_line(app.settings.get('after_nickname')) + new_name = f'{before_name}{GLib.markup_escape_text(name)}{after_name}' + + if is_groupchat: + rgba = Gdk.RGBA(*text_to_color(name)) + nick_color = convert_rgba_to_hex(rgba) + label.set_markup( + f'<span foreground="{nick_color}">{new_name}</span>') + else: + if kind in ('incoming', 'incoming_queue'): + label.get_style_context().add_class( + 'gajim-incoming-nickname') + elif kind == 'outgoing': + label.get_style_context().add_class( + 'gajim-outgoing-nickname') + label.set_markup(new_name) + return label + + +class ScrollHintRow(ConversationRow): + def __init__(self, account): + ConversationRow.__init__(self, account) + self.type = 'system' + self.timestamp = datetime.fromtimestamp(0) + self.get_style_context().add_class('conversation-system-row') + + self._button = Gtk.Button.new_from_icon_name( + 'go-up-symbolic', Gtk.IconSize.BUTTON) + self._button.set_tooltip_text(_('Load more messages')) + self._button.connect('clicked', self._on_load_history) + self.grid.attach(self._button, 0, 0, 1, 1) + + self.label.set_text(_('Scroll up to load more chat history…')) + self.label.set_halign(Gtk.Align.CENTER) + self.label.set_hexpand(True) + self.label.get_style_context().add_class( + 'conversation-meta') + self.grid.attach(self.label, 0, 1, 1, 1) + + def _on_load_history(self, _button): + self.get_parent().emit('load-history', 30) + + +class ReadMarkerRow(ConversationRow): + def __init__(self, account, contact, timestamp): + ConversationRow.__init__(self, account) + self.type = 'read_marker' + self.timestamp = timestamp + + text = _('%s has read up to this point') % contact.name + self.label.set_text(text) + self.label.set_halign(Gtk.Align.CENTER) + self.label.set_hexpand(True) + self.label.get_style_context().add_class( + 'conversation-read-marker') + self.grid.attach(self.label, 0, 0, 1, 1) + self.show_all() + + +class DateRow(ConversationRow): + def __init__(self, account, date_string, timestamp): + ConversationRow.__init__(self, account) + self.type = 'date' + self.timestamp = timestamp + self.get_style_context().add_class('conversation-date-row') + + self.label.set_text(date_string) + self.label.set_halign(Gtk.Align.CENTER) + self.label.set_hexpand(True) + self.label.get_style_context().add_class( + 'conversation-meta') + self.grid.attach(self.label, 0, 0, 1, 1) + + +class InfoMessageRow(ConversationRow): + def __init__(self, + account, + timestamp, + text, + other_text_tags, + kind, + subject, + graphics, + history_mode=False): + ConversationRow.__init__(self, account, widget='textview', + history_mode=history_mode) + self.type = 'info' + self.timestamp = timestamp + self.kind = kind + + if subject: + subject_title = _('Subject:') + text = (f'{subject_title}\n' + f'{GLib.markup_escape_text(subject)}\n' + f'{GLib.markup_escape_text(text)}') + else: + text = GLib.markup_escape_text(text) + + other_text_tags.append('status') + + avatar_placeholder = Gtk.Box() + avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1) + self.grid.attach(avatar_placeholder, 0, 0, 1, 2) + timestamp_widget = self.create_timestamp_widget(timestamp) + timestamp_widget.set_valign(Gtk.Align.START) + self.grid.attach(timestamp_widget, 2, 0, 1, 1) + + self.textview.set_justification(Gtk.Justification.CENTER) + self.textview.print_text( + text, + other_text_tags=other_text_tags, + kind=kind, + graphics=graphics) + + self.grid.attach(self.textview, 1, 0, 1, 1) + + +class TextMessageRow(ConversationRow): + def __init__(self, + account, + message_id, + timestamp, + kind, + name, + text, + other_text_tags, + avatar, + is_groupchat, + additional_data=None, + display_marking=None, + marker=None, + error=None, + encryption_enabled=False, + history_mode=False): + + # other_tags_for_name contained 'marked', 'bold' and + # 'muc_nickname_color_', which are now derived from + # other_text_tags ('marked') + + # other_tags_for_time were always empty? + + ConversationRow.__init__(self, account, widget='textview', + history_mode=history_mode) + self.type = 'chat' + self.timestamp = timestamp + self.message_id = message_id + self.kind = kind + self.name = name or '' + self.text = text + + self._corrections = [] + self._has_receipt = marker == 'received' + self._has_displayed = marker == 'displayed' + self._button_icon = None + + if is_groupchat: + if other_text_tags and 'marked' in other_text_tags: + self.get_style_context().add_class( + 'conversation-mention-highlight') + + self.textview.connect('quote', self._on_quote_selection) + self.textview.print_text( + text, + other_text_tags=other_text_tags, + kind=kind, + name=name, + additional_data=additional_data) + + self._meta_box = Gtk.Box(spacing=6) + self._meta_box.pack_start( + self.create_name_widget(name, kind, is_groupchat), False, True, 0) + timestamp_label = self.create_timestamp_widget(timestamp) + timestamp_label.set_margin_start(6) + self._meta_box.pack_end(timestamp_label, False, True, 0) + # TODO: implement app.settings.get('print_time') 'always', 'sometimes'? + + if kind in ('incoming', 'incoming_queue', 'outgoing'): + encryption_img = self._get_encryption_image( + additional_data, encryption_enabled) + if encryption_img: + self._meta_box.pack_end(encryption_img, False, True, 0) + + if display_marking: + label_text = GLib.markup_escape_text(display_marking.name) + if label_text: + bgcolor = display_marking.bgcolor + fgcolor = display_marking.fgcolor + label_text = ( + f'<span size="small" bgcolor="{bgcolor}" ' + f'fgcolor="{fgcolor}"><tt>[{label_text}]</tt></span>') + display_marking_label = Gtk.Label() + display_marking_label.set_markup(label_text) + self._meta_box.add(display_marking_label) + + self._message_icons = MessageIcons() + + if error is not None: + self.set_error(to_user_string(error)) + + if marker is not None: + if marker in ('received', 'displayed'): + self.set_receipt() + + self._meta_box.pack_end(self._message_icons, False, True, 0) + + self._avatar_surface = Gtk.Image.new_from_surface(avatar) + avatar_placeholder = Gtk.Box() + avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1) + avatar_placeholder.set_valign(Gtk.Align.START) + avatar_placeholder.add(self._avatar_surface) + + bottom_box = Gtk.Box(spacing=6) + bottom_box.add(self.textview) + bottom_box.add(self._create_more_button()) + + self.grid.attach(avatar_placeholder, 0, 0, 1, 2) + self.grid.attach(self._meta_box, 1, 0, 1, 1) + self.grid.attach(bottom_box, 1, 1, 1, 1) + + def _create_more_button(self): + more_button = Gtk.MenuButton() + more_button.connect( + 'enter-notify-event', self._on_more_button_enter) + more_button.connect( + 'leave-notify-event', self._on_more_button_leave) + more_button.set_valign(Gtk.Align.START) + more_button.set_halign(Gtk.Align.END) + self._button_icon = Gtk.Image.new_from_icon_name( + 'view-more-symbolic', Gtk.IconSize.BUTTON) + self._button_icon.set_no_show_all(True) + more_button.add(self._button_icon) + more_button.set_relief(Gtk.ReliefStyle.NONE) + + menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + menu_box.get_style_context().add_class('padding-6') + + quote_button = Gtk.ModelButton() + quote_button.set_halign(Gtk.Align.START) + quote_button.connect('clicked', self._on_quote_message) + quote_button.set_label(_('Quote…')) + quote_button.set_image(Gtk.Image.new_from_icon_name( + 'mail-reply-sender-symbolic', Gtk.IconSize.MENU)) + menu_box.add(quote_button) + + copy_button = Gtk.ModelButton() + copy_button.set_halign(Gtk.Align.START) + copy_button.connect('clicked', self._on_copy_message) + copy_button.set_label(_('Copy')) + copy_button.set_image(Gtk.Image.new_from_icon_name( + 'edit-copy-symbolic', Gtk.IconSize.MENU)) + menu_box.add(copy_button) + + menu_box.show_all() + + popover = Gtk.PopoverMenu() + popover.add(menu_box) + more_button.set_popover(popover) + return more_button + + def _on_copy_message(self, _widget): + timestamp = self.timestamp.strftime('%x, %X') + clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clip.set_text( + f'{timestamp} - {self.name}: {self.textview.get_text()}', -1) + + def _on_quote_message(self, _widget): + self.get_parent().on_quote(self.textview.get_text()) + + def _on_more_button_enter(self, _widget, _event): + self._button_icon.show() + + def _on_more_button_leave(self, widget, _event): + if not widget.get_active(): + self._button_icon.hide() + + def _get_encryption_image(self, additional_data, encryption_enabled=None): + details = self._get_encryption_details(additional_data) + if details is None: + # Message was not encrypted + if not encryption_enabled: + return None + icon = 'channel-insecure-symbolic' + color = 'unencrypted-color' + tooltip = _('Not encrypted') + else: + name, fingerprint, trust = details + tooltip = _('Encrypted (%s)') % (name) + if trust is None: + # The encryption plugin did not pass trust information + icon = 'channel-secure-symbolic' + color = 'encrypted-color' + else: + icon, trust_tooltip, color = TRUST_SYMBOL_DATA[trust] + tooltip = '%s\n%s' % (tooltip, trust_tooltip) + if fingerprint is not None: + fingerprint = format_fingerprint(fingerprint) + tooltip = '%s\n<tt>%s</tt>' % (tooltip, fingerprint) + + image = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) + image.set_tooltip_markup(tooltip) + image.get_style_context().add_class(color) + image.show() + return image + + @staticmethod + def _get_encryption_details(additional_data): + name = additional_data.get_value('encrypted', 'name') + if name is None: + return None + + fingerprint = additional_data.get_value('encrypted', 'fingerprint') + trust = additional_data.get_value('encrypted', 'trust') + return name, fingerprint, trust + + def _on_quote_selection(self, _widget, text): + self.get_parent().on_quote(text) + + @property + def has_receipt(self): + return self._has_receipt + + @property + def has_displayed(self): + return self._has_displayed + + def set_receipt(self): + self._has_receipt = True + self._message_icons.set_receipt_icon_visible(True) + + def set_displayed(self): + self._has_displayed = True + + def set_correction(self, message_id, text, other_text_tags, kind, name, + additional_data=None): + self._corrections.append(self.textview.get_text()) + self.textview.clear() + self._has_receipt = False + self._message_icons.set_receipt_icon_visible(False) + self._message_icons.set_correction_icon_visible(True) + + self.textview.print_text( + text, + other_text_tags=other_text_tags, + kind=kind, + name=name, + additional_data=additional_data) + + corrections = '\n'.join(line for line in self._corrections) + corrections = reduce_chars_newlines( + corrections, max_chars=150, max_lines=10) + self._message_icons.set_correction_tooltip( + _('Message corrected. Original message:\n%s') % corrections) + # Update message_id for this row + self.message_id = message_id + + def set_error(self, tooltip): + self._message_icons.set_error_icon_visible(True) + self._message_icons.set_error_tooltip(tooltip) + + def update_avatar(self, avatar): + self._avatar_surface.set_from_surface(avatar) + + def set_merged(self, merged): + if merged: + self._avatar_surface.set_no_show_all(True) + self._avatar_surface.hide() + self._meta_box.set_no_show_all(True) + self._meta_box.hide() + else: + self._avatar_surface.set_no_show_all(False) + self._avatar_surface.show() + self._meta_box.set_no_show_all(False) + self._meta_box.show() + + +class MessageIcons(Gtk.Box): + def __init__(self): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) + + self._correction_image = Gtk.Image.new_from_icon_name( + 'document-edit-symbolic', Gtk.IconSize.MENU) + self._correction_image.set_no_show_all(True) + self._correction_image.get_style_context().add_class('dim-label') + + self._marker_image = Gtk.Image() + self._marker_image.set_no_show_all(True) + self._marker_image.get_style_context().add_class('dim-label') + + self._error_image = Gtk.Image.new_from_icon_name( + 'dialog-warning-symbolic', Gtk.IconSize.MENU) + self._error_image.get_style_context().add_class('warning-color') + self._error_image.set_no_show_all(True) + + self.add(self._correction_image) + self.add(self._marker_image) + self.add(self._error_image) + self.show_all() + + def set_receipt_icon_visible(self, visible): + if not app.settings.get('positive_184_ack'): + return + self._marker_image.set_visible(visible) + self._marker_image.set_from_icon_name( + 'feather-check-symbolic', Gtk.IconSize.MENU) + self._marker_image.set_tooltip_text(Q_('?Message state:Received')) + + def set_correction_icon_visible(self, visible): + self._correction_image.set_visible(visible) + + def set_correction_tooltip(self, text): + self._correction_image.set_tooltip_markup(text) + + def set_error_icon_visible(self, visible): + self._error_image.set_visible(visible) + + def set_error_tooltip(self, text): + self._error_image.set_tooltip_markup(text)