Merge branch 'muc-chatstate' into 'master'

Implement chatstate in MUC, as defined in XEP-0085 §5.5

See merge request !11
This commit is contained in:
Philipp Hörist 2017-04-14 18:39:08 +02:00
commit a1565040bd
6 changed files with 172 additions and 111 deletions

View File

@ -188,14 +188,7 @@ class ChatControl(ChatControlBase):
self.bigger_avatar_window = None
self.show_avatar()
# chatstate timers and state
self.reset_kbd_mouse_timeout_vars()
self._schedule_activity_timers()
# Hook up signals
id_ = self.parent_win.window.connect('motion-notify-event',
self._on_window_motion_notify)
self.handlers[id_] = self.parent_win.window
message_tv_buffer = self.msg_textview.get_buffer()
id_ = message_tv_buffer.connect('changed',
self._on_message_tv_buffer_changed)
@ -672,21 +665,6 @@ class ChatControl(ChatControlBase):
cursor = gtkgui_helpers.get_cursor('HAND2')
self.parent_win.window.get_window().set_cursor(cursor)
def _on_window_motion_notify(self, widget, event):
"""
It gets called no matter if it is the active window or not
"""
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
def _schedule_activity_timers(self):
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 update_ui(self):
# The name banner is drawn here
ChatControlBase.update_ui(self)
@ -976,9 +954,6 @@ class ChatControl(ChatControlBase):
if message in ('', None, '\n'):
return None
# refresh timers
self.reset_kbd_mouse_timeout_vars()
contact = self.contact
encrypted = bool(self.session) and self.session.enable_encryption
@ -990,10 +965,8 @@ class ChatControl(ChatControlBase):
if not keyID:
keyID = 'UNKNOWN'
chatstates_on = gajim.config.get('outgoing_chat_state_notifications') != \
'disabled'
chatstate_to_send = None
if chatstates_on and contact is not None:
if contact is not None:
if contact.supports(NS_CHATSTATES):
# send active chatstate on every message (as XEP says)
chatstate_to_send = 'active'
@ -1031,65 +1004,6 @@ class ChatControl(ChatControlBase):
process_commands=process_commands,
attention=attention)
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
return False # stop looping
message_buffer = self.msg_textview.get_buffer()
if (self.kbd_activity_in_last_5_secs and
message_buffer.get_char_count()):
# Only composing if the keyboard activity was in text entry
self.send_chatstate('composing', self.contact)
elif (self.mouse_over_in_last_5_secs and
current_state == 'inactive' and
jid == self.parent_win.get_active_jid()):
self.send_chatstate('active', self.contact)
else:
if current_state == 'composing':
self.send_chatstate('paused', self.contact) # pause composing
# 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
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 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_cancel_session_negotiation(self):
msg = _('Session negotiation cancelled')
ChatControlBase.print_conversation_line(self, msg, 'status', '', None)
@ -1391,9 +1305,6 @@ class ChatControl(ChatControlBase):
if self.session:
self.session.control = None
# Disconnect timer callbacks
GLib.source_remove(self.possible_paused_timeout_id)
GLib.source_remove(self.possible_inactive_timeout_id)
# Remove bigger avatar window
if self.bigger_avatar_window:
self.bigger_avatar_window.destroy()
@ -1482,20 +1393,6 @@ class ChatControl(ChatControlBase):
def set_control_active(self, state):
ChatControlBase.set_control_active(self, state)
# send chatstate inactive to the one we're leaving
# and active to the one we visit
if state:
message_buffer = self.msg_textview.get_buffer()
if message_buffer.get_char_count():
self.send_chatstate('paused', self.contact)
else:
self.send_chatstate('active', self.contact)
self.reset_kbd_mouse_timeout_vars()
GLib.source_remove(self.possible_paused_timeout_id)
GLib.source_remove(self.possible_inactive_timeout_id)
self._schedule_activity_timers()
else:
self.send_chatstate('inactive', self.contact)
# Hide bigger avatar window
if self.bigger_avatar_window:
self.bigger_avatar_window.destroy()
@ -1580,11 +1477,8 @@ class ChatControl(ChatControlBase):
dialogs.TransformChatToMUC(self.account, [c.jid], [dropped_jid])
def _on_message_tv_buffer_changed(self, textbuffer):
self.kbd_activity_in_last_5_secs = True
self.kbd_activity_in_last_30_secs = True
super()._on_message_tv_buffer_changed(textbuffer)
if textbuffer.get_char_count():
self.send_chatstate('composing', self.contact)
e2e_is_active = self.session and \
self.session.enable_encryption
e2e_pref = gajim.config.get_per('accounts', self.account,
@ -1600,8 +1494,6 @@ class ChatControl(ChatControlBase):
elif (not self.session or not self.session.status) and \
gajim.connections[self.account].archiving_136_supported:
self.begin_archiving_negotiation()
else:
self.send_chatstate('active', self.contact)
def restore_conversation(self):
jid = self.contact.jid

View File

@ -383,6 +383,18 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools):
self.command_hits = []
self.last_key_tabs = False
# chatstate timers and state
self.reset_kbd_mouse_timeout_vars()
self._schedule_activity_timers()
message_tv_buffer = self.msg_textview.get_buffer()
id_ = message_tv_buffer.connect('changed',
self._on_message_tv_buffer_changed)
self.handlers[id_] = message_tv_buffer
if parent_win is not None:
id_ = parent_win.window.connect('motion-notify-event',
self._on_window_motion_notify)
self.handlers[id_] = parent_win.window
# PluginSystem: adding GUI extension point for ChatControlBase
# instance object (also subclasses, eg. ChatControl or GroupchatControl)
gajim.plugin_manager.gui_extension_point('chat_control_base', self)
@ -448,6 +460,9 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools):
def shutdown(self):
super(ChatControlBase, self).shutdown()
# Disconnect timer callbacks
GLib.source_remove(self.possible_paused_timeout_id)
GLib.source_remove(self.possible_inactive_timeout_id)
# PluginSystem: removing GUI extension points connected with ChatControlBase
# instance object
gajim.plugin_manager.remove_gui_extension_point('chat_control_base',
@ -683,6 +698,12 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools):
if process_commands and self.process_as_command(message):
return
# refresh timers
self.reset_kbd_mouse_timeout_vars()
if gajim.config.get('outgoing_chat_state_notifications') == 'disabled':
chatstate = None
label = self.get_seclabel()
def _cb(obj, msg, cb, *cb_args):
@ -712,6 +733,88 @@ 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
return False # stop looping
message_buffer = self.msg_textview.get_buffer()
if (self.kbd_activity_in_last_5_secs and
message_buffer.get_char_count()):
# Only composing if the keyboard activity was in text entry
self.send_chatstate('composing', self.contact)
elif (self.mouse_over_in_last_5_secs and
current_state == 'inactive' and
jid == self.parent_win.get_active_jid()):
self.send_chatstate('active', self.contact)
else:
if current_state == 'composing':
self.send_chatstate('paused', self.contact) # pause composing
# 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
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):
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):
"""
It gets called no matter if it is the active window or not
"""
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
def _on_message_tv_buffer_changed(self, textbuffer):
self.kbd_activity_in_last_5_secs = True
self.kbd_activity_in_last_30_secs = True
if textbuffer.get_char_count():
self.send_chatstate('composing', self.contact)
else:
self.send_chatstate('active', self.contact)
def save_message(self, message, msg_type):
# save the message, so user can scroll though the list with key up/down
if msg_type == 'sent':
@ -982,6 +1085,19 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools):
types=type_):
# There were events to remove
self.redraw_after_event_removed(jid)
# send chatstate inactive to the one we're leaving
# and active to the one we visit
message_buffer = self.msg_textview.get_buffer()
if message_buffer.get_char_count():
self.send_chatstate('paused', self.contact)
else:
self.send_chatstate('active', self.contact)
self.reset_kbd_mouse_timeout_vars()
GLib.source_remove(self.possible_paused_timeout_id)
GLib.source_remove(self.possible_inactive_timeout_id)
self._schedule_activity_timers()
else:
self.send_chatstate('inactive', self.contact)
def scroll_to_end_iter(self):
self.conv_textview.scroll_to_end_iter()

