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)