diff --git a/gajim/chat_control.py b/gajim/chat_control.py index e2dbc030d..d78b0b317 100644 --- a/gajim/chat_control.py +++ b/gajim/chat_control.py @@ -36,7 +36,6 @@ from gi.repository import GLib from nbxmpp.protocol import NS_XHTML, NS_XHTML_IM, NS_FILE, NS_MUC from nbxmpp.protocol import NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_VIDEO from nbxmpp.protocol import NS_JINGLE_ICE_UDP, NS_JINGLE_FILE_TRANSFER_5 -from nbxmpp.protocol import NS_CHATSTATES from gajim import gtkgui_helpers from gajim import gui_menu_builder @@ -50,8 +49,9 @@ from gajim.common import helpers from gajim.common import ged from gajim.common import i18n from gajim.common.contacts import GC_Contact -from gajim.common.connection_handlers_events import MessageOutgoingEvent -from gajim.common.const import AvatarSize, KindConstant +from gajim.common.const import AvatarSize +from gajim.common.const import KindConstant +from gajim.common.const import Chatstate from gajim.command_system.implementation.hosts import ChatCommands from gajim.command_system.framework import CommandHost # pylint: disable=unused-import @@ -678,7 +678,7 @@ class ChatControl(ChatControlBase): status_escaped = GLib.markup_escape_text(status_reduced) st = app.config.get('displayed_chat_state_notifications') - cs = contact.chatstate + cs = app.contacts.get_combined_chatstate(self.account, self.contact.jid) if cs and st in ('composing_only', 'all'): if contact.show == 'offline': chatstate = '' @@ -882,8 +882,8 @@ class ChatControl(ChatControlBase): correct_id=obj.correct_id, additional_data=obj.additional_data) - def send_message(self, message, keyID='', chatstate=None, xhtml=None, - process_commands=True, attention=False): + def send_message(self, message, keyID='', xhtml=None, + process_commands=True, attention=False): """ Send a message to contact """ @@ -902,18 +902,13 @@ class ChatControl(ChatControlBase): contact = self.contact keyID = contact.keyID - chatstate_to_send = None - if contact is not None: - if contact.supports(NS_CHATSTATES): - # send active chatstate on every message (as XEP says) - chatstate_to_send = 'active' - contact.our_chatstate = 'active' - - self._schedule_activity_timers() - - ChatControlBase.send_message(self, message, keyID, type_='chat', - chatstate=chatstate_to_send, xhtml=xhtml, - process_commands=process_commands, attention=attention) + ChatControlBase.send_message(self, + message, + keyID, + type_='chat', + xhtml=xhtml, + process_commands=process_commands, + attention=attention) def get_our_nick(self): return app.nicks[self.account] @@ -1059,79 +1054,6 @@ class ChatControl(ChatControlBase): show_buttonbar_items=not hide_buttonbar_items) return menu - def send_chatstate(self, state, contact=None): - """ - Send OUR chatstate as STANDLONE chat state message (eg. no body) - to contact only if new chatstate is different from the previous one - if jid is not specified, send to active tab - """ - # JEP 85 does not allow resending the same chatstate - # this function checks for that and just returns so it's safe to call it - # with same state. - - # This functions also checks for violation in state transitions - # and raises RuntimeException with appropriate message - # more on that http://xmpp.org/extensions/xep-0085.html#statechart - - # do not send if we have chat state notifications disabled - # that means we won't reply to the from other peer - # so we do not broadcast jep85 capabalities - chatstate_setting = app.config.get('outgoing_chat_state_notifications') - if chatstate_setting == 'disabled': - return - - # Dont leak presence to contacts - # which are not allowed to see our status - if contact and contact.sub in ('to', 'none'): - return - - if self.contact.jid == app.get_jid_from_account(self.account): - return - - if chatstate_setting == 'composing_only' and state != 'active' and\ - state != 'composing': - return - - if contact is None: - contact = self.parent_win.get_active_contact() - if contact is None: - # contact was from pm in MUC, and left the room so contact is None - # so we cannot send chatstate anymore - return - - # Don't send chatstates to offline contacts - if contact.show == 'offline': - return - - if not contact.supports(NS_CHATSTATES): - return - if contact.our_chatstate is False: - return - - # if the new state we wanna send (state) equals - # the current state (contact.our_chatstate) then return - if contact.our_chatstate == state: - return - - # if wel're inactive prevent composing (XEP violation) - if contact.our_chatstate == 'inactive' and state == 'composing': - # go active before - app.log('chatstates').info('%-10s - %s', 'active', self.contact.jid) - app.nec.push_outgoing_event(MessageOutgoingEvent(None, - account=self.account, jid=self.contact.jid, chatstate='active', - control=self)) - contact.our_chatstate = 'active' - self.reset_kbd_mouse_timeout_vars() - - app.log('chatstates').info('%-10s - %s', state, self.contact.jid) - app.nec.push_outgoing_event(MessageOutgoingEvent(None, - account=self.account, jid=self.contact.jid, chatstate=state, - control=self)) - - contact.our_chatstate = state - if state == 'active': - self.reset_kbd_mouse_timeout_vars() - def shutdown(self): # PluginSystem: removing GUI extension points connected with ChatControl # instance object @@ -1161,9 +1083,8 @@ class ChatControl(ChatControlBase): self.unsubscribe_events() # Send 'gone' chatstate - self.send_chatstate('gone', self.contact) - self.contact.chatstate = None - self.contact.our_chatstate = None + con = app.connections[self.account] + con.get_module('Chatstate').set_chatstate(self.contact, Chatstate.GONE) for jingle_type in ('audio', 'video'): self.close_jingle_content(jingle_type) @@ -1225,13 +1146,18 @@ class ChatControl(ChatControlBase): return on_yes(self) - def _nec_chatstate_received(self, obj): - """ - Handle incoming chatstate that jid SENT TO us - """ + def _nec_chatstate_received(self, event): + if event.account != self.account: + return + + if event.jid != self.contact.jid: + return + self.draw_banner_text() # update chatstate in tab for this chat - self.parent_win.redraw_tab(self, self.contact.chatstate) + chatstate = app.contacts.get_combined_chatstate( + self.account, self.contact.jid) + self.parent_win.redraw_tab(self, chatstate) def _nec_caps_received(self, obj): if obj.conn.name != self.account: diff --git a/gajim/chat_control_base.py b/gajim/chat_control_base.py index 49eab673e..5a2c4793e 100644 --- a/gajim/chat_control_base.py +++ b/gajim/chat_control_base.py @@ -49,6 +49,7 @@ from gajim.message_textview import MessageTextView from gajim.common.contacts import GC_Contact from gajim.common.connection_handlers_events import MessageOutgoingEvent from gajim.common.const import StyleAttr +from gajim.common.const import Chatstate from gajim.command_system.implementation.middleware import ChatCommandProcessor from gajim.command_system.implementation.middleware import CommandTools @@ -325,10 +326,9 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): # Security Labels self.seclabel_combo = self.xml.get_object('label_selector') - # chatstate timers and state - self.reset_kbd_mouse_timeout_vars() - self.possible_paused_timeout_id = None - self.possible_inactive_timeout_id = None + con = app.connections[self.account] + con.get_module('Chatstate').set_active(self.contact.jid) + message_tv_buffer = self.msg_textview.get_buffer() id_ = message_tv_buffer.connect('changed', self._on_message_tv_buffer_changed) @@ -337,7 +337,6 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): id_ = parent_win.window.connect('motion-notify-event', self._on_window_motion_notify) self.handlers[id_] = parent_win.window - self._schedule_activity_timers() self.encryption = self.get_encryption_state() self.conv_textview.encryption_enabled = self.encryption is not None @@ -520,11 +519,6 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): def shutdown(self): super(ChatControlBase, self).shutdown() - # Disconnect timer callbacks - if self.possible_paused_timeout_id: - GLib.source_remove(self.possible_paused_timeout_id) - if self.possible_inactive_timeout_id: - GLib.source_remove(self.possible_inactive_timeout_id) # PluginSystem: removing GUI extension points connected with ChatControlBase # instance object app.plugin_manager.remove_gui_extension_point('chat_control_base', @@ -777,7 +771,7 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): label = labels[lname] return label - def send_message(self, message, keyID='', type_='chat', chatstate=None, + def send_message(self, message, keyID='', type_='chat', resource=None, xhtml=None, process_commands=True, attention=False): """ Send the given message to the active tab. Doesn't return None if error @@ -788,14 +782,6 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): if process_commands and self.process_as_command(message): return - # refresh timers - self.reset_kbd_mouse_timeout_vars() - - notifications = app.config.get('outgoing_chat_state_notifications') - if (self.contact.jid == app.get_jid_from_account(self.account) or - notifications == 'disabled'): - chatstate = None - label = self.get_seclabel() if self.correcting and self.last_sent_msg: @@ -803,6 +789,10 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): else: correct_id = None + con = app.connections[self.account] + chatstate = con.get_module('Chatstate').get_active_chatstate( + self.contact) + app.nec.push_outgoing_event(MessageOutgoingEvent(None, account=self.account, jid=self.contact.jid, message=message, keyID=keyID, type_=type_, chatstate=chatstate, @@ -820,76 +810,7 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): message_buffer = self.msg_textview.get_buffer() message_buffer.set_text('') # clear message buffer (and tv of course) - def check_for_possible_paused_chatstate(self, arg): - """ - Did we move mouse of that window or write something in message textview - in the last 5 seconds? If yes - we go active for mouse, composing for - kbd. If not - we go paused if we were previously composing - """ - contact = self.contact - jid = contact.jid - current_state = contact.our_chatstate - if current_state is False: # jid doesn't support chatstates - self.possible_paused_timeout_id = None - return False # stop looping - - if current_state == 'composing': - if not self.kbd_activity_in_last_5_secs: - if self.msg_textview.has_text(): - self.send_chatstate('paused', self.contact) - else: - self.send_chatstate('active', self.contact) - elif current_state == 'inactive': - if (self.mouse_over_in_last_5_secs and - jid == self.parent_win.get_active_jid()): - self.send_chatstate('active', self.contact) - - # assume no activity and let the motion-notify or 'insert-text' make them - # True refresh 30 seconds vars too or else it's 30 - 5 = 25 seconds! - self.reset_kbd_mouse_timeout_vars() - return True # loop forever - - def check_for_possible_inactive_chatstate(self, arg): - """ - Did we move mouse over that window or wrote something in message textview - in the last 30 seconds? if yes - we go active. If no - we go inactive - """ - contact = self.contact - - current_state = contact.our_chatstate - if current_state is False: # jid doesn't support chatstates - self.possible_inactive_timeout_id = None - return False # stop looping - - if self.mouse_over_in_last_5_secs or self.kbd_activity_in_last_5_secs: - return True # loop forever - - if not self.mouse_over_in_last_30_secs or \ - self.kbd_activity_in_last_30_secs: - self.send_chatstate('inactive', contact) - - # assume no activity and let the motion-notify or 'insert-text' make them - # True refresh 30 seconds too or else it's 30 - 5 = 25 seconds! - self.reset_kbd_mouse_timeout_vars() - return True # loop forever - - def _schedule_activity_timers(self): - if self.possible_paused_timeout_id: - GLib.source_remove(self.possible_paused_timeout_id) - if self.possible_inactive_timeout_id: - GLib.source_remove(self.possible_inactive_timeout_id) - self.possible_paused_timeout_id = GLib.timeout_add_seconds(5, - self.check_for_possible_paused_chatstate, None) - self.possible_inactive_timeout_id = GLib.timeout_add_seconds(30, - self.check_for_possible_inactive_chatstate, None) - - def reset_kbd_mouse_timeout_vars(self): - self.kbd_activity_in_last_5_secs = False - self.mouse_over_in_last_5_secs = False - self.mouse_over_in_last_30_secs = False - self.kbd_activity_in_last_30_secs = False - - def _on_window_motion_notify(self, widget, event): + def _on_window_motion_notify(self, *args): """ It gets called no matter if it is the active window or not """ @@ -897,16 +818,19 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): # when a groupchat is minimized there is no parent window return if self.parent_win.get_active_jid() == self.contact.jid: - # if window is the active one, change vars assisting chatstate - self.mouse_over_in_last_5_secs = True - self.mouse_over_in_last_30_secs = True + # if window is the active one, set last interaction + con = app.connections[self.account] + con.get_module('Chatstate').set_mouse_activity(self.contact) - def _on_message_tv_buffer_changed(self, textbuffer): - self.kbd_activity_in_last_5_secs = True - self.kbd_activity_in_last_30_secs = True + def _on_message_tv_buffer_changed(self, *args): + con = app.connections[self.account] + con.get_module('Chatstate').set_keyboard_activity(self.contact) if not self.msg_textview.has_text(): + con.get_module('Chatstate').set_chatstate(self.contact, + Chatstate.ACTIVE) return - self.send_chatstate('composing', self.contact) + con.get_module('Chatstate').set_chatstate(self.contact, + Chatstate.COMPOSING) def save_message(self, message, msg_type): # save the message, so user can scroll though the list with key up/down @@ -1183,6 +1107,7 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): widget.get_active()) def set_control_active(self, state): + con = app.connections[self.account] if state: self.set_emoticon_popover() jid = self.contact.jid @@ -1198,13 +1123,14 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): # send chatstate inactive to the one we're leaving # and active to the one we visit if self.msg_textview.has_text(): - self.send_chatstate('paused', self.contact) + con.get_module('Chatstate').set_chatstate(self.contact, + Chatstate.PAUSED) else: - self.send_chatstate('active', self.contact) - self.reset_kbd_mouse_timeout_vars() - self._schedule_activity_timers() + con.get_module('Chatstate').set_chatstate(self.contact, + Chatstate.ACTIVE) else: - self.send_chatstate('inactive', self.contact) + con.get_module('Chatstate').set_chatstate(self.contact, + Chatstate.INACTIVE) def scroll_to_end(self, force=False): self.conv_textview.scroll_to_end(force) diff --git a/gajim/common/connection.py b/gajim/common/connection.py index 0f0cfd922..8edf36616 100644 --- a/gajim/common/connection.py +++ b/gajim/common/connection.py @@ -320,7 +320,7 @@ class CommonConnection: # chatstates - if peer supports xep85, send chatstates # please note that the only valid tag inside a message containing a # tag is the active event - if obj.chatstate and contact and contact.supports(nbxmpp.NS_CHATSTATES): + if obj.chatstate is not None: msg_iq.setTag(obj.chatstate, namespace=nbxmpp.NS_CHATSTATES) if not obj.message: msg_iq.setTag('no-store', @@ -1727,7 +1727,7 @@ class Connection(CommonConnection, ConnectionHandlers): msg_iq.setTag('replace', attrs={'id': obj.correct_id}, namespace=nbxmpp.NS_CORRECT) - if obj.chatstate: + if obj.chatstate is not None: msg_iq.setTag(obj.chatstate, namespace=nbxmpp.NS_CHATSTATES) if not obj.message: msg_iq.setTag('no-store', namespace=nbxmpp.NS_MSG_HINTS) @@ -1754,7 +1754,7 @@ class Connection(CommonConnection, ConnectionHandlers): obj.stanza_id = self.connection.send(obj.msg_iq) app.nec.push_incoming_event(MessageSentEvent( None, conn=self, jid=obj.jid, message=obj.message, keyID=None, - chatstate=None, automatic_message=obj.automatic_message, + automatic_message=obj.automatic_message, stanza_id=obj.stanza_id, additional_data=obj.additional_data)) def send_gc_status(self, nick, jid, show, status, auto=False): diff --git a/gajim/common/connection_handlers.py b/gajim/common/connection_handlers.py index a2c58b88b..e188befb4 100644 --- a/gajim/common/connection_handlers.py +++ b/gajim/common/connection_handlers.py @@ -180,11 +180,8 @@ class ConnectionHandlersBase: return # It isn't an agent - # reset chatstate if needed: # (when contact signs out or has errors) if obj.show in ('offline', 'error'): - obj.contact.our_chatstate = obj.contact.chatstate = None - # TODO: This causes problems when another # resource signs off! self.stop_all_active_file_transfers(obj.contact) diff --git a/gajim/common/connection_handlers_events.py b/gajim/common/connection_handlers_events.py index 4e842a23a..fa696b549 100644 --- a/gajim/common/connection_handlers_events.py +++ b/gajim/common/connection_handlers_events.py @@ -23,7 +23,6 @@ from time import time as time_time import OpenSSL.crypto import nbxmpp -from nbxmpp.protocol import NS_CHATSTATES from gajim.common import nec from gajim.common import helpers @@ -99,22 +98,6 @@ class HelperEvent: log.error('wrong timestamp, ignoring it: %s', tag) self.timestamp = time_time() - def get_chatstate(self): - """ - Extract chatstate from a stanza - Requires self.stanza and self.msgtxt - """ - self.chatstate = None - - # chatstates - look for chatstate tags in a message if not delayed - delayed = self.stanza.getTag('x', namespace=nbxmpp.NS_DELAY) is not None - if not delayed: - children = self.stanza.getChildren() - for child in children: - if child.getNamespace() == NS_CHATSTATES: - self.chatstate = child.getName() - break - def get_oob_data(self, stanza): oob_node = stanza.getTag('x', namespace=nbxmpp.NS_X_OOB) if oob_node is not None: @@ -434,17 +417,6 @@ class OurShowEvent(nec.NetworkIncomingEvent): class BeforeChangeShowEvent(nec.NetworkIncomingEvent): name = 'before-change-show' -class ChatstateReceivedEvent(nec.NetworkIncomingEvent): - name = 'chatstate-received' - - def generate(self): - self.stanza = self.msg_obj.stanza - self.jid = self.msg_obj.jid - self.fjid = self.msg_obj.fjid - self.resource = self.msg_obj.resource - self.chatstate = self.msg_obj.chatstate - return True - class GcMessageReceivedEvent(nec.NetworkIncomingEvent): name = 'gc-message-received' diff --git a/gajim/common/const.py b/gajim/common/const.py index 471a73459..8e92af701 100644 --- a/gajim/common/const.py +++ b/gajim/common/const.py @@ -174,6 +174,18 @@ class PEPEventType(IntEnum): ATOM = 7 +@unique +class Chatstate(IntEnum): + COMPOSING = 0 + PAUSED = 1 + ACTIVE = 2 + INACTIVE = 3 + GONE = 4 + + def __str__(self): + return self.name.lower() + + ACTIVITIES = { 'doing_chores': { 'category': _('Doing Chores'), diff --git a/gajim/common/contacts.py b/gajim/common/contacts.py index 4d9dd91ff..52903230b 100644 --- a/gajim/common/contacts.py +++ b/gajim/common/contacts.py @@ -28,6 +28,7 @@ try: from gajim.common import caps_cache from gajim.common.account import Account from gajim import common + from gajim.common.const import Chatstate except ImportError as e: if __name__ != "__main__": raise ImportError(str(e)) @@ -45,7 +46,7 @@ class XMPPEntity: class CommonContact(XMPPEntity): def __init__(self, jid, account, resource, show, status, name, - our_chatstate, chatstate, client_caps=None): + chatstate, client_caps=None): XMPPEntity.__init__(self, jid, account, resource) @@ -55,11 +56,8 @@ class CommonContact(XMPPEntity): self.client_caps = client_caps or caps_cache.NullClientCaps() - # please read xep-85 http://www.xmpp.org/extensions/xep-0085.html - # this holds what WE SEND to contact (our current chatstate) - self.our_chatstate = our_chatstate # this is contact's chatstate - self.chatstate = chatstate + self._chatstate = chatstate @property def show(self): @@ -71,6 +69,27 @@ class CommonContact(XMPPEntity): raise TypeError('show must be a string') self._show = value + @property + def chatstate_enum(self): + return self._chatstate + + @property + def chatstate(self): + if self._chatstate is None: + return + return str(self._chatstate) + + @chatstate.setter + def chatstate(self, value): + if value is None: + self._chatstate = value + else: + self._chatstate = Chatstate[value.upper()] + + @property + def is_gc_contact(self): + return isinstance(self, GC_Contact) + def get_full_jid(self): raise NotImplementedError @@ -97,14 +116,14 @@ class Contact(CommonContact): """ def __init__(self, jid, account, name='', groups=None, show='', status='', sub='', ask='', resource='', priority=0, keyID='', client_caps=None, - our_chatstate=None, chatstate=None, idle_time=None, avatar_sha=None, groupchat=False): + chatstate=None, idle_time=None, avatar_sha=None, groupchat=False): if not isinstance(jid, str): print('no str') if groups is None: groups = [] CommonContact.__init__(self, jid, account, resource, show, status, name, - our_chatstate, chatstate, client_caps=client_caps) + chatstate, client_caps=client_caps) self.contact_name = '' # nick choosen by contact self.groups = [i if i else _('General') for i in set(groups)] # filter duplicate values @@ -182,11 +201,10 @@ class GC_Contact(CommonContact): """ def __init__(self, room_jid, account, name='', show='', status='', role='', - affiliation='', jid='', resource='', our_chatstate=None, - chatstate=None, avatar_sha=None): + affiliation='', jid='', resource='', chatstate=None, avatar_sha=None): CommonContact.__init__(self, jid, account, resource, show, status, name, - our_chatstate, chatstate) + chatstate) self.room_jid = room_jid self.role = role @@ -254,7 +272,7 @@ class LegacyContactsAPI: def create_contact(self, jid, account, name='', groups=None, show='', status='', sub='', ask='', resource='', priority=0, keyID='', - client_caps=None, our_chatstate=None, chatstate=None, idle_time=None, + client_caps=None, chatstate=None, idle_time=None, avatar_sha=None, groupchat=False): if groups is None: groups = [] @@ -263,8 +281,8 @@ class LegacyContactsAPI: return Contact(jid=jid, account=account, name=name, groups=groups, show=show, status=status, sub=sub, ask=ask, resource=resource, priority=priority, keyID=keyID, client_caps=client_caps, - our_chatstate=our_chatstate, chatstate=chatstate, - idle_time=idle_time, avatar_sha=avatar_sha, groupchat=groupchat) + chatstate=chatstate, idle_time=idle_time, avatar_sha=avatar_sha, + groupchat=groupchat) def create_self_contact(self, jid, account, resource, show, status, priority, name='', keyID=''): @@ -292,7 +310,7 @@ class LegacyContactsAPI: status=contact.status, sub=contact.sub, ask=contact.ask, resource=contact.resource, priority=contact.priority, keyID=contact.keyID, client_caps=contact.client_caps, - our_chatstate=contact.our_chatstate, chatstate=contact.chatstate, + chatstate=contact.chatstate, idle_time=contact.idle_time, avatar_sha=contact.avatar_sha) def add_contact(self, account, contact): @@ -451,6 +469,9 @@ class LegacyContactsAPI: return contact.avatar_sha = sha + def get_combined_chatstate(self, account, jid): + return self._accounts[account].contacts.get_combined_chatstate(jid) + class Contacts(): """ @@ -603,6 +624,18 @@ class Contacts(): self._contacts[new_jid].append(_contact) del self._contacts[old_jid] + def get_combined_chatstate(self, jid): + if jid not in self._contacts: + return + contacts = self._contacts[jid] + states = [] + for contact in contacts: + if contact.chatstate_enum is None: + continue + states.append(contact.chatstate_enum) + + return str(min(states)) if states else None + class GC_Contacts(): diff --git a/gajim/common/modules/chatstates.py b/gajim/common/modules/chatstates.py index 407d05a56..c80abe0e5 100644 --- a/gajim/common/modules/chatstates.py +++ b/gajim/common/modules/chatstates.py @@ -14,20 +14,239 @@ # XEP-0085: Chat State Notifications +from typing import Any +from typing import Dict # pylint: disable=unused-import +from typing import Optional +from typing import Tuple + +import time import logging import nbxmpp +from gi.repository import GLib +from gajim.common import app +from gajim.common.nec import NetworkEvent +from gajim.common.const import Chatstate as State from gajim.common.modules.misc import parse_delay +from gajim.common.connection_handlers_events import MessageOutgoingEvent +from gajim.common.connection_handlers_events import GcMessageOutgoingEvent + +from gajim.common.types import ContactT +from gajim.common.types import ConnectionT log = logging.getLogger('gajim.c.m.chatstates') +INACTIVE_AFTER = 60 +PAUSED_AFTER = 5 -def parse_chatstate(stanza): + +def parse_chatstate(stanza: nbxmpp.Message) -> Optional[str]: if parse_delay(stanza) is not None: - return + return None children = stanza.getChildren() for child in children: if child.getNamespace() == nbxmpp.NS_CHATSTATES: return child.getName() + return None + + +class Chatstate: + def __init__(self, con: ConnectionT) -> None: + self._con = con + self._account = con.name + + self.handlers = [ + ('presence', self._presence_received), + ] + self._chatstates = {} # type: Dict[str, State] + self._last_keyboard_activity = {} # type: Dict[str, float] + self._last_mouse_activity = {} # type: Dict[str, float] + + self._timeout_id = GLib.timeout_add_seconds( + 2, self._check_last_interaction) + + def _presence_received(self, + _con: ConnectionT, + stanza: nbxmpp.Presence) -> None: + if stanza.getType() not in ('unavailable', 'error'): + return + + full_jid = stanza.getFrom() + jid = full_jid.getStripped() + + if self._con.get_own_jid().bareMatch(full_jid): + return + + contact = app.contacts.get_contact_from_full_jid( + self._account, str(full_jid)) + if contact is None or contact.is_gc_contact: + return + + if contact.chatstate is None: + return + + contact.chatstate = None + self._chatstates.pop(contact.jid, None) + self._last_mouse_activity.pop(contact.jid, None) + + log.info('Reset chatstate for %s', jid) + + app.nec.push_outgoing_event( + NetworkEvent('chatstate-received', + account=self._account, + jid=jid)) + + def delegate(self, event: Any) -> None: + if self._con.get_own_jid().bareMatch(event.jid) or event.sent: + # Dont show chatstates from our own resources + return + + chatstate = parse_chatstate(event.stanza) + if chatstate is None: + return + + contact = app.contacts.get_contact_from_full_jid( + self._account, event.fjid) + if contact is None or contact.is_gc_contact: + return + + contact.chatstate = chatstate + log.info('Recv: %-10s - %s', chatstate, event.fjid) + app.nec.push_outgoing_event( + NetworkEvent('chatstate-received', + account=self._account, + jid=event.jid)) + + def _check_last_interaction(self) -> GLib.SOURCE_CONTINUE: + setting = app.config.get('outgoing_chat_state_notifications') + if setting in ('composing_only', 'disabled'): + return GLib.SOURCE_CONTINUE + + now = time.time() + for jid, time_ in self._last_mouse_activity.items(): + current_state = self._chatstates.get(jid) + if current_state is None: + self._last_mouse_activity.pop(jid, None) + return GLib.SOURCE_CONTINUE + + if current_state in (State.GONE, State.INACTIVE): + return GLib.SOURCE_CONTINUE + + new_chatstate = None + if now - time_ > INACTIVE_AFTER: + new_chatstate = State.INACTIVE + + elif current_state == State.COMPOSING: + key_time = self._last_keyboard_activity[jid] + if now - key_time > PAUSED_AFTER: + new_chatstate = State.PAUSED + + if new_chatstate is not None: + if self._chatstates.get(jid) != new_chatstate: + contact = app.contacts.get_contact(self._account, jid) + if contact is None: + self._last_mouse_activity.pop(jid, None) + return GLib.SOURCE_CONTINUE + self.set_chatstate(contact, new_chatstate) + + return GLib.SOURCE_CONTINUE + + def set_active(self, jid: str) -> None: + self._last_mouse_activity[jid] = time.time() + setting = app.config.get('outgoing_chat_state_notifications') + if setting == 'disabled': + return + self._chatstates[jid] = State.ACTIVE + + def get_active_chatstate(self, contact: ContactT) -> Optional[str]: + # determines if we add 'active' on outgoing messages + setting = app.config.get('outgoing_chat_state_notifications') + if setting == 'disabled': + return None + + # Dont send chatstates to ourself + if self._con.get_own_jid().bareMatch(contact.jid): + return None + + if not contact.supports(nbxmpp.NS_CHATSTATES): + return None + + self.set_active(contact.jid) + return 'active' + + def set_chatstate(self, contact: ContactT, state: State) -> None: + current_state = self._chatstates.get(contact.jid) + setting = app.config.get('outgoing_chat_state_notifications') + if setting == 'disabled': + # Send a last 'gone' state after user disabled chatstates + if current_state is not None: + log.info('Send: %-10s - %s', State.GONE, contact.jid) + app.nec.push_outgoing_event( + MessageOutgoingEvent(None, + account=self._account, + jid=contact.jid, + chatstate=str(State.GONE))) + self._chatstates.pop(contact.jid, None) + self._last_mouse_activity.pop(contact.jid, None) + return + + if not contact.is_groupchat(): + # Dont leak presence to contacts + # which are not allowed to see our status + if contact and contact.sub in ('to', 'none'): + return + + if contact.show == 'offline': + return + + if not contact.supports(nbxmpp.NS_CHATSTATES): + return + + if state in (State.ACTIVE, State.COMPOSING): + self._last_mouse_activity[contact.jid] = time.time() + + if setting == 'composing_only': + if state in (State.INACTIVE, State.GONE, State.PAUSED): + state = State.ACTIVE + + if current_state == state: + return + + # Dont send chatstates to ourself + if self._con.get_own_jid().bareMatch(contact.jid): + return + + log.info('Send: %-10s - %s', state, contact.jid) + + event_attrs = {'account': self._account, + 'jid': contact.jid, + 'chatstate': str(state)} + + if contact.is_groupchat(): + app.nec.push_outgoing_event( + GcMessageOutgoingEvent(None, **event_attrs)) + else: + app.nec.push_outgoing_event( + MessageOutgoingEvent(None, **event_attrs)) + + self._chatstates[contact.jid] = state + + def set_mouse_activity(self, contact: ContactT) -> None: + self._last_mouse_activity[contact.jid] = time.time() + setting = app.config.get('outgoing_chat_state_notifications') + if setting == 'disabled': + return + if self._chatstates.get(contact.jid) == State.INACTIVE: + self.set_chatstate(contact, State.ACTIVE) + + def set_keyboard_activity(self, contact: ContactT) -> None: + self._last_keyboard_activity[contact.jid] = time.time() + + def cleanup(self): + GLib.source_remove(self._timeout_id) + + +def get_instance(*args: Any, **kwargs: Any) -> Tuple[Chatstate, str]: + return Chatstate(*args, **kwargs), 'Chatstate' diff --git a/gajim/common/modules/message.py b/gajim/common/modules/message.py index 946bc7069..328ea32ec 100644 --- a/gajim/common/modules/message.py +++ b/gajim/common/modules/message.py @@ -24,7 +24,6 @@ from gajim.common import helpers from gajim.common.nec import NetworkIncomingEvent, NetworkEvent from gajim.common.modules.security_labels import parse_securitylabel from gajim.common.modules.user_nickname import parse_nickname -from gajim.common.modules.chatstates import parse_chatstate from gajim.common.modules.carbons import parse_carbon from gajim.common.modules.misc import parse_delay from gajim.common.modules.misc import parse_eme @@ -218,6 +217,7 @@ class Message: def _on_message_decrypted(self, event): try: self._con.get_module('Receipts').delegate(event) + self._con.get_module('Chatstate').delegate(event) except nbxmpp.NodeProcessed: return @@ -236,7 +236,6 @@ class Message: 'user_nick': '' if event.sent else parse_nickname(event.stanza), 'form_node': parse_form(event.stanza), 'xhtml': parse_xhtml(event.stanza), - 'chatstate': parse_chatstate(event.stanza), 'timestamp': timestamp, 'delayed': delayed, } diff --git a/gajim/common/modules/ping.py b/gajim/common/modules/ping.py index 0b7f7dc77..fea6d7d5d 100644 --- a/gajim/common/modules/ping.py +++ b/gajim/common/modules/ping.py @@ -25,7 +25,7 @@ import nbxmpp from gajim.common import app from gajim.common.nec import NetworkIncomingEvent from gajim.common.types import ConnectionT -from gajim.common.types import ContactT +from gajim.common.types import ContactsT log = logging.getLogger('gajim.c.m.ping') @@ -73,7 +73,7 @@ class Ping: log.warning('No reply received for keepalive ping. Reconnecting...') self._con.disconnectedReconnCB() - def send_ping(self, contact: ContactT) -> None: + def send_ping(self, contact: ContactsT) -> None: if not app.account_is_connected(self._account): return @@ -93,7 +93,7 @@ class Ping: _con: ConnectionT, stanza: nbxmpp.Iq, ping_time: int, - contact: ContactT) -> None: + contact: ContactsT) -> None: if not nbxmpp.isResultNode(stanza): log.info('Error: %s', stanza.getError()) app.nec.push_incoming_event( diff --git a/gajim/common/types.py b/gajim/common/types.py index 5f9b6a685..ae9cc2598 100644 --- a/gajim/common/types.py +++ b/gajim/common/types.py @@ -44,7 +44,8 @@ InterfaceT = Union['Interface'] LoggerT = Union['Logger'] ConnectionT = Union['Connection', 'ConnectionZeroconf'] -ContactT = Union['Contact', 'GC_Contact'] +ContactsT = Union['Contact', 'GC_Contact'] +ContactT = Union['Contact'] UserTuneDataT = Optional[Tuple[str, str, str, str, str]] diff --git a/gajim/common/zeroconf/connection_handlers_zeroconf.py b/gajim/common/zeroconf/connection_handlers_zeroconf.py index 54b4a8f2a..bdd5af53d 100644 --- a/gajim/common/zeroconf/connection_handlers_zeroconf.py +++ b/gajim/common/zeroconf/connection_handlers_zeroconf.py @@ -20,6 +20,7 @@ # along with Gajim. If not, see . import time +import logging import nbxmpp @@ -30,14 +31,13 @@ from gajim.common.zeroconf.zeroconf import Constant from gajim.common import connection_handlers from gajim.common.nec import NetworkIncomingEvent, NetworkEvent from gajim.common.modules.user_nickname import parse_nickname -from gajim.common.modules.chatstates import parse_chatstate from gajim.common.modules.misc import parse_eme from gajim.common.modules.misc import parse_correction from gajim.common.modules.misc import parse_attention from gajim.common.modules.misc import parse_oob from gajim.common.modules.misc import parse_xhtml -import logging + log = logging.getLogger('gajim.c.z.connection_handlers_zeroconf') STATUS_LIST = ['offline', 'connecting', 'online', 'chat', 'away', 'xa', 'dnd', @@ -147,6 +147,7 @@ connection_handlers.ConnectionJingle): def _on_message_decrypted(self, event): try: self.get_module('Receipts').delegate(event) + self.get_module('Chatstate').delegate(event) except nbxmpp.NodeProcessed: return @@ -160,7 +161,6 @@ connection_handlers.ConnectionJingle): 'correct_id': parse_correction(event.stanza), 'user_nick': parse_nickname(event.stanza), 'xhtml': parse_xhtml(event.stanza), - 'chatstate': parse_chatstate(event.stanza), 'stanza_id': event.unique_id } diff --git a/gajim/groupchat_control.py b/gajim/groupchat_control.py index 33b159951..2d60ea18f 100644 --- a/gajim/groupchat_control.py +++ b/gajim/groupchat_control.py @@ -59,6 +59,7 @@ from gajim.common import ged from gajim.common import i18n from gajim.common import contacts from gajim.common.const import StyleAttr +from gajim.common.const import Chatstate from gajim.chat_control import ChatControl from gajim.chat_control_base import ChatControlBase @@ -794,7 +795,6 @@ class GroupchatControl(ChatControlBase): self.add_actions() self.update_actions() self.set_lock_image() - self._schedule_activity_timers() self._connect_window_state_change(self.parent_win) def set_tooltip(self): @@ -2187,13 +2187,9 @@ class GroupchatControl(ChatControlBase): correct_id = self.last_sent_msg else: correct_id = None - - # Set chatstate - chatstate = None - if app.config.get('outgoing_chat_state_notifications') != 'disabled': - chatstate = 'active' - self.reset_kbd_mouse_timeout_vars() - self.contact.our_chatstate = chatstate + con = app.connections[self.account] + chatstate = con.get_module('Chatstate').get_active_chatstate( + self.contact.jid) # Send the message app.nec.push_outgoing_event(GcMessageOutgoingEvent( @@ -2228,69 +2224,16 @@ class GroupchatControl(ChatControlBase): control = win.notebook.get_nth_page(ctrl_page) win.notebook.remove_page(ctrl_page) - if self.possible_paused_timeout_id: - GLib.source_remove(self.possible_paused_timeout_id) - self.possible_paused_timeout_id = None - if self.possible_inactive_timeout_id: - GLib.source_remove(self.possible_inactive_timeout_id) - self.possible_inactive_timeout_id = None control.unparent() ctrl.parent_win = None - self.send_chatstate('inactive', self.contact) + con = app.connections[self.account] + con.get_module('Chatstate').set_chatstate(self.contact, Chatstate.INACTIVE) app.interface.roster.minimize_groupchat( self.account, self.contact.jid, status=self.subject) del win._controls[self.account][self.contact.jid] - def send_chatstate(self, state, contact): - """ - Send OUR chatstate as STANDLONE chat state message (eg. no body) - to contact only if new chatstate is different from the previous one - if jid is not specified, send to active tab - """ - # JEP 85 does not allow resending the same chatstate - # this function checks for that and just returns so it's safe to call it - # with same state. - - # This functions also checks for violation in state transitions - # and raises RuntimeException with appropriate message - # more on that http://xmpp.org/extensions/xep-0085.html#statechart - - # do not send if we have chat state notifications disabled - # that means we won't reply to the from other peer - # so we do not broadcast jep85 capabalities - chatstate_setting = app.config.get('outgoing_chat_state_notifications') - if chatstate_setting == 'disabled': - return - - if (chatstate_setting == 'composing_only' and - state != 'active' and - state != 'composing'): - return - - # if the new state we wanna send (state) equals - # the current state (contact.our_chatstate) then return - if contact.our_chatstate == state: - return - - # if we're inactive prevent composing (XEP violation) - if contact.our_chatstate == 'inactive' and state == 'composing': - # go active before - app.nec.push_outgoing_event(GcMessageOutgoingEvent(None, - account=self.account, jid=self.contact.jid, chatstate='active', - control=self)) - contact.our_chatstate = 'active' - self.reset_kbd_mouse_timeout_vars() - - app.nec.push_outgoing_event(GcMessageOutgoingEvent(None, - account=self.account, jid=self.contact.jid, chatstate=state, - control=self)) - - contact.our_chatstate = state - if state == 'active': - self.reset_kbd_mouse_timeout_vars() - def shutdown(self, status='offline'): # PluginSystem: calling shutdown of super class (ChatControlBase) # to let it remove it's GUI extension points diff --git a/gajim/gui_interface.py b/gajim/gui_interface.py index 41d2e0d5d..4e08ede69 100644 --- a/gajim/gui_interface.py +++ b/gajim/gui_interface.py @@ -444,7 +444,6 @@ class Interface: account=account, name=nick, show=show) ctrl = self.new_private_chat(gc_c, account, session) - ctrl.contact.our_chatstate = False ctrl.print_conversation(_('Error %(code)s: %(msg)s') % { 'code': obj.error_code, 'msg': obj.error_msg}, 'status') return diff --git a/gajim/session.py b/gajim/session.py index d907bb36b..f52798f32 100644 --- a/gajim/session.py +++ b/gajim/session.py @@ -21,14 +21,12 @@ import string import random import itertools -from gajim import message_control from gajim import notify from gajim.common import helpers from gajim.common import events from gajim.common import app from gajim.common import contacts from gajim.common import ged -from gajim.common.connection_handlers_events import ChatstateReceivedEvent from gajim.common.const import KindConstant from gajim.gtk.single_message import SingleMessageWindow @@ -97,7 +95,7 @@ class ChatControlSession: self.control.change_resource(self.resource) if obj.mtype == 'chat': - if not obj.msgtxt and obj.chatstate is None: + if not obj.msgtxt: return log_type = KindConstant.CHAT_MSG_RECV @@ -142,27 +140,6 @@ class ChatControlSession: # joined. We log it silently without notification. return True - # Handle chat states - if contact and (not obj.forwarded or not obj.sent): - if self.control and self.control.type_id == \ - message_control.TYPE_CHAT: - if obj.chatstate is not None: - # other peer sent us reply, so he supports jep85 or jep22 - contact.chatstate = obj.chatstate - if contact.our_chatstate == 'ask': # we were jep85 disco? - contact.our_chatstate = 'active' # no more - app.nec.push_incoming_event(ChatstateReceivedEvent(None, - conn=obj.conn, msg_obj=obj)) - elif contact.chatstate != 'active': - # got no valid jep85 answer, peer does not support it - contact.chatstate = False - elif obj.chatstate == 'active': - # Brand new message, incoming. - contact.our_chatstate = obj.chatstate - contact.chatstate = obj.chatstate - - # THIS MUST BE AFTER chatstates handling - # AND BEFORE playsound (else we hear sounding on chatstates!) if not obj.msgtxt: # empty message text return True @@ -189,7 +166,7 @@ class ChatControlSession: if app.interface.remote_ctrl: app.interface.remote_ctrl.raise_signal('NewMessage', ( self.conn.name, [obj.fjid, obj.msgtxt, obj.timestamp, - obj.encrypted, obj.mtype, obj.subject, obj.chatstate, + obj.encrypted, obj.mtype, obj.subject, obj.msg_log_id, obj.user_nick, obj.xhtml, obj.form_node])) def roster_message2(self, obj): diff --git a/test/unit/test_contacts.py b/test/unit/test_contacts.py index a722621c5..40127dabf 100644 --- a/test/unit/test_contacts.py +++ b/test/unit/test_contacts.py @@ -14,9 +14,9 @@ from gajim.common import caps_cache class TestCommonContact(unittest.TestCase): def setUp(self): - self.contact = CommonContact(jid='', account="", resource='', show='', - status='', name='', our_chatstate=None, chatstate=None, - client_caps=None) + self.contact = CommonContact( + jid='', account="", resource='', show='', + status='', name='', chatstate=None, client_caps=None) def test_default_client_supports(self): ''' @@ -43,8 +43,8 @@ class TestContact(TestCommonContact): '''This test supports the migration from the old to the new contact domain model by smoke testing that no attribute values are lost''' - attributes = ["jid", "resource", "show", "status", "name", "our_chatstate", - "chatstate", "client_caps", "priority", "sub"] + attributes = ["jid", "resource", "show", "status", "name", + "chatstate", "client_caps", "priority", "sub"] for attr in attributes: self.assertTrue(hasattr(self.contact, attr), msg="expected: " + attr) @@ -59,8 +59,8 @@ class TestGC_Contact(TestCommonContact): '''This test supports the migration from the old to the new contact domain model by asserting no attributes have been lost''' - attributes = ["jid", "resource", "show", "status", "name", "our_chatstate", - "chatstate", "client_caps", "role", "room_jid"] + attributes = ["jid", "resource", "show", "status", "name", + "chatstate", "client_caps", "role", "room_jid"] for attr in attributes: self.assertTrue(hasattr(self.contact, attr), msg="expected: " + attr)