Refactor Chat State Notifications

- Move code into chatstate module
- Refactor most of the code, make it much simpler
This commit is contained in:
Philipp Hörist 2018-09-29 21:48:21 +02:00
parent c88932fc14
commit 50c670e61b
16 changed files with 359 additions and 355 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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'

View File

@ -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'),

View File

@ -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():

View File

@ -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'

View File

@ -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,
}

View File

@ -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(

View File

@ -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]]

View File

@ -20,6 +20,7 @@
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
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
}

View File

@ -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 <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
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

View File

@ -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

View File

@ -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):

View File

@ -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)