From f6739730afa1ba7396837169053abc292caab14b Mon Sep 17 00:00:00 2001 From: Emmanuel Gil Peyrot Date: Sat, 24 Dec 2016 04:36:21 +0100 Subject: [PATCH] =?UTF-8?q?Implement=20chatstate=20in=20MUC,=20as=20define?= =?UTF-8?q?d=20in=20XEP-0085=20=C2=A75.5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat_control.py | 112 +--------------------- src/chat_control_base.py | 116 +++++++++++++++++++++++ src/common/connection.py | 2 + src/common/connection_handlers_events.py | 1 + src/groupchat_control.py | 49 +++++++++- src/roster_window.py | 3 + 6 files changed, 172 insertions(+), 111 deletions(-) diff --git a/src/chat_control.py b/src/chat_control.py index 0726ffe21..f7dbbed44 100644 --- a/src/chat_control.py +++ b/src/chat_control.py @@ -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 diff --git a/src/chat_control_base.py b/src/chat_control_base.py index bc321ad4b..1b461caaf 100644 --- a/src/chat_control_base.py +++ b/src/chat_control_base.py @@ -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() diff --git a/src/common/connection.py b/src/common/connection.py index c1caf10bd..edce4ef56 100644 --- a/src/common/connection.py +++ b/src/common/connection.py @@ -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( diff --git a/src/common/connection_handlers_events.py b/src/common/connection_handlers_events.py index e8482b6f2..3963a53bf 100644 --- a/src/common/connection_handlers_events.py +++ b/src/common/connection_handlers_events.py @@ -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 diff --git a/src/groupchat_control.py b/src/groupchat_control.py index 4505e1a92..facef7ece 100644 --- a/src/groupchat_control.py +++ b/src/groupchat_control.py @@ -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 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 diff --git a/src/roster_window.py b/src/roster_window.py index c9c871bdb..1d16b700a 100644 --- a/src/roster_window.py +++ b/src/roster_window.py @@ -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)