# 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 Optional
from typing import Union
from datetime import datetime
from datetime import timedelta
import textwrap
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import Pango
import cairo
from nbxmpp.errors import StanzaError
from nbxmpp.modules.security_labels import Displaymarking
from nbxmpp.structs import CommonError
from gajim.common import app
from gajim.common.const import AvatarSize
from gajim.common.const import Trust
from gajim.common.const import TRUST_SYMBOL_DATA
from gajim.common.helpers import AdditionalDataDict
from gajim.common.helpers import from_one_line
from gajim.common.helpers import get_group_chat_nick
from gajim.common.helpers import get_muc_context
from gajim.common.helpers import message_needs_highlight
from gajim.common.helpers import to_user_string
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim.common.modules.contacts import GroupchatContact
from gajim.common.types import ChatContactT
from .base import BaseRow
from .widgets 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
MERGE_TIMEFRAME = timedelta(seconds=120)
class MessageRow(BaseRow):
def __init__(self,
account: str,
contact: ChatContactT,
message_id: Optional[str],
stanza_id: Optional[str],
timestamp: float,
kind: str,
name: str,
text: str,
additional_data: Optional[AdditionalDataDict] = None,
display_marking: Optional[Displaymarking] = None,
marker: Optional[str] = None,
error: Union[CommonError, StanzaError, None] = None,
log_line_id: Optional[int] = None) -> None:
BaseRow.__init__(self, account)
self.type = 'chat'
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
self.text = text
self.additional_data = additional_data
self.display_marking = display_marking
self._account = account
self._contact = contact
self._is_groupchat: bool = False
if contact is not None and contact.is_groupchat:
self._is_groupchat = True
self._has_receipt: bool = marker == 'received'
self._has_displayed: bool = marker == 'displayed'
# Keep original text for message correction
self._original_text: str = text
if self._is_groupchat:
our_nick = get_group_chat_nick(self._account, self._contact.jid)
from_us = name == our_nick
else:
from_us = kind == 'outgoing'
is_previewable = False
preview_enabled = app.settings.get('enable_file_preview')
if additional_data is not None and preview_enabled:
is_previewable = app.preview_manager.is_previewable(
text, additional_data)
if is_previewable:
context = None
if self._is_groupchat:
context = get_muc_context(self._contact.jid)
self._message_widget = PreviewWidget(account)
app.preview_manager.create_preview(
text, self._message_widget, from_us, context)
else:
self._message_widget = MessageWidget(account)
self._message_widget.add_with_styling(text, nickname=name)
if self._is_groupchat:
our_nick = get_group_chat_nick(
self._account, self._contact.jid)
if name != our_nick:
self._check_for_highlight(text)
if self._contact.jid == self._client.get_own_jid().bare:
name = _('Me')
name_widget = self.create_name_widget(name, from_us)
self._meta_box = Gtk.Box(spacing=6)
self._meta_box.set_hexpand(True)
self._meta_box.pack_start(name_widget, False, True, 0)
timestamp_label = self.create_timestamp_widget(self.timestamp)
self._meta_box.pack_start(timestamp_label, False, True, 0)
if additional_data is not None:
encryption_img = self._get_encryption_image(additional_data)
if encryption_img:
self._meta_box.pack_start(encryption_img, False, True, 0)
if display_marking and app.settings.get_account_setting(
account, 'enable_security_labels'):
label_text = GLib.markup_escape_text(display_marking.name)
if label_text:
display_marking_label = Gtk.Label()
display_marking_label.set_ellipsize(Pango.EllipsizeMode.END)
display_marking_label.set_max_width_chars(30)
display_marking_label.set_tooltip_text(label_text)
bgcolor = display_marking.bgcolor
fgcolor = display_marking.fgcolor
label_text = (
f'{label_text}')
display_marking_label.set_markup(label_text)
self._meta_box.add(display_marking_label)
self._message_icons = MessageIcons()
if additional_data is not None:
if additional_data.get_value('retracted', 'by') is not None:
self.get_style_context().add_class('retracted-message')
correction_original = additional_data.get_value(
'corrected', 'original_text')
if correction_original is not None:
self._original_text = correction_original
self._message_icons.set_correction_icon_visible(True)
original_text = textwrap.fill(correction_original,
width=150,
max_lines=10,
placeholder='…')
self._message_icons.set_correction_tooltip(
_('Message corrected. Original message:'
'\n%s') % original_text)
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_start(self._message_icons, False, True, 0)
avatar = self._get_avatar(kind, name)
self._avatar_image = Gtk.Image.new_from_surface(avatar)
if self._is_groupchat:
avatar_placeholder = Gtk.EventBox()
avatar_placeholder.connect(
'button-press-event', self._on_avatar_clicked, name)
avatar_placeholder.connect('realize', self._on_realize)
else:
avatar_placeholder = Gtk.Box()
avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1)
avatar_placeholder.set_valign(Gtk.Align.START)
avatar_placeholder.add(self._avatar_image)
self._bottom_box = Gtk.Box(spacing=6)
self._bottom_box.add(self._message_widget)
more_menu_button = MoreMenuButton(self, self._contact, name)
more_menu_button.set_hexpand(True)
more_menu_button.set_halign(Gtk.Align.END)
self._bottom_box.pack_end(more_menu_button, False, True, 0)
self.grid.attach(avatar_placeholder, 0, 0, 1, 2)
self.grid.attach(self._meta_box, 1, 0, 1, 1)
self.grid.attach(self._bottom_box, 1, 1, 1, 1)
self.show_all()
def _check_for_highlight(self, text: str) -> None:
assert isinstance(self._contact, GroupchatContact)
if self._contact.nickname is None:
return
needs_highlight = message_needs_highlight(
text,
self._contact.nickname,
self._client.get_own_jid().bare)
if needs_highlight:
self.get_style_context().add_class(
'gajim-mention-highlight')
def _get_avatar(self, kind: str, name: str) -> Optional[cairo.ImageSurface]:
if self._contact is None:
return None
scale = self.get_scale_factor()
if isinstance(self._contact, GroupchatContact):
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
avatar = contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False)
assert not isinstance(avatar, GdkPixbuf.Pixbuf)
return avatar
def _on_avatar_clicked(self,
_widget: Gtk.Widget,
event: Gdk.EventButton,
name: str
) -> int:
if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 1:
app.window.activate_action('mention', GLib.Variant('s', name))
return Gdk.EVENT_STOP
@staticmethod
def _on_realize(event_box: Gtk.EventBox) -> None:
window = event_box.get_window()
if window is not None:
window.set_cursor(get_cursor('pointer'))
def is_same_sender(self, message: MessageRow) -> bool:
return message.name == self.name
def is_same_encryption(self, message: MessageRow) -> bool:
m_add_data = message.additional_data
if m_add_data is None:
m_add_data = AdditionalDataDict()
s_add_data = self.additional_data
if s_add_data is None:
s_add_data = AdditionalDataDict()
message_details = self._get_encryption_details(m_add_data)
own_details = self._get_encryption_details(s_add_data)
if message_details is None and own_details is None:
return True
if message_details is not None and own_details is not None:
# *_details contains encryption method's name, fingerprint, trust
m_name, _, m_trust = message_details
o_name, _, o_trust = own_details
if m_name == o_name and m_trust == o_trust:
return True
return False
def is_same_display_marking(self, message: MessageRow) -> bool:
if message.display_marking == self.display_marking:
return True
if (message.display_marking is not None and
self.display_marking is not None):
if message.display_marking.name == self.display_marking.name:
return True
return False
def is_mergeable(self, message: MessageRow) -> bool:
if message.type != self.type:
return False
if not self.is_same_sender(message):
return False
if not self.is_same_encryption(message):
return False
if not self.is_same_display_marking(message):
return False
return abs(message.timestamp - self.timestamp) < MERGE_TIMEFRAME
def on_copy_message(self, _widget: Gtk.Widget) -> None:
time_format = from_one_line(app.settings.get('chat_timestamp_format'))
timestamp_formatted = self.timestamp.strftime(time_format)
clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
text = self._message_widget.get_text()
clip.set_text(f'{timestamp_formatted} - {self.name}: {text}', -1)
def on_quote_message(self, _widget: Gtk.Widget) -> None:
app.window.activate_action(
'quote', GLib.Variant('s', self._message_widget.get_text()))
def on_correct_message(self, _widget: Gtk.Widget) -> None:
app.window.activate_action('correct-message', None)
def on_retract_message(self, _widget: Gtk.Widget) -> None:
def _on_retract(reason: str) -> None:
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: AdditionalDataDict,
) -> Optional[Gtk.Image]:
details = self._get_encryption_details(additional_data)
if details is None:
# Message was not encrypted
if not self._contact.settings.get('encryption'):
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(trust)]
tooltip = f'{tooltip}\n{trust_tooltip}'
if fingerprint is not None:
fingerprint = format_fingerprint(fingerprint)
tooltip = f'{tooltip}\n{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: AdditionalDataDict
) -> Optional[tuple[
str, Optional[str], Optional[Trust]]]:
name = additional_data.get_value('encrypted', 'name')
if name is None:
return None
fingerprint = additional_data.get_value('encrypted', 'fingerprint')
trust_data = additional_data.get_value('encrypted', 'trust')
if trust_data is not None:
trust_data = Trust(trust_data)
return name, fingerprint, trust_data
@property
def has_receipt(self) -> bool:
return self._has_receipt
@property
def has_displayed(self) -> bool:
return self._has_displayed
def set_receipt(self) -> None:
self._has_receipt = True
self._message_icons.set_receipt_icon_visible(True)
def set_displayed(self) -> None:
self._has_displayed = True
def set_retracted(self, text: str) -> None:
if isinstance(self._message_widget, PreviewWidget):
self._message_widget.destroy()
self._message_widget = MessageWidget(self._account)
self._bottom_box.pack_start(self._message_widget, True, True, 0)
self._message_widget.add_with_styling(text)
self.get_style_context().add_class('retracted-message')
def set_correction(self, text: str, nickname: Optional[str]) -> None:
if not isinstance(self._message_widget, PreviewWidget):
self._message_widget.add_with_styling(text, nickname)
self._has_receipt = False
self._message_icons.set_receipt_icon_visible(False)
self._message_icons.set_correction_icon_visible(True)
original_text = textwrap.fill(self._original_text,
width=150,
max_lines=10,
placeholder='…')
self._message_icons.set_correction_tooltip(
_('Message corrected. Original message:\n%s') % original_text)
def set_error(self, tooltip: str) -> None:
self._message_icons.set_error_icon_visible(True)
self._message_icons.set_error_tooltip(tooltip)
def update_avatar(self) -> None:
avatar = self._get_avatar(self.kind, self.name)
self._avatar_image.set_from_surface(avatar)
def set_merged(self, merged: bool) -> None:
self._merged = merged
if merged:
self.get_style_context().add_class('merged')
self._avatar_image.set_no_show_all(True)
self._avatar_image.hide()
self._meta_box.set_no_show_all(True)
self._meta_box.hide()
else:
self.get_style_context().remove_class('merged')
self._avatar_image.set_no_show_all(False)
self._avatar_image.show()
self._meta_box.set_no_show_all(False)
self._meta_box.show()
class MessageIcons(Gtk.Box):
def __init__(self) -> None:
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: bool) -> None:
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: bool) -> None:
self._correction_image.set_visible(visible)
def set_correction_tooltip(self, text: str) -> None:
self._correction_image.set_tooltip_markup(text)
def set_error_icon_visible(self, visible: bool) -> None:
self._error_image.set_visible(visible)
def set_error_tooltip(self, text: str) -> None:
self._error_image.set_tooltip_markup(text)