Commit e55d282b authored by Daniel Brötzmann's avatar Daniel Brötzmann Committed by Philipp Hörist

Add Chat Markers (XEP-0333)

parent 29110347
Pipeline #6740 passed with stages
in 4 minutes and 20 seconds
......@@ -123,7 +123,7 @@ def __init__(self, parent_win, contact, acct, session, resource=None):
# Menu for the HeaderBar
self.control_menu = gui_menu_builder.get_singlechat_menu(
self.control_id, self.account, self.contact.jid)
self.control_id, self.account, self.contact.jid, self._type)
# Settings menu
self.xml.settings_menu.set_menu_model(self.control_menu)
......@@ -228,6 +228,7 @@ def __init__(self, parent_win, contact, acct, session, resource=None):
('mam-decrypted-message-received', ged.GUI1, self._on_mam_decrypted_message_received),
('decrypted-message-received', ged.GUI1, self._on_decrypted_message_received),
('receipt-received', ged.GUI1, self._receipt_received),
('displayed-received', ged.GUI1, self._displayed_received),
('message-error', ged.GUI1, self._on_message_error),
('zeroconf-error', ged.GUI1, self._on_zeroconf_error),
])
......@@ -271,6 +272,15 @@ def add_actions(self):
act.connect('change-state', self._on_send_chatstate)
self.parent_win.window.add_action(act)
marker = self.contact.settings.get('send_marker')
act = Gio.SimpleAction.new_stateful(
f'send-marker-{self.control_id}',
None,
GLib.Variant.new_boolean(marker))
act.connect('change-state', self._on_send_marker)
self.parent_win.window.add_action(act)
def update_actions(self):
win = self.parent_win.window
online = app.account_is_connected(self.account)
......@@ -338,6 +348,12 @@ def update_actions(self):
tooltip_text = _('No File Transfer available')
self.xml.sendfile_button.set_tooltip_text(tooltip_text)
# Chat markers
state = GLib.Variant.new_boolean(
self.contact.settings.get('send_marker'))
win.lookup_action(
f'send-marker-{self.control_id}').change_state(state)
# Convert to GC
if app.settings.get_account_setting(self.account, 'is_zeroconf'):
win.lookup_action(
......@@ -363,6 +379,7 @@ def remove_actions(self):
'information-',
'start-call-',
'send-chatstate-',
'send-marker-',
]
for action in actions:
self.parent_win.window.remove_action(f'{action}{self.control_id}')
......@@ -435,6 +452,10 @@ def _on_send_chatstate(self, action, param):
action.set_state(param)
self.contact.settings.set('send_chatstate', param.get_string())
def _on_send_marker(self, action, param):
action.set_state(param)
self.contact.settings.set('send_marker', param.get_boolean())
def subscribe_events(self):
"""
Register listeners to the events class
......@@ -643,6 +664,10 @@ def _on_message_sent(self, event):
def _receipt_received(self, event):
self.conv_textview.show_receipt(event.receipt_id)
@event_filter(['account', 'jid'])
def _displayed_received(self, event):
self.conv_textview.show_displayed(event.marker_id)
@event_filter(['account', 'jid'])
def _on_zeroconf_error(self, event):
self.add_status_message(event.message)
......@@ -1385,6 +1410,14 @@ def read_queue(self):
self.set_session(event.session)
if message_ids:
app.storage.archive.set_read_messages(message_ids)
# XEP-0333 Send <displayed> marker
con = app.connections[self.account]
con.get_module('ChatMarkers').send_displayed_marker(
self.contact,
self.last_msg_id,
self._type)
self.last_msg_id = None
app.events.remove_events(self.account,
jid_with_resource,
types=[str(self._type)])
......
......@@ -218,6 +218,9 @@ def __init__(self, parent_win, widget_name, contact, acct,
self.received_history_pos = 0
self.orig_msg = None
# For XEP-0333
self.last_msg_id = None
self.correcting = False
self.last_sent_msg = None
......@@ -1116,6 +1119,7 @@ def add_message(self,
displaymarking=None,
msg_log_id=None,
message_id=None,
stanza_id=None,
correct_id=None,
additional_data=None,
marker=None,
......@@ -1159,6 +1163,12 @@ def add_message(self,
if restored:
return
if message_id:
if self._type.is_groupchat:
self.last_msg_id = stanza_id or message_id
else:
self.last_msg_id = message_id
if kind == 'incoming':
if (not self._type.is_groupchat or
self.contact.can_notify() or
......@@ -1203,7 +1213,10 @@ def add_message(self,
event = event_type(text,
subject,
self, msg_log_id,
self,
msg_log_id,
message_id=message_id,
stanza_id=stanza_id,
show_in_roster=show_in_roster,
show_in_systray=show_in_systray)
app.events.add_event(self.account, full_jid, event)
......@@ -1355,7 +1368,7 @@ def set_control_active(self, state):
jid = self.contact.jid
if self.conv_textview.autoscroll:
# we are at the end
type_ = ['printed_%s' % self._type]
type_ = [f'printed_{self._type}']
if self._type.is_groupchat:
type_ = ['printed_gc_msg', 'printed_marked_gc_msg']
if not app.events.remove_events(self.account,
......@@ -1363,6 +1376,12 @@ def set_control_active(self, state):
types=type_):
# There were events to remove
self.redraw_after_event_removed(jid)
# XEP-0333 Send <displayed> marker
con.get_module('ChatMarkers').send_displayed_marker(
self.contact,
self.last_msg_id,
self._type)
self.last_msg_id = None
# send chatstate inactive to the one we're leaving
# and active to the one we visit
if self.msg_textview.has_text():
......@@ -1391,20 +1410,27 @@ def _on_edge_reached(self, _scrolledwindow, pos):
types_list = []
if self._type.is_groupchat:
types_list = ['printed_gc_msg', 'gc_msg', 'printed_marked_gc_msg']
else: # Not a GC
types_list = ['printed_%s' % self._type, str(self._type)]
else:
types_list = [f'printed_{self._type}', str(self._type)]
if not app.events.get_events(self.account, jid, types_list):
return
if not self.parent_win:
return
if self.parent_win.get_active_control() == self and \
self.parent_win.window.is_active():
if (self.parent_win.get_active_control() == self and
self.parent_win.window.is_active()):
# we are at the end
if not app.events.remove_events(
self.account, jid, types=types_list):
# There were events to remove
self.redraw_after_event_removed(jid)
# XEP-0333 Send <displayed> tag
con = app.connections[self.account]
con.get_module('ChatMarkers').send_displayed_marker(
self.contact,
self.last_msg_id,
self._type)
self.last_msg_id = None
def _on_scrollbar_button_release(self, scrollbar, event):
if event.get_button()[1] != 1:
......@@ -1469,6 +1495,8 @@ def redraw_after_event_removed(self, jid):
We just removed a 'printed_*' event, redraw contact in roster or
gc_roster and titles in roster and msg_win
"""
if not self.parent_win: # minimized groupchat
return
self.parent_win.redraw_tab(self)
self.parent_win.show_title()
# TODO : get the contact and check get_show_in_roster()
......
......@@ -970,6 +970,7 @@ def is_error(self):
Namespace.SECLABEL,
Namespace.CONFERENCE,
Namespace.CORRECT,
Namespace.CHATMARKERS,
Namespace.EME,
Namespace.XHTML_IM,
Namespace.HASHES_2,
......
......@@ -91,13 +91,16 @@ class PmEvent(ChatEvent):
class PrintedChatEvent(Event):
type_ = 'printed_chat'
def __init__(self, message, subject, control, msg_log_id, time_=None,
show_in_roster=False, show_in_systray=True):
message_id=None, stanza_id=None, show_in_roster=False,
show_in_systray=True):
Event.__init__(self, time_, show_in_roster=show_in_roster,
show_in_systray=show_in_systray)
show_in_systray=show_in_systray)
self.message = message
self.subject = subject
self.control = control
self.msg_log_id = msg_log_id
self.message_id = message_id
self.stanza_id = stanza_id
class PrintedGcMsgEvent(PrintedChatEvent):
type_ = 'printed_gc_msg'
......
......@@ -1406,3 +1406,13 @@ def get_group_chat_nick(account, room_jid):
nick = bookmark.nick
return nick
def get_muc_context(jid):
disco_info = app.storage.cache.get_last_disco_info(jid)
if disco_info is None:
return None
if (disco_info.muc_is_members_only and disco_info.muc_is_nonanonymous):
return 'private'
return 'public'
......@@ -40,6 +40,7 @@
'bookmarks',
'caps',
'carbons',
'chat_markers',
'chatstates',
'delimiter',
'discovery',
......
# 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/>.
# Chat Markers (XEP-0333)
from nbxmpp.namespaces import Namespace
from nbxmpp.structs import StanzaHandler
from gajim.common import app
from gajim.common.nec import NetworkEvent
from gajim.common.modules.base import BaseModule
from gajim.common.structs import OutgoingMessage
class ChatMarkers(BaseModule):
_nbxmpp_extends = 'ChatMarkers'
def __init__(self, con):
BaseModule.__init__(self, con)
self.handlers = [
StanzaHandler(name='message',
callback=self._process_chat_marker,
ns=Namespace.CHATMARKERS,
priority=47),
]
def _process_chat_marker(self, _con, _stanza, properties):
if not properties.is_marker or not properties.marker.is_displayed:
return
if properties.type.is_error:
return
if properties.type.is_groupchat:
manager = self._con.get_module('MUC').get_manager()
muc_data = manager.get(properties.muc_jid)
if muc_data is None:
return
if properties.muc_nickname != muc_data.nick:
return
self._raise_event('read-state-sync', properties)
return
if properties.is_carbon_message and properties.carbon.is_sent:
self._raise_event('read-state-sync', properties)
return
if properties.is_mam_message:
if properties.from_.bareMatch(self._con.get_own_jid()):
return
self._raise_event('displayed-received', properties)
def _raise_event(self, name, properties):
self._log.info('%s: %s %s',
name,
properties.jid,
properties.marker.id)
jid = properties.jid
if not properties.is_muc_pm and not properties.type.is_groupchat:
jid = properties.jid.bare
app.nec.push_outgoing_event(
NetworkEvent(name,
account=self._account,
jid=jid,
properties=properties,
type=properties.type,
is_muc_pm=properties.is_muc_pm,
marker_id=properties.marker.id))
def _send_marker(self, contact, marker, id_, type_):
jid = contact.jid
if contact.is_pm_contact:
jid = app.get_jid_without_resource(contact.jid)
if type_ in ('gc', 'pm'):
disco_info = app.storage.cache.get_last_disco_info(jid)
context = 'public'
if disco_info is not None and disco_info.muc_is_members_only:
context = 'private'
if not app.settings.get_group_chat_setting(
self._account, jid, 'send_marker', context=context):
return
else:
if not app.settings.get_contact_setting(
self._account, jid, 'send_marker'):
return
typ = 'groupchat' if type_ == 'gc' else 'chat'
message = OutgoingMessage(account=self._account,
contact=contact,
message=None,
type_=typ,
marker=(marker, id_),
play_sound=False)
self._con.send_message(message)
self._log.info('Send %s: %s', marker, contact.jid)
def send_displayed_marker(self, contact, id_, type_):
self._send_marker(contact, 'displayed', id_, str(type_))
def get_instance(*args, **kwargs):
return ChatMarkers(*args, **kwargs), 'ChatMarkers'
......@@ -350,6 +350,13 @@ def build_message_stanza(self, message):
stanza.setTag('no-store',
namespace=Namespace.MSG_HINTS)
# XEP-0333
if message.message:
stanza.setMarkable()
if message.marker:
marker, id_ = message.marker
stanza.setMarker(marker, id_)
# Add other nodes
if message.nodes is not None:
for node in message.nodes:
......
......@@ -255,12 +255,16 @@ class _ACCOUNT_DEFAULT:
'filetransfer_preference': 'httpupload',
'send_chatstate_default': 'composing_only',
'gc_send_chatstate_default': 'composing_only',
'send_marker_default': True,
'gc_send_marker_private_default': True,
'gc_send_marker_public_default': False,
'chat_history_max_age': -1,
},
'contact': {
'speller_language': '',
'send_chatstate': HAS_ACCOUNT_DEFAULT,
'send_marker': HAS_ACCOUNT_DEFAULT,
'encryption': '',
},
......@@ -272,6 +276,7 @@ class _ACCOUNT_DEFAULT:
'minimize_on_autojoin': True,
'minimize_on_close': True,
'send_chatstate': HAS_ACCOUNT_DEFAULT,
'send_marker': HAS_ACCOUNT_DEFAULT,
'encryption': '',
'sync_threshold': HAS_APP_DEFAULT,
},
......
......@@ -33,6 +33,7 @@
from gajim.common import app
from gajim.common import configpaths
from gajim.common import optparser
from gajim.common.helpers import get_muc_context
from gajim.common.setting_values import APP_SETTINGS
from gajim.common.setting_values import ACCOUNT_SETTINGS
from gajim.common.setting_values import PROXY_SETTINGS
......@@ -678,11 +679,16 @@ def set_group_chat_setting(self,
def set_group_chat_settings(self,
setting: str,
value: SETTING_TYPE) -> None:
value: SETTING_TYPE,
context: str = None) -> None:
for account in self._account_settings:
for jid in self._account_settings[account]['group_chat']:
self.set_group_chat_setting(account, jid, setting, value)
if context is not None:
if get_muc_context(jid) != context:
continue
self.set_group_chat_setting(
account, jid, setting, value, context)
def get_contact_setting(self,
account: str,
......
......@@ -55,6 +55,7 @@ def __init__(self,
type_,
subject=None,
chatstate=None,
marker=None,
resource=None,
user_nick=None,
label=None,
......@@ -69,7 +70,7 @@ def __init__(self,
if type_ not in ('chat', 'groupchat', 'normal', 'headline'):
raise ValueError('Unknown message type: %s' % type_)
if not message and chatstate is None:
if not message and chatstate is None and marker is None:
raise ValueError('Trying to send message without content')
self.account = account
......@@ -91,6 +92,7 @@ def __init__(self,
self.subject = subject
self.chatstate = chatstate
self.marker = marker
self.resource = resource
self.user_nick = user_nick
self.label = label
......
......@@ -38,6 +38,7 @@
from gajim.common import helpers
from gajim.common import i18n
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim.common.helpers import AdditionalDataDict
from gajim.common.const import StyleAttr
from gajim.common.const import Trust
......@@ -311,6 +312,12 @@ def show_receipt(self, id_):
return
line.set_receipt()
def show_displayed(self, id_):
line = self._get_message_line(id_)
if line is None:
return
line.set_displayed()
def show_error(self, id_, error):
line = self._get_message_line(id_)
if line is None:
......@@ -1194,16 +1201,27 @@ def __init__(self, id_, timestamp, message_icons, start_mark):
self.timestamp = timestamp
self.start_mark = start_mark
self._has_receipt = False
self._has_displayed = False
self._message_icons = message_icons
@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
if self._has_displayed:
return
self._message_icons.set_receipt_icon_visible(True)
def set_displayed(self):
self._has_displayed = True
self._message_icons.set_displayed_icon_visible(True)
def set_correction(self, tooltip):
self._message_icons.set_correction_icon_visible(True)
self._message_icons.set_correction_tooltip(tooltip)
......@@ -1222,12 +1240,12 @@ def __init__(self):
'document-edit-symbolic', Gtk.IconSize.MENU)
self._correction_image.set_no_show_all(True)
self._receipt_image = Gtk.Image.new_from_icon_name(
self._marker_image = Gtk.Image.new_from_icon_name(
'emblem-ok-symbolic', Gtk.IconSize.MENU)
self._receipt_image.get_style_context().add_class(
self._marker_image.get_style_context().add_class(
'receipt-received-color')
self._receipt_image.set_tooltip_text(_('Received'))
self._receipt_image.set_no_show_all(True)
self._marker_image.set_tooltip_text(_('Received'))
self._marker_image.set_no_show_all(True)
self._error_image = Gtk.Image.new_from_icon_name(
'dialog-warning-symbolic', Gtk.IconSize.MENU)
......@@ -1235,14 +1253,22 @@ def __init__(self):
self._error_image.set_no_show_all(True)
self.add(self._correction_image)
self.add(self._receipt_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._receipt_image.set_visible(visible)
self._marker_image.set_visible(visible)
def set_displayed_icon_visible(self, visible):
self._marker_image.set_visible(visible)
self._marker_image.get_style_context().remove_class(
'receipt-received-color')
self._marker_image.get_style_context().add_class(
'displayed-received-color')
self._marker_image.set_tooltip_text(Q_('?Message state:Read'))
def set_correction_icon_visible(self, visible):
self._correction_image.set_visible(visible)
......
......@@ -295,6 +295,7 @@ button.flat.link { padding: 0; border: 0; }
/*Receipts*/
.receipt-received-color { color: rgb(75, 181, 67); }
.displayed-received-color { color: rgb(0, 141, 242); }
/*Dataforms*/
.field-fixed { font-size: 16px; font-weight: bold; padding-top:5px;}
......
......@@ -658,6 +658,17 @@ def on_groupchat_maximize(self):
self.update_actions()
self.set_lock_image()
self.draw_banner_text()
type_ = ['printed_gc_msg', 'printed_marked_gc_msg']
if not app.events.remove_events(self.account,
self.get_full_jid(),
types=type_):
# XEP-0333 Send <displayed> marker
con = app.connections[self.account]
con.get_module('ChatMarkers').send_displayed_marker(
self.contact,
self.last_msg_id,
self._type)
self.last_msg_id = None
def _on_roster_row_activated(self, _roster, nick):
self._start_private_message(nick)
......@@ -796,17 +807,21 @@ def _on_gc_message_received(self, event):
else:
if event.properties.muc_nickname == self.nick:
self.last_sent_txt = event.msgtxt
stanza_id = None
if event.properties.stanza_id:
stanza_id = event.properties.stanza_id.id
self.add_message(event.msgtxt,
contact=event.properties.muc_nickname,
tim=event.properties.timestamp,
displaymarking=event.displaymarking,
correct_id=event.correct_id,
message_id=event.properties.id,
stanza_id=stanza_id,
additional_data=event.additional_data)
event.needs_highlight = self.needs_visual_notification(event.msgtxt)
def on_private_message(self, nick, sent, msg, tim, session, additional_data,
msg_log_id=None, displaymarking=None):
message_id, msg_log_id=None, displaymarking=None):
# Do we have a queue?
fjid = self.room_jid + '/' + nick
......@@ -819,7 +834,9 @@ def on_private_message(self, nick, sent, msg, tim, session, additional_data,
session=session,
displaymarking=displaymarking,
sent_forwarded=sent,
additional_data=additional_data)
additional_data=additional_data,
message_id=message_id)
app.events.add_event(self.account, fjid, event)
if allow_popup_window(self.account):
......@@ -837,7 +854,7 @@ def on_private_message(self, nick, sent, msg, tim, session, additional_data,
def add_message(self, text, contact='', tim=None,
displaymarking=None, correct_id=None, message_id=None,
additional_data=None):
stanza_id=None, additional_data=None):
"""
Add message to the ConversationsTextview
......@@ -887,6 +904,7 @@ def add_message(self, text, contact='', tim=None,
displaymarking=displaymarking,
correct_id=correct_id,
message_id=message_id,
stanza_id=stanza_id,
additional_data=additional_data)
def get_nb_unread(self):
......@@ -1057,6 +1075,7 @@ def _on_decrypted_message_received(self, event):
event.properties.timestamp,
self.session,
event.additional_data,
event.properties.id,
msg_log_id=event.msg_log_id,
displaymarking=event.displaymarking)
......