Commit 460d3907 authored by Philipp Hörist's avatar Philipp Hörist

Refactor Chat State Notifications

- Move code into chatstate module
- Refactor most of the code, make it much simpler
parent 07b175d5
......@@ -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 <active/> 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:
......
......@@ -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)
......
......@@ -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
# <body> 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):
......
......@@ -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)
......
......@@ -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 <message/> 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'
......
......@@ -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'),
......
......@@ -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():
......
......@@ -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)