View File

@ -2719,6 +2719,8 @@ class Connection(CommonConnection, ConnectionHandlers):
obj.xhtml = create_xhtml(obj.message)
msg_iq = nbxmpp.Message(obj.jid, obj.message, typ='groupchat',
xhtml=obj.xhtml)
if obj.chatstate:
msg_iq.setTag(obj.chatstate, namespace=nbxmpp.NS_CHATSTATES)
if obj.label is not None:
msg_iq.addChild(node=obj.label)
gajim.nec.push_incoming_event(GcStanzaMessageOutgoingEvent(

View File

@ -2769,6 +2769,7 @@ class GcMessageOutgoingEvent(nec.NetworkOutgoingEvent):
def init(self):
self.additional_data = {}
self.message = ''
self.chatstate = None
self.xhtml = None
self.label = None
self.callback = None

View File

@ -343,7 +343,7 @@ class GroupchatControl(ChatControlBase):
img.set_from_icon_name('document-open-recent', Gtk.IconSize.MENU)
self.current_tooltip = None
if parent_win:
if parent_win is not None:
# On AutoJoin with minimize Groupchats are created without parent
# Tooltip Window has to be created with parent
self.set_tooltip()
@ -2035,6 +2035,53 @@ class GroupchatControl(ChatControlBase):
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 = gajim.config.get('outgoing_chat_state_notifications')
if chatstate_setting == 'disabled':
return
elif 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 wel're inactive prevent composing (XEP violation)
if contact.our_chatstate == 'inactive' and state == 'composing':
# go active before
gajim.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()
gajim.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

@ -3215,6 +3215,9 @@ class RosterWindow:
if not mw:
mw = gajim.interface.msg_win_mgr.create_window(ctrl.contact,
ctrl.account, ctrl.type_id)
id_ = mw.window.connect('motion-notify-event',
ctrl._on_window_motion_notify)
ctrl.handlers[id_] = mw.window
ctrl.parent_win = mw
ctrl.set_tooltip()
mw.new_tab(ctrl)