Daniel Brötzmann pushed to branch mainwindow at gajim / gajim
Commits: 365d7b17 by wurstsalat at 2021-11-10T22:11:54+01:00 Add XEP-0425: Message Moderation
- - - - -
16 changed files:
- data/gajim.doap - gajim/common/const.py - gajim/common/helpers.py - gajim/common/modules/mam.py - gajim/common/modules/muc.py - gajim/common/storage/archive.py - gajim/data/style/gajim.css - gajim/gtk/chat_list.py - gajim/gtk/chat_list_stack.py - gajim/gtk/controls/base.py - gajim/gtk/controls/chat.py - gajim/gtk/controls/groupchat.py - gajim/gtk/conversation/rows/base.py - gajim/gtk/conversation/rows/message.py - gajim/gtk/conversation/view.py - gajim/gtk/main.py
Changes:
===================================== data/gajim.doap ===================================== @@ -660,6 +660,13 @@ xmpp:version0.2</xmpp:version> </xmpp:SupportedXep> </implements> + <implements> + xmpp:SupportedXep + <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0425.html%22/%3E + xmpp:statuscomplete</xmpp:status> + xmpp:version0.2.1</xmpp:version> + </xmpp:SupportedXep> + </implements> <implements> xmpp:SupportedXep <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0441.html%22/%3E
===================================== gajim/common/const.py ===================================== @@ -1025,6 +1025,7 @@ def is_active(self): Namespace.JINGLE_BYTESTREAM, Namespace.JINGLE_IBB, Namespace.AVATAR_METADATA + '+notify', + Namespace.MESSAGE_MODERATE ]
===================================== gajim/common/helpers.py ===================================== @@ -752,6 +752,16 @@ def get_subscription_request_msg(account: Optional[str] = None) -> str: message = _('Hello, I am $name. %s') % message return Template(message).safe_substitute({'name': app.nicks[account]})
+def get_retraction_text(account: str, moderator_jid: str, + reason: Optional[str]) -> str: + client = app.get_client(account) + contact = client.get_module('Contacts').get_contact( + moderator_jid, groupchat=True) + text = _('This message has been retracted by %s.') % contact.name + if reason is not None: + text += ' ' + _('Reason: %s') % reason + return text + def get_user_proxy(account: str) -> Optional[ProxyData]: proxy_name = app.settings.get_account_setting(account, 'proxy') if not proxy_name: @@ -1197,6 +1207,12 @@ def is_role_change_allowed(self_contact, contact): return self_contact.affiliation >= contact.affiliation
+def is_retraction_allowed(self_contact, contact): + if self_contact.role < Role.MODERATOR: + return False + return self_contact.affiliation >= contact.affiliation + + def get_tls_error_phrase(tls_error: Gio.TlsCertificateFlags) -> str: phrase = GIO_TLS_ERRORS.get(tls_error) if phrase is None:
===================================== gajim/common/modules/mam.py ===================================== @@ -34,6 +34,7 @@ from gajim.common.const import KindConstant from gajim.common.const import SyncThreshold from gajim.common.helpers import AdditionalDataDict +from gajim.common.helpers import get_retraction_text from gajim.common.modules.misc import parse_oob from gajim.common.modules.misc import parse_correction from gajim.common.modules.util import get_eme_message @@ -226,6 +227,19 @@ def _mam_message_received(self, _con, stanza, properties): if properties.eme is not None: msgtxt = get_eme_message(properties.eme)
+ if properties.is_moderation: + additional_data.set_value( + 'retracted', 'by', properties.moderation.moderator_jid) + additional_data.set_value( + 'retracted', 'timestamp', properties.moderation.timestamp) + additional_data.set_value( + 'retracted', 'reason', properties.moderation.reason) + + msgtxt = get_retraction_text( + self._account, + properties.moderation.moderator_jid, + properties.moderation.reason) + if not msgtxt: # For example Chatstates, Receipts, Chatmarkers self._log.debug(stanza.getProperties()) @@ -259,6 +273,7 @@ def _mam_message_received(self, _con, stanza, properties): account=self._account, additional_data=additional_data, correct_id=parse_correction(properties), + stanza_id=stanza_id, archive_jid=properties.mam.archive, msgtxt=properties.body, properties=properties,
===================================== gajim/common/modules/muc.py ===================================== @@ -63,7 +63,8 @@ class MUC(BaseModule): 'request_voice', 'approve_voice_request', 'destroy', - 'request_disco_info' + 'request_disco_info', + 'retract_message' ]
def __init__(self, con): @@ -82,6 +83,10 @@ def __init__(self, con): callback=self._on_subject_change, typ='groupchat', priority=49), + StanzaHandler(name='message', + callback=self._on_update_message, + typ='groupchat', + priority=49), StanzaHandler(name='message', callback=self._on_config_change, ns=Namespace.MUC_USER, @@ -668,6 +673,19 @@ def _on_subject_change(self, _con, _stanza, properties):
raise nbxmpp.NodeProcessed
+ def _on_update_message(self, _con, _stanza, properties): + if properties.is_moderation: + app.storage.archive.update_additional_data( + self._account, properties.moderation.stanza_id, properties) + + app.nec.push_incoming_event( + NetworkEvent('message-updated', + account=self._account, + jid=properties.jid, + properties=properties)) + + raise nbxmpp.NodeProcessed + def _fake_subject_change(self, room_jid): # This is for servers which don’t send empty subjects as part of the # event order on joining a MUC. For example jabber.ru
===================================== gajim/common/storage/archive.py ===================================== @@ -29,6 +29,8 @@ import sqlite3 as sqlite from collections import namedtuple
+from nbxmpp.structs import MessageProperties + from gajim.common import app from gajim.common import configpaths from gajim.common.helpers import AdditionalDataDict @@ -352,7 +354,7 @@ def get_conversation_before_after(self, account, jid, before, timestamp,
sql = ''' SELECT contact_name, time, kind, show, message, subject, - additional_data, log_line_id, message_id, + additional_data, log_line_id, message_id, stanza_id, error as "error [common_error]", marker as "marker [marker]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) @@ -394,7 +396,7 @@ def get_conversation_muc_before_after(self, account, jid, before,
sql = ''' SELECT contact_name, time, kind, show, message, subject, - additional_data, log_line_id, message_id, + additional_data, log_line_id, message_id, stanza_id, error as "error [common_error]", marker as "marker [marker]" FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) @@ -429,7 +431,8 @@ def get_last_conversation_line(self, account, jid): KindConstant.ERROR])
sql = ''' - SELECT contact_name, time, kind, message, additional_data + SELECT contact_name, time, kind, message, stanza_id, + additional_data FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) AND account_id = {account_id} AND kind NOT IN ({kinds}) @@ -920,6 +923,84 @@ def find_stanza_id(self, account, archive_jid, stanza_id, origin_id=None, return True return False
+ @timeit + def update_additional_data(self, + account: str, + stanza_id: str, + properties: MessageProperties) -> None: + is_groupchat = properties.type.is_groupchat + type_ = JIDConstant.NORMAL_TYPE + if is_groupchat: + type_ = JIDConstant.ROOM_TYPE + + archive_id = self.get_jid_id(str(properties.jid.bare), type_=type_) + account_id = self.get_account_id(account) + + if is_groupchat: + # Stanza ID is only unique within a specific archive. + # So a Stanza ID could be repeated in different MUCs, so we + # filter also for the archive JID which is the bare MUC jid. + + # Use Unary-"+" operator for "jid_id", otherwise the + # idx_logs_jid_id_time index is used instead of the much better + # idx_logs_stanza_id index + sql = ''' + SELECT additional_data FROM logs + WHERE stanza_id = ? + AND +jid_id = ? + AND account_id = ? + LIMIT 1 + ''' + result = self._con.execute( + sql, (stanza_id, archive_id, account_id)).fetchone() + else: + sql = ''' + SELECT additional_data FROM logs + WHERE stanza_id = ? + AND account_id = ? + AND kind != ? + LIMIT 1 + ''' + result = self._con.execute( + sql, (stanza_id, account_id, KindConstant.GC_MSG)).fetchone() + + if result is None: + return + + if result.additional_data is None: + additional_data = AdditionalDataDict() + else: + additional_data = result.additional_data + + if properties.is_moderation: + additional_data.set_value( + 'retracted', 'by', properties.moderation.moderator_jid) + additional_data.set_value( + 'retracted', 'timestamp', properties.moderation.timestamp) + additional_data.set_value( + 'retracted', 'reason', properties.moderation.reason) + serialized_dict = json.dumps(additional_data.data) + + if is_groupchat: + sql = ''' + UPDATE logs SET additional_data = ? + WHERE stanza_id = ? + AND account_id = ? + AND +jid_id = ? + ''' + self._con.execute( + sql, (serialized_dict, stanza_id, account_id, archive_id)) + else: + sql = ''' + UPDATE logs SET additional_data = ? + WHERE stanza_id = ? + AND account_id = ? + AND kind != ? + ''' + self._con.execute( + sql, (serialized_dict, stanza_id, account_id, + KindConstant.GC_MSG)) + def insert_jid(self, jid, kind=None, type_=JIDConstant.NORMAL_TYPE): """ Insert a new jid into the `jids` table.
===================================== gajim/data/style/gajim.css ===================================== @@ -59,7 +59,9 @@ .conversation-row grid textview:not(.sourceview) { .conversation-row grid textview:not(.sourceview) text { background: transparent; } - +.retracted-message { + opacity: 0.4; +} @keyframes highlight { from { background: rgb(140, 160, 180); } to { background: transparent; }
===================================== gajim/gtk/chat_list.py ===================================== @@ -29,6 +29,7 @@ from gajim.common.i18n import _ from gajim.common.helpers import get_groupchat_name from gajim.common.helpers import get_group_chat_nick +from gajim.common.helpers import get_retraction_text from gajim.common.helpers import get_uf_relative_time from gajim.common.helpers import AdditionalDataDict from gajim.common.preview_helpers import filename_from_uri @@ -318,6 +319,8 @@ def process_event(self, event): 'mam-message-received', 'gc-message-received'): self._on_message_received(event) + elif event.name == 'message-updated': + self._on_message_updated(event) elif event.name == 'presence-received': self._on_presence_received(event) elif event.name == 'message-sent': @@ -341,9 +344,23 @@ def _on_message_received(self, event): row.set_timestamp(event.properties.timestamp) row.set_message_text( event.msgtxt, additional_data=event.additional_data) + row.set_stanza_id(event.properties.stanza_id.id) self._add_unread(row, event.properties) self.invalidate_sort()
+ def _on_message_updated(self, event): + row = self._chats.get((event.account, event.jid)) + if row is None: + return + + if event.properties.is_moderation: + if event.properties.moderation.stanza_id == row.stanza_id: + text = get_retraction_text( + event.account, + event.properties.moderation.moderator_jid, + event.properties.moderation.reason) + row.set_message_text(text) + def _on_message_sent(self, event): msgtext = event.message if not msgtext: @@ -448,6 +465,7 @@ def __init__(self, workspace_id, account, jid, type_, pinned):
self.contact_name = self.contact.name self.timestamp = 0 + self.stanza_id: Optional[str] = None self._unread_count = 0 self._pinned = pinned
@@ -475,8 +493,19 @@ def __init__(self, workspace_id, account, jid, type_, pinned): return
if line.message is not None: + message_text = line.message + + if line.additional_data is not None: + retracted_by = line.additional_data.get_value( + 'retracted', 'by') + if retracted_by is not None: + reason = line.additional_data.get_value( + 'retracted', 'reason') + message_text = get_retraction_text( + self.account, retracted_by, reason) + self.set_message_text( - line.message, additional_data=line.additional_data) + message_text, additional_data=line.additional_data) if line.kind in (KindConstant.CHAT_MSG_SENT, KindConstant.SINGLE_MSG_SENT): self._ui.nick_label.set_text(_('Me:')) @@ -495,6 +524,8 @@ def __init__(self, workspace_id, account, jid, type_, pinned): uf_timestamp = get_uf_relative_time(line.time) self._ui.timestamp_label.set_text(uf_timestamp)
+ self.stanza_id = line.stanza_id + if line.kind in (KindConstant.FILE_TRANSFER_INCOMING, KindConstant.FILE_TRANSFER_OUTGOING): self.set_message_text( @@ -634,6 +665,9 @@ def set_timestamp(self, timestamp): self.timestamp = timestamp self.update_time()
+ def set_stanza_id(self, stanza_id: str) -> None: + self.stanza_id = stanza_id + def update_time(self): if self.timestamp == 0: return
===================================== gajim/gtk/chat_list_stack.py ===================================== @@ -28,6 +28,7 @@ 'message-received', 'mam-message-received', 'gc-message-received', + 'message-updated', 'presence-received', 'message-sent', 'chatstate-received',
===================================== gajim/gtk/controls/base.py ===================================== @@ -44,6 +44,7 @@ from gajim.common.nec import EventHelper from gajim.common.nec import NetworkEvent from gajim.common.helpers import AdditionalDataDict +from gajim.common.helpers import get_retraction_text from gajim.common.const import KindConstant from gajim.common.structs import OutgoingMessage
@@ -290,6 +291,15 @@ def process_event(self, event): method_name = f'_on_{method_name}' getattr(self, method_name)(event)
+ def _on_message_updated(self, event): + if event.properties.is_moderation: + text = get_retraction_text( + self.account, + event.properties.moderation.moderator_jid, + event.properties.moderation.reason) + self.conversation_view.show_message_retraction( + event.properties.moderation.stanza_id, text) + 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): @@ -1120,6 +1130,7 @@ def add_message(self, tim, display_marking=displaymarking, message_id=message_id, + stanza_id=stanza_id, correct_id=correct_id, log_line_id=msg_log_id, additional_data=additional_data) @@ -1366,6 +1377,8 @@ def add_messages(self, messages): if not msg.message: continue
+ message_text = msg.message + contact_name = msg.contact_name if msg.kind in ( KindConstant.SINGLE_MSG_RECV, KindConstant.CHAT_MSG_RECV): @@ -1380,13 +1393,22 @@ def add_messages(self, messages): else: raise ValueError('no kind attribute')
+ if msg.additional_data is not None: + retracted_by = msg.additional_data.get_value('retracted', 'by') + if retracted_by is not None: + reason = msg.additional_data.get_value( + 'retracted', 'reason') + message_text = get_retraction_text( + self.account, retracted_by, reason) + self.conversation_view.add_message( - msg.message, + message_text, kind, contact_name, msg.time, additional_data=msg.additional_data, message_id=msg.message_id, + stanza_id=msg.stanza_id, log_line_id=msg.log_line_id, marker=msg.marker, error=msg.error)
===================================== gajim/gtk/controls/chat.py ===================================== @@ -414,6 +414,7 @@ def _on_mam_message_received(self, event): tim=event.properties.mam.timestamp, correct_id=event.correct_id, message_id=event.properties.id, + stanza_id=event.stanza_id, additional_data=event.additional_data)
def _on_message_received(self, event): @@ -431,6 +432,7 @@ def _on_message_received(self, event): displaymarking=event.displaymarking, msg_log_id=event.msg_log_id, message_id=event.properties.id, + stanza_id=event.stanza_id, correct_id=event.correct_id, additional_data=event.additional_data)
@@ -602,6 +604,7 @@ def add_message(self, subject=None, displaymarking=None, msg_log_id=None, + stanza_id=None, correct_id=None, message_id=None, additional_data=None): @@ -622,6 +625,7 @@ def add_message(self, displaymarking=displaymarking, msg_log_id=msg_log_id, message_id=message_id, + stanza_id=stanza_id, correct_id=correct_id, additional_data=additional_data)
===================================== gajim/gtk/controls/groupchat.py ===================================== @@ -673,6 +673,7 @@ def _on_mam_message_received(self, event): tim=event.properties.mam.timestamp, correct_id=event.correct_id, message_id=event.properties.id, + stanza_id=event.stanza_id, additional_data=event.additional_data)
def _on_gc_message_received(self, event):
===================================== gajim/gtk/conversation/rows/base.py ===================================== @@ -21,6 +21,7 @@ from gajim.common import app from gajim.common.i18n import _ from gajim.common.helpers import from_one_line +from gajim.common.helpers import is_retraction_allowed
from ...util import wrap_with_event_box
@@ -87,27 +88,44 @@ def create_name_widget(name: str, is_self: bool) -> Gtk.Label:
@wrap_with_event_box class MoreMenuButton(Gtk.MenuButton): - def __init__(self, row): + def __init__(self, row, contact, name): Gtk.MenuButton.__init__(self) - self.set_valign(Gtk.Align.START) self.set_halign(Gtk.Align.END) self.set_relief(Gtk.ReliefStyle.NONE) - image = Gtk.Image.new_from_icon_name( - 'feather-more-horizontal-symbolic', Gtk.IconSize.BUTTON) - self.add(image) - - self._create_popover(row)
self.get_style_context().add_class('conversation-more-button')
- def _create_popover(self, row): + self._row = row + self._contact = contact + self._name = name + + image = Gtk.Image.new_from_icon_name( + 'feather-more-horizontal-symbolic', Gtk.IconSize.BUTTON) + self.add(image) + self._create_popover() + + self.connect('clicked', self._on_click) + + def _on_click(self, _button): + show_retract = False + if self._contact.is_groupchat: + disco_info = app.storage.cache.get_last_disco_info( + self._contact.jid) + contact = self._contact.get_resource(self._name) + self_contact = self._contact.get_self() + is_allowed = is_retraction_allowed(self_contact, contact) + if disco_info.has_message_moderation and is_allowed: + show_retract = True + self._retract_button.set_visible(show_retract) + + def _create_popover(self): 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', row.on_quote_message) + quote_button.connect('clicked', self._row.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)) @@ -115,12 +133,21 @@ def _create_popover(self, row):
copy_button = Gtk.ModelButton() copy_button.set_halign(Gtk.Align.START) - copy_button.connect('clicked', row.on_copy_message) + copy_button.connect('clicked', self._row.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)
+ self._retract_button = Gtk.ModelButton() + self._retract_button.set_halign(Gtk.Align.START) + self._retract_button.connect( + 'clicked', self._row.on_retract_message) + self._retract_button.set_label(_('Retract')) + self._retract_button.set_image(Gtk.Image.new_from_icon_name( + 'edit-undo-symbolic', Gtk.IconSize.MENU)) + menu_box.add(self._retract_button) + menu_box.show_all()
popover = Gtk.PopoverMenu()
===================================== gajim/gtk/conversation/rows/message.py ===================================== @@ -34,6 +34,8 @@ from .base import BaseRow from .base import MoreMenuButton from ..message_widget import MessageWidget +from ...dialogs import InputDialog +from ...dialogs import DialogButton from ...preview import PreviewWidget from ...util import format_fingerprint from ...util import get_cursor @@ -46,6 +48,7 @@ def __init__(self, account, contact, message_id, + stanza_id, timestamp, kind, name, @@ -62,6 +65,7 @@ def __init__(self, self.timestamp = datetime.fromtimestamp(timestamp) self.db_timestamp = timestamp self.message_id = message_id + self.stanza_id = stanza_id self.log_line_id = log_line_id self.kind = kind self.name = name or '' @@ -78,6 +82,10 @@ def __init__(self, self._has_receipt = marker == 'received' self._has_displayed = marker == 'displayed'
+ if additional_data is not None: + if additional_data.get_value('retracted', 'by') is not None: + self.get_style_context().add_class('retracted-message') + is_previewable = app.interface.preview_manager.get_previewable( text, additional_data) if is_previewable: @@ -160,7 +168,8 @@ def __init__(self,
bottom_box = Gtk.Box(spacing=6) bottom_box.add(self._message_widget) - more_menu_button = MoreMenuButton(self) + + more_menu_button = MoreMenuButton(self, self._contact, name) more_menu_button.set_hexpand(True) more_menu_button.set_halign(Gtk.Align.END) bottom_box.pack_end(more_menu_button, False, True, 0) @@ -242,6 +251,22 @@ def on_copy_message(self, _widget): def on_quote_message(self, _widget): self.get_parent().on_quote(self._message_widget.get_text())
+ def on_retract_message(self, _widget): + def _on_retract(reason): + self._client.get_module('MUC').retract_message( + self._contact.jid, self.stanza_id, reason or None) + + InputDialog( + _('Retract Message'), + _('Retract message?'), + _('Why do you want to retract this message?'), + [DialogButton.make('Cancel'), + DialogButton.make('Remove', + text=_('_Retract'), + callback=_on_retract)], + input_str=_('Spam'), + transient_for=app.window).show() + def _get_encryption_image(self, additional_data, encryption_enabled=None): details = self._get_encryption_details(additional_data) if details is None: @@ -296,6 +321,10 @@ def set_receipt(self): def set_displayed(self): self._has_displayed = True
+ def set_retracted(self, text: str) -> None: + self._message_widget.add_content(process(text)) + self.get_style_context().add_class('retracted-message') + def set_correction(self, text, message_id): self._corrections.append(self._message_widget.get_text()) result = process(text)
===================================== gajim/gtk/conversation/view.py ===================================== @@ -12,6 +12,8 @@ # You should have received a copy of the GNU General Public License # along with Gajim. If not, see http://www.gnu.org/licenses/.
+from typing import Optional + import logging import time
@@ -209,6 +211,7 @@ def add_message(self, timestamp, log_line_id=None, message_id=None, + stanza_id=None, correct_id=None, display_marking=None, additional_data=None, @@ -227,6 +230,7 @@ def add_message(self, self._account, self._contact, message_id, + stanza_id, timestamp, kind, name, @@ -404,6 +408,14 @@ def get_row_by_log_line_id(self, log_line_id): return row return None
+ def get_row_by_stanza_id(self, stanza_id: str) -> Optional[MessageRow]: + for row in self.get_children(): + if row.type != 'chat': + continue + if row.stanza_id == stanza_id: + return row + return None + def iter_rows(self): for row in self.get_children(): yield row @@ -448,6 +460,11 @@ def correct_message(self, correct_id, message_id, text): message_row.set_correction(text, message_id) message_row.set_merged(False)
+ def show_message_retraction(self, stanza_id: str, text: str) -> None: + message_row = self.get_row_by_stanza_id(stanza_id) + if message_row is not None: + message_row.set_retracted(text) + def show_receipt(self, id_): message_row = self._get_row_by_message_id(id_) if message_row is not None:
===================================== gajim/gtk/main.py ===================================== @@ -72,6 +72,7 @@ def __init__(self): ('message-received', ged.CORE, self._on_event), ('mam-message-received', ged.CORE, self._on_event), ('gc-message-received', ged.CORE, self._on_event), + ('message-updated', ged.CORE, self._on_event), ('receipt-received', ged.GUI1, self._on_event), ('displayed-received', ged.GUI1, self._on_event), ('message-error', ged.GUI1, self._on_event),
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/commit/365d7b175c050627196aa08dc22daa261...