From f8e3e85bf344515921b3ec2bfb6dbcb1e457295b Mon Sep 17 00:00:00 2001 From: wurstsalat Date: Wed, 16 Nov 2022 17:38:28 +0100 Subject: feat: Add XEP-0444: Message Reactions --- data/gajim.doap | 7 + gajim/common/const.py | 3 +- gajim/common/events.py | 8 ++ gajim/common/modules/reactions.py | 89 ++++++++++++ gajim/common/storage/archive.py | 30 ++++ gajim/data/style/gajim.css | 19 +++ gajim/gtk/control.py | 10 ++ gajim/gtk/conversation/reactions_bar.py | 237 ++++++++++++++++++++++++++++++++ gajim/gtk/conversation/rows/message.py | 10 ++ pyproject.toml | 1 + 10 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 gajim/common/modules/reactions.py create mode 100644 gajim/gtk/conversation/reactions_bar.py diff --git a/data/gajim.doap b/data/gajim.doap index aa6c390d3..8d4012a99 100644 --- a/data/gajim.doap +++ b/data/gajim.doap @@ -686,6 +686,13 @@ 0.2.0 + + + + complete + 0.1.0 + + diff --git a/gajim/common/const.py b/gajim/common/const.py index dd1e6b3c1..11f7d4bd3 100644 --- a/gajim/common/const.py +++ b/gajim/common/const.py @@ -923,7 +923,8 @@ COMMON_FEATURES = [ Namespace.JINGLE_IBB, Namespace.AVATAR_METADATA + '+notify', Namespace.MESSAGE_MODERATE, - Namespace.OMEMO_TEMP_DL + '+notify' + Namespace.OMEMO_TEMP_DL + '+notify', + Namespace.REACTIONS, ] diff --git a/gajim/common/events.py b/gajim/common/events.py index b7a6dbb4a..b227a0106 100644 --- a/gajim/common/events.py +++ b/gajim/common/events.py @@ -303,6 +303,14 @@ class DisplayedReceived(ApplicationEvent): marker_id: str +@dataclass +class ReactionReceived(ApplicationEvent): + name: str = field(init=False, default='reaction-received') + account: str + jid: JID + reaction_id: str + + @dataclass class HttpAuth(ApplicationEvent): name: str = field(init=False, default='http-auth') diff --git a/gajim/common/modules/reactions.py b/gajim/common/modules/reactions.py new file mode 100644 index 000000000..1f3dacf1a --- /dev/null +++ b/gajim/common/modules/reactions.py @@ -0,0 +1,89 @@ +# 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 . + +# Message Reactions (XEP-0444) + +from __future__ import annotations + +from nbxmpp.namespaces import Namespace +from nbxmpp.protocol import JID +from nbxmpp.protocol import Message +from nbxmpp.structs import MessageProperties +from nbxmpp.structs import StanzaHandler + +from gajim.common import app +from gajim.common import types +from gajim.common.events import ReactionReceived +from gajim.common.modules.base import BaseModule + + +class Reactions(BaseModule): + + _nbxmpp_extends = 'Reactions' + + def __init__(self, client: types.Client) -> None: + BaseModule.__init__(self, client) + + self.handlers = [ + StanzaHandler(name='message', + callback=self._process_reaction, + ns=Namespace.REACTIONS, + priority=47) + ] + + def _process_reaction(self, + _client: types.xmppClient, + _stanza: Message, + properties: MessageProperties + ) -> None: + + if properties.reactions is None: + return + + if properties.type.is_error: + return + + # TODO: make sure to use correct jid here + jid = properties.jid + assert jid is not None + + app.storage.archive.update_reactions( + self._account, + jid, + properties.occupant_id, + properties.timestamp, + properties.reactions) + + app.ged.raise_event( + ReactionReceived(account=self._account, + jid=jid, + reaction_id=properties.reactions.id)) + + def add_reaction(self, + jid: JID, + message_id: str, + reactions: list[str] + ) -> None: + + # TODO + pass + + def remove_reaction(self, + jid: JID, + message_id: str, + reactions: list[str] + ) -> None: + + # TODO + pass diff --git a/gajim/common/storage/archive.py b/gajim/common/storage/archive.py index 3e52c3a4f..219c523dd 100644 --- a/gajim/common/storage/archive.py +++ b/gajim/common/storage/archive.py @@ -38,6 +38,7 @@ from collections.abc import KeysView from nbxmpp import JID from nbxmpp.structs import CommonError from nbxmpp.structs import MessageProperties +from nbxmpp.structs import Reactions as ReactionStruct from gajim.common import app from gajim.common import configpaths @@ -1310,6 +1311,35 @@ class MessageArchiveStorage(SqliteStorage): self._con.execute(sql, (state_int, account_id, jid_id, message_id)) self._delayed_commit() + @timeit + def update_reactions(self, + account: str, + jid: JID, + occupant_id: str, + timestamp: float, + reactions: ReactionStruct + ) -> None: + + # TODO: + # get message by account, jid, message_id; + # get reactions column content, parse it; + # update content, store it + # message_id = reactions.id + # emojis = reactions.emojis + + # Possible structure + # test = { + # '🤘️': [ + # { + # 'jid': 'test@example.net', + # 'occupant_id': 'asda-sdas-asda-asdf', + # 'timestamp': 123456678.123145, + # }, + # ], + # } + + pass + @timeit def get_archive_infos(self, jid: str) -> LastArchiveMessageRow | None: ''' diff --git a/gajim/data/style/gajim.css b/gajim/data/style/gajim.css index ad50008c5..c9496d0fb 100644 --- a/gajim/data/style/gajim.css +++ b/gajim/data/style/gajim.css @@ -717,6 +717,25 @@ infobar.error > revealer > box { text-shadow: none; box-shadow: none; } +/* ReactionsBar */ +.reaction { + border: 1px solid @borders; + border-radius: 18px; + padding: 4px 8px; + outline: none; + } + .reaction:hover { + border-color: @theme_selected_bg_color; + background: alpha(@theme_selected_bg_color, 0.2); + } + .reaction-from-us { + border-color: alpha(@theme_selected_bg_color, 0.8); + background: alpha(@theme_selected_bg_color, 0.06); +} +.reaction-dummy-entry { + padding: 0px; +} + /* Multi-purpose elements / widgets ======================================== */ diff --git a/gajim/gtk/control.py b/gajim/gtk/control.py index 91cd4ca86..724366e1a 100644 --- a/gajim/gtk/control.py +++ b/gajim/gtk/control.py @@ -279,6 +279,7 @@ class ChatControl(EventHelper): ('message-moderated', ged.GUI2, self._on_message_moderated), ('receipt-received', ged.GUI2, self._on_receipt_received), ('displayed-received', ged.GUI2, self._on_displayed_received), + ('reaction-received', ged.GUI2, self._on_reaction_received), ('message-error', ged.GUI2, self._on_message_error), ('call-stopped', ged.GUI2, self._on_call_stopped), ('jingle-request-received', @@ -471,6 +472,15 @@ class ChatControl(EventHelper): self._scrolled_view.set_read_marker(event.marker_id) + def _on_reaction_received(self, event: events.ReactionReceived) -> None: + if not self._is_event_processable(event): + return + + # TODO: Get reactions from archive + # reaction_id = event.reaction_id + # reaction_data format should be: (emoji, [(user, timestamp)]) + # self._scrolled_view.show_reactions(reaction_id, reaction_data) + def _on_message_error(self, event: events.MessageError) -> None: if not self._is_event_processable(event): return diff --git a/gajim/gtk/conversation/reactions_bar.py b/gajim/gtk/conversation/reactions_bar.py new file mode 100644 index 000000000..a0ee7374d --- /dev/null +++ b/gajim/gtk/conversation/reactions_bar.py @@ -0,0 +1,237 @@ +# 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 typing import NamedTuple + +import time +from datetime import datetime + +from gi.repository import Gtk + +from gajim.common import app +from gajim.common import types +from gajim.common.i18n import _ +from gajim.common.modules.contacts import GroupchatContact + +MAX_VISIBLE_REACTIONS = 3 + + +class ReactionData(NamedTuple): + emoji: str + users: list[tuple[types.ChatContactT, float]] + + +class ReactionsBar(Gtk.Box): + def __init__(self, + contact: types.ChatContactT, + message_id: str, + new_reaction: bool + ) -> None: + + Gtk.Box.__init__(self, spacing=3) + + self._contact = contact + self._message_id = message_id + + if new_reaction: + self.add(AddReaction( + self._contact, self._message_id, new_reaction=True)) + self.show_all() + return + + # TODO: remove + self._add_dummy_data() + + def set_reactions(self, reactions: list[ReactionData]) -> None: + for reaction in reactions: + reaction_widget = Reaction( + self._contact, self._message_id, reaction) + self.add(reaction_widget) + if reactions.index(reaction) + 1 >= MAX_VISIBLE_REACTIONS: + break + + if len(reactions) > MAX_VISIBLE_REACTIONS: + more_reactions_button = MoreReactionsButton( + self._contact, self._message_id, reactions) + self.add(more_reactions_button) + + self.add(AddReaction(self._contact, self._message_id)) + self.show_all() + + def _add_dummy_data(self) -> None: + # TODO: remove + reactions_list: list[ReactionData] = [] + client = app.get_client(self._contact.account) + contact1 = client.get_module('Contacts').get_contact( + 'heinrich@test') + contact2 = client.get_module('Contacts').get_contact( + 'igor@test') + contact3 = client.get_module('Contacts').get_contact( + 'romeo@test') + + timestamp = time.time() + data1 = ReactionData( + emoji='🤘️', + users=[(contact1, timestamp), (contact2, timestamp)]) + data2 = ReactionData( + emoji='🚀️', + users=[(contact3, timestamp)]) + data3 = ReactionData( + emoji='🙉️', + users=[(contact2, timestamp)]) + reactions_list.append(data1) + reactions_list.append(data2) + reactions_list.append(data3) + self.set_reactions(reactions_list) + + +class Reaction(Gtk.Button): + def __init__(self, + contact: types.ChatContactT, + message_id: str, + reaction: ReactionData + ) -> None: + + Gtk.Button.__init__(self) + self.get_style_context().add_class('flat') + self.get_style_context().add_class('reaction') + + self._contact = contact + self._message_id = message_id + self._reaction = reaction + + if isinstance(contact, GroupchatContact): + self_contact = contact.get_self() + else: + client = app.get_client(contact.account) + self_contact = client.get_module('Contacts').get_contact( + client.get_own_jid().bare) + + if self_contact in reaction.users[0]: + self.get_style_context().add_class('reaction-from-us') + + emoji_label = Gtk.Label(label=reaction.emoji) + count_label = Gtk.Label(label=str(len(reaction.users))) + count_label.get_style_context().add_class('small') + count_label.get_style_context().add_class('monospace') + + self._box = Gtk.Box(spacing=3) + self._box.add(emoji_label) + self._box.add(count_label) + self.add(self._box) + + format_string = app.settings.get('date_time_format') + tooltip_text = '' + for user, timestamp in reaction.users: + date_time = datetime.utcfromtimestamp(timestamp) + timestamp_formatted = date_time.strftime(format_string) + tooltip_text += f'{user.name} ({timestamp_formatted})\n' + self.set_tooltip_text(tooltip_text.strip()) + + self.connect('clicked', + self._on_clicked, + self_contact in reaction.users) + + self.show_all() + + def _on_clicked(self, _button: Gtk.Button, from_us: bool) -> None: + if from_us: + # TODO: Remove reaction of type self._reaction + return + + # TODO: Add reaction of type self._reaction + + +class MoreReactionsButton(Gtk.MenuButton): + def __init__(self, + contact: types.ChatContactT, + message_id: str, + reactions: list[ReactionData] + ) -> None: + + Gtk.MenuButton.__init__(self) + self.get_style_context().add_class('flat') + self.get_style_context().add_class('reaction') + + box = Gtk.FlowBox() + box.set_row_spacing(3) + box.set_column_spacing(3) + box.set_selection_mode(Gtk.SelectionMode.NONE) + box.set_min_children_per_line(3) + box.set_max_children_per_line(3) + box.get_style_context().add_class('padding-6') + + for reaction in reactions[MAX_VISIBLE_REACTIONS:]: + reaction_widget = Reaction(contact, message_id, reaction) + box.add(reaction_widget) + + box.show_all() + + popover = Gtk.Popover() + popover.add(box) + self.set_popover(popover) + + +class AddReaction(Gtk.Button): + def __init__(self, + contact: types.ChatContactT, + message_id: str, + new_reaction: bool = False + ) -> None: + + Gtk.Button.__init__(self) + self.get_style_context().add_class('reaction') + self.get_style_context().add_class('flat') + + self._contact = contact + self._message_id = message_id + + icon = Gtk.Image.new_from_icon_name( + 'list-add-symbolic', Gtk.IconSize.BUTTON) + self._dummy_entry = Gtk.Entry() + self._dummy_entry.set_width_chars(0) + self._dummy_entry.set_editable(True) + self._dummy_entry.set_no_show_all(True) + self._dummy_entry.get_style_context().add_class('flat') + self._dummy_entry.get_style_context().add_class('reaction-dummy-entry') + self._dummy_entry.connect('changed', self._on_changed) + + box = Gtk.Box() + box.add(icon) + box.add(self._dummy_entry) + self.add(box) + self.set_tooltip_text(_('Add Reaction…')) + + self.connect('clicked', self._on_clicked) + + if new_reaction: + self._dummy_entry.show() + self._dummy_entry.emit('insert-emoji') + + def _on_clicked(self, _button: Gtk.Button) -> None: + self._dummy_entry.show() + self._dummy_entry.emit('insert-emoji') + + def _on_changed(self, entry: Gtk.Entry) -> None: + if not entry.get_text(): + return + + self._dummy_entry.hide() + emoji = self._dummy_entry.get_text() + entry.set_text('') + + print(f'Reaction to {self._contact.jid} ({self._message_id}): {emoji}') + # TODO: Add reaction of type 'emoji' diff --git a/gajim/gtk/conversation/rows/message.py b/gajim/gtk/conversation/rows/message.py index 614405993..ddb877462 100644 --- a/gajim/gtk/conversation/rows/message.py +++ b/gajim/gtk/conversation/rows/message.py @@ -42,6 +42,7 @@ from gajim.common.modules.contacts import GroupchatParticipant from gajim.common.types import ChatContactT from gajim.gtk.conversation.message_widget import MessageWidget +from gajim.gtk.conversation.reactions_bar import ReactionsBar from gajim.gtk.conversation.rows.base import BaseRow from gajim.gtk.conversation.rows.widgets import AvatarBox from gajim.gtk.conversation.rows.widgets import DateTimeLabel @@ -186,6 +187,15 @@ class MessageRow(BaseRow): self.grid.attach(self._meta_box, 1, 0, 1, 1) self.grid.attach(self._bottom_box, 1, 1, 1, 1) + if self._contact.is_groupchat: + reaction_id = self.stanza_id + else: + reaction_id = self.message_id + if reaction_id is not None: + self._reactions_bar = ReactionsBar( + self._contact, reaction_id, False) + self.grid.attach(self._reactions_bar, 1, 2, 1, 1) + self.show_all() def _on_more_menu_button_clicked(self, button: Gtk.Button) -> None: diff --git a/pyproject.toml b/pyproject.toml index c03f5a8c1..1a53688d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,6 +132,7 @@ include = [ "gajim/common/logging_helpers.py", "gajim/common/modules/chat_markers.py", "gajim/common/modules/pep.py", + "gajim/common/modules/reactions.py", "gajim/common/modules/register.py", "gajim/common/modules/vcard4.py", "gajim/common/modules/vcard_temp.py", -- cgit v1.2.3