From 75f57a5cc08af3ca40956c9d23b1effd9331aa02 Mon Sep 17 00:00:00 2001 From: wurstsalat Date: Sat, 3 Dec 2022 00:24:15 +0100 Subject: feat: Add support for XEP-0461: Message Replies --- data/gajim.doap | 7 + gajim/common/client.py | 1 + gajim/common/events.py | 2 + gajim/common/modules/message.py | 17 +- gajim/common/storage/archive.py | 59 +++++- gajim/common/storage/base.py | 17 ++ gajim/common/structs.py | 3 + gajim/common/util/text.py | 20 ++ gajim/data/gui/message_actions_box.ui | 333 +++++++++++++++++---------------- gajim/data/style/gajim.css | 6 + gajim/gtk/builder.pyi | 1 + gajim/gtk/chat_list_row.py | 7 + gajim/gtk/chat_stack.py | 12 +- gajim/gtk/const.py | 2 + gajim/gtk/control.py | 53 +++++- gajim/gtk/conversation/rows/message.py | 21 ++- gajim/gtk/conversation/view.py | 7 +- gajim/gtk/menus.py | 19 +- gajim/gtk/message_actions_box.py | 70 ++++++- gajim/gtk/message_input.py | 10 +- gajim/gtk/referred_message_widget.py | 93 +++++++++ 21 files changed, 576 insertions(+), 184 deletions(-) create mode 100644 gajim/gtk/referred_message_widget.py diff --git a/data/gajim.doap b/data/gajim.doap index aa6c390d3..a2ee6153b 100644 --- a/data/gajim.doap +++ b/data/gajim.doap @@ -693,5 +693,12 @@ 0.1.0 + + + + complete + 0.1.0 + + diff --git a/gajim/common/client.py b/gajim/common/client.py index 626e65612..caef18e40 100644 --- a/gajim/common/client.py +++ b/gajim/common/client.py @@ -518,6 +518,7 @@ class Client(Observable): label=message.label, correct_id=message.correct_id, message_id=message.message_id, + reply_data=message.reply_data, msg_log_id=log_line_id, play_sound=message.play_sound)) diff --git a/gajim/common/events.py b/gajim/common/events.py index b7a6dbb4a..7a09622f2 100644 --- a/gajim/common/events.py +++ b/gajim/common/events.py @@ -33,6 +33,7 @@ from nbxmpp.protocol import JID from nbxmpp.structs import HTTPAuthData from nbxmpp.structs import LocationData from nbxmpp.structs import ModerationData +from nbxmpp.structs import ReplyData from nbxmpp.structs import RosterItem from nbxmpp.structs import TuneData @@ -174,6 +175,7 @@ class MessageSent(ApplicationEvent): additional_data: AdditionalDataDict label: SecurityLabel | None correct_id: str | None + reply_data: ReplyData | None play_sound: bool diff --git a/gajim/common/modules/message.py b/gajim/common/modules/message.py index c94214f10..f5b72015a 100644 --- a/gajim/common/modules/message.py +++ b/gajim/common/modules/message.py @@ -230,7 +230,8 @@ class Message(BaseModule): subject=properties.subject, additional_data=additional_data, stanza_id=stanza_id or message_id, - message_id=properties.id) + message_id=properties.id, + reply_data=properties.reply_data) event.msg_log_id = msg_log_id app.ged.raise_event(event) @@ -279,7 +280,8 @@ class Message(BaseModule): stanza_id=event.stanza_id, message_id=event.properties.id, occupant_id=event.occupant_id, - real_Jid=event.real_jid) + real_Jid=event.real_jid, + reply_data=event.properties.reply_data) def _check_for_mam_compliance(self, room_jid: str, stanza_id: str) -> None: disco_info = app.storage.cache.get_last_disco_info(room_jid) @@ -342,6 +344,14 @@ class Message(BaseModule): stanza.setTag('replace', attrs={'id': message.correct_id}, namespace=Namespace.CORRECT) + if message.reply_data is not None: + assert message.reply_data.fallback_start is not None + assert message.reply_data.fallback_end is not None + stanza.setReply(str(message.jid), + message.reply_data.id, + message.reply_data.fallback_start, + message.reply_data.fallback_end) + # XEP-0359 message.message_id = generate_id() stanza.setID(message.message_id) @@ -430,5 +440,6 @@ class Message(BaseModule): subject=message.subject, additional_data=message.additional_data, message_id=message.message_id, - stanza_id=message.message_id) + stanza_id=message.message_id, + reply_data=message.reply_data) return msg_log_id diff --git a/gajim/common/storage/archive.py b/gajim/common/storage/archive.py index 3e52c3a4f..2ec8c41e1 100644 --- a/gajim/common/storage/archive.py +++ b/gajim/common/storage/archive.py @@ -38,9 +38,11 @@ from collections.abc import KeysView from nbxmpp import JID from nbxmpp.structs import CommonError from nbxmpp.structs import MessageProperties +from nbxmpp.structs import ReplyData from gajim.common import app from gajim.common import configpaths +from gajim.common import types from gajim.common.const import JIDConstant from gajim.common.const import KindConstant from gajim.common.const import MAX_MESSAGE_CORRECTION_DELAY @@ -111,6 +113,7 @@ class ConversationRow(NamedTuple): stanza_id: str message_id: str marker: str + reply_data: ReplyData | None class LastConversationRow(NamedTuple): @@ -121,6 +124,15 @@ class LastConversationRow(NamedTuple): additional_data: AdditionalDataDict | None message_id: str stanza_id: str + reply_data: ReplyData | None + + +class ReferredMessageRow(NamedTuple): + log_line_id: int + contact_name: str + kind: int + time: float + message: str class SearchLogRow(NamedTuple): @@ -268,6 +280,13 @@ class MessageArchiveStorage(SqliteStorage): ] self._execute_multiple(statements) + if user_version < 8: + statements = [ + 'ALTER TABLE logs ADD COLUMN "reply_data" TEXT', + 'PRAGMA user_version=8' + ] + self._execute_multiple(statements) + @staticmethod def _like(search_str: str) -> str: return f'%{search_str}%' @@ -394,7 +413,8 @@ class MessageArchiveStorage(SqliteStorage): additional_data, stanza_id, message_id, - marker as "marker [marker]" + marker as "marker [marker]", + reply_data as "reply_data [reply_data]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND kind NOT IN ({kinds}) @@ -433,7 +453,7 @@ class MessageArchiveStorage(SqliteStorage): sql = ''' SELECT contact_name, time, kind, message, stanza_id, message_id, - additional_data + additional_data, reply_data as "reply_data [reply_data]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND kind NOT IN ({kinds}) @@ -480,7 +500,8 @@ class MessageArchiveStorage(SqliteStorage): additional_data, stanza_id, message_id, - marker as "marker [marker]" + marker as "marker [marker]", + reply_data as "reply_data [reply_data]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND kind NOT IN ({kinds}) @@ -503,7 +524,8 @@ class MessageArchiveStorage(SqliteStorage): additional_data, stanza_id, message_id, - marker as "marker [marker]" + marker as "marker [marker]", + reply_data as "reply_data [reply_data]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND kind NOT IN ({kinds}) @@ -557,7 +579,8 @@ class MessageArchiveStorage(SqliteStorage): additional_data, stanza_id, message_id, - marker as "marker [marker]" + marker as "marker [marker]", + reply_data as "reply_data [reply_data]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND kind NOT IN ({kinds}) @@ -996,7 +1019,7 @@ class MessageArchiveStorage(SqliteStorage): sql = ''' SELECT contact_name, time, kind, message, stanza_id, message_id, - additional_data + additional_data, reply_data as "reply_data [reply_data]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND message_id = ? @@ -1084,6 +1107,30 @@ class MessageArchiveStorage(SqliteStorage): return True + @timeit + def get_referred_message(self, + contact: types.ChatContactT, + message_id: str + ) -> ReferredMessageRow | None: + + jids = [contact.jid] + account_id = self.get_account_id(contact.account) + message_id_type = 'message_id' + if contact.is_groupchat: + message_id_type = 'stanza_id' + + sql = ''' + SELECT log_line_id, contact_name, kind, time, message + FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) + AND account_id = {account_id} + AND {message_id_type} = ? + '''.format(jids=', '.join('?' * len(jids)), + account_id=account_id, + message_id_type=message_id_type) + return self._con.execute( + sql, + tuple(jids) + (message_id, )).fetchone() + @timeit def update_additional_data(self, account: str, diff --git a/gajim/common/storage/base.py b/gajim/common/storage/base.py index 107c1b65d..9037a6e1b 100644 --- a/gajim/common/storage/base.py +++ b/gajim/common/storage/base.py @@ -37,6 +37,7 @@ from nbxmpp.protocol import Iq from nbxmpp.protocol import JID from nbxmpp.structs import CommonError from nbxmpp.structs import DiscoInfo +from nbxmpp.structs import ReplyData from nbxmpp.structs import RosterItem _T = TypeVar('_T') @@ -68,6 +69,22 @@ sqlite3.register_converter('common_error', _convert_common_error) sqlite3.register_adapter(CommonError, _adapt_common_error) +def _convert_reply_data(reply_data: bytes) -> ReplyData: + data = json.loads(reply_data) + return ReplyData(to=data['to'], + id=data['id'], + fallback_start=data['fallback_start'], + fallback_end=data['fallback_end']) + + +def _adapt_reply_data(reply_data: ReplyData) -> str: + return json.dumps(reply_data._asdict()) + + +sqlite3.register_converter('reply_data', _convert_reply_data) +sqlite3.register_adapter(ReplyData, _adapt_reply_data) + + def _convert_marker(marker: bytes): return 'received' if int(marker) == 0 else 'displayed' diff --git a/gajim/common/structs.py b/gajim/common/structs.py index cc25f1d04..7263c4b12 100644 --- a/gajim/common/structs.py +++ b/gajim/common/structs.py @@ -31,6 +31,7 @@ from nbxmpp.modules.security_labels import SecurityLabel from nbxmpp.protocol import JID from nbxmpp.structs import MucSubject from nbxmpp.structs import PresenceProperties +from nbxmpp.structs import ReplyData from gajim.common import types from gajim.common.const import KindConstant @@ -98,6 +99,7 @@ class OutgoingMessage: control: Any | None = None, attention: bool | None = None, correct_id: str | None = None, + reply_data: ReplyData | None = None, oob_url: str | None = None, xhtml: str | None = None, nodes: Any | None = None, @@ -136,6 +138,7 @@ class OutgoingMessage: self.control = control self.attention = attention self.correct_id = correct_id + self.reply_data = reply_data self.oob_url = oob_url diff --git a/gajim/common/util/text.py b/gajim/common/util/text.py index 2ab79dde9..50c16d8ac 100644 --- a/gajim/common/util/text.py +++ b/gajim/common/util/text.py @@ -51,6 +51,26 @@ def jid_to_iri(jid: str) -> str: return 'xmpp:' + escape_iri_path(jid) +def remove_fallback_text(text: str, + start: int | None, + end: int | None + ) -> str: + + if start is None: + start = 0 + + if end is None: + return text[start:] + + before = text[:start] + after = text[end:] + return before + after + + +def quote_text(text: str) -> str: + return '> ' + text.replace('\n', '\n> ') + '\n' + + def format_duration(ns: float, total_ns: float) -> str: seconds = ns / 1e9 minutes = seconds / 60 diff --git a/gajim/data/gui/message_actions_box.ui b/gajim/data/gui/message_actions_box.ui index 987ccb583..0fb0ad85d 100644 --- a/gajim/data/gui/message_actions_box.ui +++ b/gajim/data/gui/message_actions_box.ui @@ -5,196 +5,213 @@ True False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - True - 2 + vertical + 3 - - - - - True - False - True + + True + False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - True - none - + True + 2 - - True - False - 1 + + + + + True + False + True + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + True + none + + + + True + False + 1 + + + + + False + False + end + 1 + - - - - False - False - end - 1 - - - - - True - True - True - none - + True - False - channel-insecure-symbolic + True + True + none + + + True + False + channel-insecure-symbolic + + + + + False + True + end + 2 + - - - - False - True - end - 2 - - - - - True - True - False - True - win.send-file - [''] - none - + True - False - mail-attachment-symbolic + True + False + True + win.send-file + [''] + none + + + True + False + mail-attachment-symbolic + + + + + False + False + end + 3 + - - - - False - False - end - 3 - - - - - True - True - True - Show a list of emojis - win.show-emoji-chooser - none - + True - False - face-smile-symbolic + True + True + Show a list of emojis + win.show-emoji-chooser + none + + + True + False + face-smile-symbolic + + + + + False + True + 5 + + + + + True + False + True + True + Send Message + win.send-message + none + + + True + False + gajim-send-message-symbolic + + + + + + False + False + end + 6 + - - - - False - True - 5 - - - - - True - False - True - True - Send Message - win.send-message - none - + True - False - gajim-send-message-symbolic + True + False + True + Format your message + none + up + + + True + False + format-text-bold-symbolic + 1 + + + + + False + True + 7 + - - - - False - False - end - 6 - - - - - True - True - False - True - Format your message - none - up - + True - False - format-text-bold-symbolic - 1 + True + 3 + 3 + external + in + False + 100 + True + + + + + + True + True + 9 + - False True - 7 + end + 0 - - True - True - 3 - 3 - external - in - False - 100 - True - - - - - - - True - True - 9 - + diff --git a/gajim/data/style/gajim.css b/gajim/data/style/gajim.css index ad50008c5..d9733dc58 100644 --- a/gajim/data/style/gajim.css +++ b/gajim/data/style/gajim.css @@ -643,6 +643,12 @@ infobar.error > revealer > box { padding: 0; } +/* ReferredMessageWidget */ +.referred-message { + border-left: 3px solid @borders; + padding: 3px 8px; +} + /* PreviewWidget */ .preview-stack > box { border: 1px solid; diff --git a/gajim/gtk/builder.pyi b/gajim/gtk/builder.pyi index 3cac99713..2b5e4646f 100644 --- a/gajim/gtk/builder.pyi +++ b/gajim/gtk/builder.pyi @@ -592,6 +592,7 @@ class ManageSoundsBuilder(Builder): class MessageActionsBoxBuilder(Builder): box: Gtk.Box + action_box: Gtk.Box encryption_details_button: Gtk.Button encryption_details_image: Gtk.Image encryption_menu_button: Gtk.MenuButton diff --git a/gajim/gtk/chat_list_row.py b/gajim/gtk/chat_list_row.py index 643b3a98a..0d9a39881 100644 --- a/gajim/gtk/chat_list_row.py +++ b/gajim/gtk/chat_list_row.py @@ -49,6 +49,7 @@ from gajim.common.preview_helpers import split_geo_uri from gajim.common.storage.draft import DraftStorage from gajim.common.types import ChatContactT from gajim.common.types import OneOnOneContactT +from gajim.common.util.text import remove_fallback_text from gajim.gtk.builder import get_builder from gajim.gtk.menus import get_chat_list_row_menu @@ -157,6 +158,12 @@ class ChatListRow(Gtk.ListBoxRow): if line.message is not None: message_text = line.message + if line.reply_data is not None: + message_text = remove_fallback_text( + line.message, + line.reply_data.fallback_start, + line.reply_data.fallback_end) + if line.additional_data is not None: retracted_by = line.additional_data.get_value( 'retracted', 'by') diff --git a/gajim/gtk/chat_stack.py b/gajim/gtk/chat_stack.py index b2a861643..99988286d 100644 --- a/gajim/gtk/chat_stack.py +++ b/gajim/gtk/chat_stack.py @@ -519,6 +519,7 @@ class ChatStack(Gtk.Stack, EventHelper): app.window.get_action('quote').set_enabled(online) app.window.get_action('mention').set_enabled(online) + app.window.get_action('reply').set_enabled(online) def _update_group_chat_actions(self, contact: GroupchatContact) -> None: joined = contact.is_joined @@ -543,6 +544,7 @@ class ChatStack(Gtk.Stack, EventHelper): app.window.get_action('quote').set_enabled(joined) app.window.get_action('mention').set_enabled(joined) + app.window.get_action('reply').set_enabled(joined) app.window.get_action('retract-message').set_enabled(joined) def _update_participant_actions(self, @@ -766,6 +768,11 @@ class ChatStack(Gtk.Stack, EventHelper): if correct_id is None: return + reply_data = None + message_reply = self._message_action_box.get_reply_data() + if message_reply is not None: + reply_data, message = message_reply + chatstate = client.get_module('Chatstate').get_active_chatstate( contact) @@ -780,11 +787,14 @@ class ChatStack(Gtk.Stack, EventHelper): chatstate=chatstate, label=label, control=self._chat_control, - correct_id=correct_id) + correct_id=correct_id, + reply_data=reply_data) client.send_message(message_) self._message_action_box.msg_textview.clear() + if message_reply is not None: + self._message_action_box.disable_reply_mode() app.storage.drafts.set(contact, '') def get_last_message_id(self, contact: ChatContactT) -> str | None: diff --git a/gajim/gtk/const.py b/gajim/gtk/const.py index 107a910df..3a6f6e70f 100644 --- a/gajim/gtk/const.py +++ b/gajim/gtk/const.py @@ -223,6 +223,8 @@ MAIN_WIN_ACTIONS = [ ('copy-message', 's', True), ('retract-message', 'a{sv}', False), ('quote', 's', False), + ('reply', 'as', False), + ('jump-to-message', 'au', True), ('mention', 's', False), ('send-file-httpupload', 'as', False), ('send-file-jingle', 'as', False), diff --git a/gajim/gtk/control.py b/gajim/gtk/control.py index 91cd4ca86..8efba1e47 100644 --- a/gajim/gtk/control.py +++ b/gajim/gtk/control.py @@ -27,6 +27,7 @@ from nbxmpp import JID from nbxmpp.const import StatusCode from nbxmpp.modules.security_labels import Displaymarking from nbxmpp.structs import MucSubject +from nbxmpp.structs import ReplyData from gajim.common import app from gajim.common import events @@ -44,6 +45,7 @@ from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.common.storage.archive import ConversationRow +from gajim.common.util.text import remove_fallback_text from gajim.gtk.builder import get_builder from gajim.gtk.conversation.jump_to_end_button import JumpToEndButton @@ -95,6 +97,8 @@ class ChatControl(EventHelper): app.window.get_action('activate-message-selection').connect( 'activate', self._on_activate_message_selection) + app.window.get_action('jump-to-message').connect( + 'activate', self._on_jump_to_message) self.widget = cast(Gtk.Box, self._ui.get_object('control_box')) self.widget.show_all() @@ -347,7 +351,8 @@ class ChatControl(EventHelper): msg_log_id=event.msg_log_id, message_id=message_id, stanza_id=None, - additional_data=event.additional_data) + additional_data=event.additional_data, + reply_data=event.reply_data) def _on_message_received(self, event: events.MessageReceived) -> None: if not self._is_event_processable(event): @@ -373,7 +378,8 @@ class ChatControl(EventHelper): msg_log_id=event.msg_log_id, message_id=event.properties.id, stanza_id=event.stanza_id, - additional_data=event.additional_data) + additional_data=event.additional_data, + reply_data=event.properties.reply_data) def _on_mam_message_received(self, event: events.MamMessageReceived) -> None: @@ -417,7 +423,8 @@ class ChatControl(EventHelper): msg_log_id=event.msg_log_id, message_id=event.properties.id, stanza_id=event.stanza_id, - additional_data=event.additional_data) + additional_data=event.additional_data, + reply_data=event.properties.reply_data) def _on_gc_message_received(self, event: events.GcMessageReceived) -> None: if not self._is_event_processable(event): @@ -439,7 +446,8 @@ class ChatControl(EventHelper): msg_log_id=event.msg_log_id, message_id=event.properties.id, stanza_id=event.stanza_id, - additional_data=event.additional_data) + additional_data=event.additional_data, + reply_data=event.properties.reply_data) def _on_message_updated(self, event: events.MessageUpdated) -> None: if not self._is_event_processable(event): @@ -551,6 +559,14 @@ class ChatControl(EventHelper): def _on_cancel_selection(self, _widget: MessageSelection) -> None: self._scrolled_view.disable_row_selection() + def _on_jump_to_message(self, + _action: Gio.SimpleAction, + param: GLib.Variant + ) -> None: + + log_line_id, timestamp = param.unpack() + self.scroll_to_message(log_line_id, timestamp) + def _on_jump_to_end(self, _button: Gtk.Button) -> None: self.reset_view() @@ -590,12 +606,23 @@ class ChatControl(EventHelper): msg_log_id: int | None, message_id: str | None, stanza_id: str | None, - additional_data: AdditionalDataDict | None + additional_data: AdditionalDataDict | None, + reply_data: ReplyData | None ) -> None: if additional_data is None: additional_data = AdditionalDataDict() + referred_message = None + if reply_data is not None: + referred_message = app.storage.archive.get_referred_message( + self._contact, reply_data.id) + if referred_message is not None: + text = remove_fallback_text( + text, + reply_data.fallback_start, + reply_data.fallback_end) + if self._allow_add_message(): self._scrolled_view.add_message( text, @@ -606,7 +633,8 @@ class ChatControl(EventHelper): message_id=message_id, stanza_id=stanza_id, log_line_id=msg_log_id, - additional_data=additional_data) + additional_data=additional_data, + referred_message=referred_message) if not self._scrolled_view.get_autoscroll(): if kind == 'outgoing': @@ -666,6 +694,16 @@ class ChatControl(EventHelper): message_text = get_retraction_text( self.contact.account, retracted_by, reason) + referred_message = None + if msg.reply_data is not None: + referred_message = app.storage.archive.get_referred_message( + self._contact, msg.reply_data.id) + if referred_message is not None: + message_text = remove_fallback_text( + message_text, + msg.reply_data.fallback_start, + msg.reply_data.fallback_end) + self._scrolled_view.add_message( message_text, kind, @@ -676,7 +714,8 @@ class ChatControl(EventHelper): stanza_id=msg.stanza_id, log_line_id=msg.log_line_id, marker=msg.marker, - error=msg.error) + error=msg.error, + referred_message=referred_message) def _request_messages(self, before: bool) -> list[ConversationRow]: if before: diff --git a/gajim/gtk/conversation/rows/message.py b/gajim/gtk/conversation/rows/message.py index 614405993..c2df4ab00 100644 --- a/gajim/gtk/conversation/rows/message.py +++ b/gajim/gtk/conversation/rows/message.py @@ -39,6 +39,7 @@ from gajim.common.i18n import _ from gajim.common.i18n import is_rtl_text from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant +from gajim.common.storage.archive import ReferredMessageRow from gajim.common.types import ChatContactT from gajim.gtk.conversation.message_widget import MessageWidget @@ -50,6 +51,7 @@ from gajim.gtk.conversation.rows.widgets import MoreMenuButton from gajim.gtk.conversation.rows.widgets import NicknameLabel from gajim.gtk.menus import get_chat_row_menu from gajim.gtk.preview import PreviewWidget +from gajim.gtk.referred_message_widget import ReferredMessageWidget from gajim.gtk.util import format_fingerprint from gajim.gtk.util import GajimPopover @@ -70,7 +72,9 @@ class MessageRow(BaseRow): display_marking: Displaymarking | None = None, marker: str | None = None, error: CommonError | StanzaError | None = None, - log_line_id: int | None = None) -> None: + log_line_id: int | None = None, + referred_message: ReferredMessageRow | None = None + ) -> None: BaseRow.__init__(self, account) self.type = 'chat' @@ -100,6 +104,8 @@ class MessageRow(BaseRow): # Keep original text for message correction self._original_text: str = text + self._ref_message_widget = None + if self._is_groupchat: our_nick = get_group_chat_nick(self._account, self._contact.jid) from_us = name == our_nick @@ -115,6 +121,11 @@ class MessageRow(BaseRow): app.preview_manager.create_preview( text, self._message_widget, from_us, muc_context) else: + if referred_message is not None: + self._ref_message_widget = ReferredMessageWidget( + self._contact, + referred_message) + self._message_widget = MessageWidget(account) self._message_widget.add_with_styling(text, nickname=name) if self._is_groupchat: @@ -173,7 +184,13 @@ class MessageRow(BaseRow): self._avatar_box = AvatarBox(self._contact, name, avatar) self._bottom_box = Gtk.Box(spacing=6) - self._bottom_box.add(self._message_widget) + if self._ref_message_widget is not None: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) + box.add(self._ref_message_widget) + box.add(self._message_widget) + self._bottom_box.add(box) + else: + self._bottom_box.add(self._message_widget) if is_rtl_text(text): self._bottom_box.set_halign(Gtk.Align.END) diff --git a/gajim/gtk/conversation/view.py b/gajim/gtk/conversation/view.py index 9c804b865..3c407d6ca 100644 --- a/gajim/gtk/conversation/view.py +++ b/gajim/gtk/conversation/view.py @@ -44,6 +44,7 @@ from gajim.common.modules.contacts import BareContact from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.common.storage.archive import ConversationRow +from gajim.common.storage.archive import ReferredMessageRow from gajim.common.types import ChatContactT from gajim.gtk.conversation.rows.base import BaseRow @@ -506,7 +507,8 @@ class ConversationView(Gtk.ScrolledWindow): display_marking: Displaymarking | None = None, additional_data: AdditionalDataDict | None = None, marker: str | None = None, - error: CommonError | StanzaError | None = None + error: CommonError | StanzaError | None = None, + referred_message: ReferredMessageRow | None = None ) -> None: if not timestamp: @@ -525,7 +527,8 @@ class ConversationView(Gtk.ScrolledWindow): display_marking=display_marking, marker=marker, error=error, - log_line_id=log_line_id) + log_line_id=log_line_id, + referred_message=referred_message) if message_id is not None: self._message_id_row_map[message_id] = message_row diff --git a/gajim/gtk/menus.py b/gajim/gtk/menus.py index 419d3aa1c..c7b32730a 100644 --- a/gajim/gtk/menus.py +++ b/gajim/gtk/menus.py @@ -705,9 +705,24 @@ def get_chat_row_menu(contact: types.ChatContactT, show_quote = not self_contact.role.is_visitor else: show_quote = False + if show_quote: - menu_items.append(( - p_('Message row action', 'Quote…'), 'win.quote', text)) + if isinstance(contact, GroupchatContact) and stanza_id is None: + # Use XEP-0393 quotes for MUCs without XEP-0359 IDs + menu_items.append(( + p_('Message row action', 'Quote…'), 'win.quote', text)) + else: + if isinstance(contact, GroupchatContact): + reply_to_id = stanza_id + resource_contact = contact.get_resource(name) + jid = str(resource_contact.real_jid or resource_contact.jid) + else: + reply_to_id = message_id + jid = contact.jid.bare + menu_items.append(( + p_('Message row action', 'Reply…'), + 'win.reply', + GLib.Variant('as', [reply_to_id, jid]))) show_correction = False if message_id is not None: diff --git a/gajim/gtk/message_actions_box.py b/gajim/gtk/message_actions_box.py index e5876b7e8..4733e6d76 100644 --- a/gajim/gtk/message_actions_box.py +++ b/gajim/gtk/message_actions_box.py @@ -28,6 +28,7 @@ from gi.repository import GObject from gi.repository import Gtk from nbxmpp.const import Chatstate from nbxmpp.modules.security_labels import SecurityLabel +from nbxmpp.structs import ReplyData from gajim.common import app from gajim.common.client import Client @@ -39,12 +40,14 @@ from gajim.common.modules.contacts import BareContact from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant from gajim.common.types import ChatContactT +from gajim.common.util.text import quote_text from gajim.gtk.builder import get_builder from gajim.gtk.dialogs import ErrorDialog from gajim.gtk.menus import get_encryption_menu from gajim.gtk.menus import get_format_menu from gajim.gtk.message_input import MessageInputTextView +from gajim.gtk.referred_message_widget import ReferredMessageWidget from gajim.gtk.security_label_selector import SecurityLabelSelector from gajim.gtk.util import open_window @@ -66,6 +69,11 @@ class MessageActionsBox(Gtk.Grid): # For undo self.space_pressed = False + # For message replies + self._reply_box = None + self._reply_data = None + self._reply_quoted_text = None + self._ui.send_message_button.set_visible( app.settings.get('show_send_message_button')) app.settings.bind_signal('show_send_message_button', @@ -73,7 +81,8 @@ class MessageActionsBox(Gtk.Grid): 'set_visible') self._security_label_selector = SecurityLabelSelector() - self._ui.box.pack_start(self._security_label_selector, False, True, 0) + self._ui.action_box.pack_start( + self._security_label_selector, False, True, 0) self.msg_textview = MessageInputTextView() self.msg_textview.get_buffer().connect('changed', @@ -96,7 +105,7 @@ class MessageActionsBox(Gtk.Grid): self._connect_actions() app.plugin_manager.gui_extension_point( - 'message_actions_box', self, self._ui.box) + 'message_actions_box', self, self._ui.action_box) def get_current_contact(self) -> ChatContactT: assert self._contact is not None @@ -114,6 +123,7 @@ class MessageActionsBox(Gtk.Grid): 'show-emoji-chooser', 'quote', 'mention', + 'reply', 'correct-message', ] @@ -154,6 +164,11 @@ class MessageActionsBox(Gtk.Grid): assert param self.msg_textview.insert_as_quote(param.get_string()) + elif action_name == 'reply': + assert param + reply_to_id, jid = param.get_strv() + self._enable_reply_mode(reply_to_id, jid) + elif action_name == 'mention': assert param self.msg_textview.mention_participant(param.get_string()) @@ -173,6 +188,8 @@ class MessageActionsBox(Gtk.Grid): self._client.connect_signal( 'state-changed', self._on_client_state_changed) + self.disable_reply_mode() + self._contact = contact if isinstance(self._contact, GroupchatContact): @@ -536,6 +553,55 @@ class MessageActionsBox(Gtk.Grid): return False + def _enable_reply_mode(self, reply_to_id: str, jid: str) -> None: + if self._reply_data is not None: + # If reply was called again, remove the last reply box first + self.disable_reply_mode() + + assert self._contact is not None + referred_message_row = app.storage.archive.get_referred_message( + self._contact, reply_to_id) + assert referred_message_row is not None + + self._reply_quoted_text = quote_text(referred_message_row.message) + self._reply_data = ReplyData( + to=jid, + id=reply_to_id, + fallback_start=0, + fallback_end=len(self._reply_quoted_text)) + + close_button = Gtk.Button.new_from_icon_name( + 'window-close-symbolic', Gtk.IconSize.BUTTON) + close_button.set_valign(Gtk.Align.CENTER) + close_button.set_tooltip_text(_('Cancel')) + close_button.connect('clicked', self.disable_reply_mode) + + ref_widget = ReferredMessageWidget(self._contact, referred_message_row) + + self._reply_box = Gtk.Box(spacing=14) + self._reply_box.add(close_button) + self._reply_box.add(ref_widget) + + self._ui.box.pack_start(self._reply_box, True, True, 0) + self._reply_box.show_all() + + self.msg_textview.grab_focus() + + def disable_reply_mode(self, *args: Any) -> None: + self._reply_data = None + self._reply_quoted_text = None + if self._reply_box is not None: + self._reply_box.destroy() + self._reply_box = None + + def get_reply_data(self) -> tuple[ReplyData, str] | None: + if self._reply_data is None or self._reply_quoted_text is None: + return None + + message = self.msg_textview.get_text() + fallback_text = f'{self._reply_quoted_text}{message}' + return self._reply_data, fallback_text + def _on_paste_clipboard(self, texview: MessageInputTextView ) -> None: diff --git a/gajim/gtk/message_input.py b/gajim/gtk/message_input.py index 4b682498b..be6fe3458 100644 --- a/gajim/gtk/message_input.py +++ b/gajim/gtk/message_input.py @@ -38,6 +38,7 @@ from gajim.common.i18n import get_default_lang from gajim.common.styling import PlainBlock from gajim.common.styling import process from gajim.common.types import ChatContactT +from gajim.common.util.text import remove_fallback_text from gajim.gtk.chat_action_processor import ChatActionProcessor from gajim.gtk.const import MAX_MESSAGE_LENGTH @@ -140,9 +141,16 @@ class MessageInputTextView(Gtk.TextView, EventHelper): if message_row is None or message_row.message is None: return + text = message_row.message + if message_row.reply_data is not None: + text = remove_fallback_text( + message_row.message, + message_row.reply_data.fallback_start, + message_row.reply_data.fallback_end) + self._set_correcting(True) self.get_style_context().add_class('gajim-msg-correcting') - self.insert_text(message_row.message) + self.insert_text(text) def try_message_correction(self, message: str) -> str | None: assert self._contact is not None diff --git a/gajim/gtk/referred_message_widget.py b/gajim/gtk/referred_message_widget.py new file mode 100644 index 000000000..53a5ed21b --- /dev/null +++ b/gajim/gtk/referred_message_widget.py @@ -0,0 +1,93 @@ +# 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 . + +from __future__ import annotations + +from datetime import datetime + +from gi.repository import GLib +from gi.repository import Gtk +from gi.repository import Pango + +from gajim.common import app +from gajim.common import types +from gajim.common.const import KindConstant +from gajim.common.helpers import from_one_line +from gajim.common.i18n import _ +from gajim.common.storage.archive import ReferredMessageRow + + +class ReferredMessageWidget(Gtk.Box): + def __init__(self, + contact: types.ChatContactT, + referred_message: ReferredMessageRow + ) -> None: + + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + self.set_halign(Gtk.Align.START) + self.get_style_context().add_class('referred-message') + + self._referred_message = referred_message + + if contact.is_groupchat: + ref_str = _('%s wrote') % referred_message.contact_name + else: + if referred_message.kind == KindConstant.CHAT_MSG_RECV: + ref_str = _('%s wrote') % contact.name + else: + ref_str = _('You wrote') + + icon = Gtk.Image.new_from_icon_name( + 'mail-reply-sender-symbolic', + Gtk.IconSize.BUTTON) + icon.get_style_context().add_class('dim-label') + + name_label = Gtk.Label(label=ref_str) + name_label.get_style_context().add_class('dim-label') + + date_time = datetime.fromtimestamp(referred_message.time) + time_format = from_one_line(app.settings.get('date_time_format')) + timestamp_label = Gtk.Label( + label=f'({date_time.strftime(time_format)})') + timestamp_label.get_style_context().add_class('dim-label') + + jump_to_button = Gtk.LinkButton(label=_('[view message]')) + jump_to_button.connect('activate-link', self._on_jump_clicked) + + meta_box = Gtk.Box(spacing=6) + meta_box.get_style_context().add_class('small-label') + meta_box.add(icon) + meta_box.add(name_label) + meta_box.add(timestamp_label) + meta_box.add(jump_to_button) + + message_text = referred_message.message.split('\n')[0] + message_label = Gtk.Label(label=message_text) + message_label.set_halign(Gtk.Align.START) + message_label.set_max_width_chars(52) + message_label.set_ellipsize(Pango.EllipsizeMode.END) + message_label.get_style_context().add_class('dim-label') + + self.add(meta_box) + self.add(message_label) + + self.show_all() + + def _on_jump_clicked(self, _button: Gtk.LinkButton) -> bool: + app.window.activate_action( + 'jump-to-message', GLib.Variant( + 'au', + [self._referred_message.log_line_id, + self._referred_message.time])) + return True -- cgit v1.2.3