gajim3/gajim/common/modules/chatstates.py

359 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
# XEP-0085: Chat State Notifications
from typing import Any
from typing import Dict # pylint: disable=unused-import
from typing import List # pylint: disable=unused-import
from typing import Optional
from typing import Tuple
import time
from functools import wraps
import nbxmpp
from nbxmpp.namespaces import Namespace
from nbxmpp.structs import StanzaHandler
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.structs import OutgoingMessage
from gajim.common.modules.base import BaseModule
from gajim.common.types import ContactT
from gajim.common.types import ConnectionT
INACTIVE_AFTER = 60
PAUSED_AFTER = 10
def ensure_enabled(func):
@wraps(func)
def func_wrapper(self, *args, **kwargs):
if not self.enabled:
return None
return func(self, *args, **kwargs)
return func_wrapper
class Chatstate(BaseModule):
def __init__(self, con: ConnectionT) -> None:
BaseModule.__init__(self, con)
self.handlers = [
StanzaHandler(name='presence',
callback=self._presence_received),
StanzaHandler(name='message',
callback=self._process_chatstate,
ns=Namespace.CHATSTATES,
priority=46),
]
# Our current chatstate with a specific contact
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 = None
self._delay_timeout_ids = {} # type: Dict[str, str]
self._blocked = [] # type: List[str]
self._enabled = False
@property
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value):
if self._enabled == value:
return
self._log.info('Chatstate module %s',
'enabled' if value else 'disabled')
self._enabled = value
if value:
self._timeout_id = GLib.timeout_add_seconds(
2, self._check_last_interaction)
else:
self.cleanup()
self._chatstates = {}
self._last_keyboard_activity = {}
self._last_mouse_activity = {}
self._blocked = []
@ensure_enabled
def _presence_received(self,
_con: ConnectionT,
stanza: nbxmpp.Presence,
_properties: Any) -> None:
if stanza.getType() not in ('unavailable', 'error'):
return
full_jid = stanza.getFrom()
if full_jid is None or self._con.get_own_jid().bare_match(full_jid):
# Presence from ourself
return
contact = app.contacts.get_gc_contact(
self._account, full_jid.bare, full_jid.resource)
if contact is None:
contact = app.contacts.get_contact_from_full_jid(
self._account, str(full_jid))
if contact is None:
return
if contact.chatstate is None:
return
if contact.is_gc_contact:
jid = contact.get_full_jid()
else:
jid = contact.jid
contact.chatstate = None
self._chatstates.pop(jid, None)
self._last_mouse_activity.pop(jid, None)
self._last_keyboard_activity.pop(jid, None)
self._log.info('Reset chatstate for %s', jid)
app.nec.push_outgoing_event(
NetworkEvent('chatstate-received',
account=self._account,
contact=contact))
def _process_chatstate(self, _con, _stanza, properties):
if not properties.has_chatstate:
return
if (properties.is_self_message or
properties.type.is_groupchat or
properties.is_mam_message or
properties.is_carbon_message and properties.carbon.is_sent):
return
if properties.is_muc_pm:
contact = app.contacts.get_gc_contact(
self._account,
properties.jid.bare,
properties.jid.resource)
else:
contact = app.contacts.get_contact_from_full_jid(
self._account, str(properties.jid))
if contact is None:
return
contact.chatstate = properties.chatstate
self._log.info('Recv: %-10s - %s', properties.chatstate, properties.jid)
app.nec.push_outgoing_event(
NetworkEvent('chatstate-received',
account=self._account,
contact=contact))
@ensure_enabled
def _check_last_interaction(self) -> GLib.SOURCE_CONTINUE:
now = time.time()
for jid in list(self._last_mouse_activity.keys()):
time_ = self._last_mouse_activity[jid]
current_state = self._chatstates.get(jid)
if current_state is None:
self._last_mouse_activity.pop(jid, None)
self._last_keyboard_activity.pop(jid, None)
continue
if current_state in (State.GONE, State.INACTIVE):
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:
room, nick = app.get_room_and_nick_from_fjid(jid)
contact = app.contacts.get_gc_contact(
self._account, room, nick)
if contact is not None:
contact = contact.as_contact()
else:
# Contact not found, maybe we left the group chat
# or the contact was removed from the roster
self._log.info(
'Contact %s not found, reset chatstate', jid)
self._chatstates.pop(jid, None)
self._last_mouse_activity.pop(jid, None)
self._last_keyboard_activity.pop(jid, None)
continue
self.set_chatstate(contact, new_chatstate)
return GLib.SOURCE_CONTINUE
@ensure_enabled
def set_active(self, contact: ContactT) -> None:
if contact.settings.get('send_chatstate') == 'disabled':
return
self._last_mouse_activity[contact.jid] = time.time()
self._chatstates[contact.jid] = State.ACTIVE
def get_active_chatstate(self, contact: ContactT) -> Optional[str]:
# determines if we add 'active' on outgoing messages
if contact.settings.get('send_chatstate') == 'disabled':
return None
if not contact.is_groupchat:
# Dont send chatstates to ourself
if self._con.get_own_jid().bare_match(contact.jid):
return None
if not contact.supports(Namespace.CHATSTATES):
return None
self.set_active(contact)
return 'active'
@ensure_enabled
def block_chatstates(self, contact: ContactT, block: bool) -> None:
# Block sending chatstates to a contact
# Used for example if we cycle through the MUC nick list, which
# produces a lot of buffer 'changed' signals from the input textview.
# This would lead to sending ACTIVE -> COMPOSING -> ACTIVE ...
if block:
self._blocked.append(contact.jid)
else:
self._blocked.remove(contact.jid)
@ensure_enabled
def set_chatstate_delayed(self, contact: ContactT, state: State) -> None:
# Used when we go from Composing -> Active after deleting all text
# from the Textview. We delay the Active state because maybe the
# User starts writing again.
self.remove_delay_timeout(contact)
self._delay_timeout_ids[contact.jid] = GLib.timeout_add_seconds(
2, self.set_chatstate, contact, state)
@ensure_enabled
def set_chatstate(self, contact: ContactT, state: State) -> None:
# Dont send chatstates to ourself
if self._con.get_own_jid().bare_match(contact.jid):
return
if contact.jid in self._blocked:
return
self.remove_delay_timeout(contact)
current_state = self._chatstates.get(contact.jid)
setting = contact.settings.get('send_chatstate')
if setting == 'disabled':
# Send a last 'active' state after user disabled chatstates
if current_state is not None:
self._log.info('Disabled for %s', contact.jid)
self._log.info('Send last state: %-10s - %s',
State.ACTIVE, contact.jid)
self._send_chatstate(contact, str(State.ACTIVE))
self._chatstates.pop(contact.jid, None)
self._last_mouse_activity.pop(contact.jid, None)
self._last_keyboard_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 not contact.is_pm_contact:
if contact and contact.sub in ('to', 'none'):
self._log.info('Contact not subscribed: %s', contact.jid)
return
if contact.show == 'offline':
self._log.info('Contact offline: %s', contact.jid)
return
if not contact.supports(Namespace.CHATSTATES):
self._log.info('Chatstates not supported: %s', contact.jid)
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 = State.ACTIVE
if current_state == state:
return
self._log.info('Send: %-10s - %s', state, contact.jid)
self._send_chatstate(contact, str(state))
self._chatstates[contact.jid] = state
def _send_chatstate(self, contact, chatstate):
type_ = 'groupchat' if contact.is_groupchat else 'chat'
message = OutgoingMessage(account=self._account,
contact=contact,
message=None,
type_=type_,
chatstate=chatstate,
play_sound=False)
self._con.send_message(message)
@ensure_enabled
def set_mouse_activity(self, contact: ContactT, was_paused: bool) -> None:
if contact.settings.get('send_chatstate') == 'disabled':
return
self._last_mouse_activity[contact.jid] = time.time()
if self._chatstates.get(contact.jid) == State.INACTIVE:
if was_paused:
self.set_chatstate(contact, State.PAUSED)
else:
self.set_chatstate(contact, State.ACTIVE)
@ensure_enabled
def set_keyboard_activity(self, contact: ContactT) -> None:
self._last_keyboard_activity[contact.jid] = time.time()
def remove_delay_timeout(self, contact):
timeout = self._delay_timeout_ids.get(contact.jid)
if timeout is not None:
GLib.source_remove(timeout)
del self._delay_timeout_ids[contact.jid]
def remove_all_delay_timeouts(self):
for timeout in self._delay_timeout_ids.values():
GLib.source_remove(timeout)
self._delay_timeout_ids = {}
def cleanup(self):
self.remove_all_delay_timeouts()
if self._timeout_id is not None:
GLib.source_remove(self._timeout_id)
def get_instance(*args: Any, **kwargs: Any) -> Tuple[Chatstate, str]:
return Chatstate(*args, **kwargs), 'Chatstate'