Refactor groupchat nick auto completion

This commit is contained in:
Marcin Mielniczuk 2018-09-25 16:56:08 +02:00 committed by Philipp Hörist
parent a00e8e3abb
commit 9d8b56bc0f
3 changed files with 160 additions and 30 deletions

View File

@ -8,6 +8,7 @@
# Stephan Erb <steve-e AT h3c.de> # Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com> # Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
# Jonathan Schleifer <js-gajim AT webkeks.org> # Jonathan Schleifer <js-gajim AT webkeks.org>
# Copyright (C) 2018 Marcin Mielniczuk <marmistrz dot dev at zoho dot eu>
# #
# This file is part of Gajim. # This file is part of Gajim.
# #
@ -32,6 +33,7 @@ import logging
from enum import IntEnum, unique from enum import IntEnum, unique
import nbxmpp import nbxmpp
from gi.repository import Gtk from gi.repository import Gtk
from gi.repository import Gdk from gi.repository import Gdk
from gi.repository import Pango from gi.repository import Pango
@ -75,6 +77,7 @@ from gajim.gtk.add_contact import AddNewContactWindow
from gajim.gtk.tooltips import GCTooltip from gajim.gtk.tooltips import GCTooltip
from gajim.gtk.groupchat_config import GroupchatConfig from gajim.gtk.groupchat_config import GroupchatConfig
from gajim.gtk.adhoc_commands import CommandWindow from gajim.gtk.adhoc_commands import CommandWindow
from gajim.gtk.util import NickCompletionGenerator
from gajim.gtk.util import get_icon_name from gajim.gtk.util import get_icon_name
from gajim.gtk.util import get_affiliation_surface from gajim.gtk.util import get_affiliation_surface
from gajim.gtk.util import get_builder from gajim.gtk.util import get_builder
@ -317,6 +320,7 @@ class GroupchatControl(ChatControlBase):
# sorted list of nicks who mentioned us (last at the end) # sorted list of nicks who mentioned us (last at the end)
self.attention_list = [] self.attention_list = []
self.nick_hits = [] self.nick_hits = []
self._nick_completion = NickCompletionGenerator(self.nick)
self.last_key_tabs = False self.last_key_tabs = False
self.subject = '' self.subject = ''
@ -1433,11 +1437,7 @@ class GroupchatControl(ChatControlBase):
other_tags_for_name.append('bold') other_tags_for_name.append('bold')
other_tags_for_text.append('marked') other_tags_for_text.append('marked')
if contact in self.attention_list: self._nick_completion.record_message(contact, highlight)
self.attention_list.remove(contact)
elif len(self.attention_list) > 6:
self.attention_list.pop(0) # remove older
self.attention_list.append(contact)
if text.startswith('/me ') or text.startswith('/me\n'): if text.startswith('/me ') or text.startswith('/me\n'):
other_tags_for_text.append('gc_nickname_color_' + \ other_tags_for_text.append('gc_nickname_color_' + \
@ -1826,6 +1826,10 @@ class GroupchatControl(ChatControlBase):
for role in ('visitor', 'participant', 'moderator'): for role in ('visitor', 'participant', 'moderator'):
self.draw_role(role) self.draw_role(role)
def _change_nick(self, new_nick: str) -> None:
self.nick = new_nick
self._nick_completion.change_nick(new_nick)
def _nec_gc_presence_received(self, obj): def _nec_gc_presence_received(self, obj):
if obj.room_jid != self.room_jid or obj.conn.name != self.account: if obj.room_jid != self.room_jid or obj.conn.name != self.account:
return return
@ -1945,7 +1949,7 @@ class GroupchatControl(ChatControlBase):
elif '303' in obj.status_code: # Someone changed their nick elif '303' in obj.status_code: # Someone changed their nick
if obj.new_nick == self.new_nick or obj.nick == self.nick: if obj.new_nick == self.new_nick or obj.nick == self.nick:
# We changed our nick # We changed our nick
self.nick = obj.new_nick self.change_nick(obj.new_nick)
self.new_nick = '' self.new_nick = ''
s = _('You are now known as %s') % self.nick s = _('You are now known as %s') % self.nick
else: else:
@ -1963,8 +1967,7 @@ class GroupchatControl(ChatControlBase):
# after that, but that doesn't hurt # after that, but that doesn't hurt
self.add_contact_to_roster(obj.new_nick, obj.show, role, self.add_contact_to_roster(obj.new_nick, obj.show, role,
affiliation, obj.status, obj.real_jid) affiliation, obj.status, obj.real_jid)
if obj.nick in self.attention_list: self._nick_completion.contact_renamed(nick, obj.new_nick)
self.attention_list.remove(obj.nick)
# keep nickname color # keep nickname color
if obj.nick in self.gc_custom_colors: if obj.nick in self.gc_custom_colors:
self.gc_custom_colors[obj.new_nick] = \ self.gc_custom_colors[obj.new_nick] = \
@ -2016,7 +2019,7 @@ class GroupchatControl(ChatControlBase):
if not iter_: if not iter_:
if '210' in obj.status_code: if '210' in obj.status_code:
# Server changed our nick # Server changed our nick
self.nick = obj.nick self.change_nick(obj.nick)
s = _('You are now known as %s') % nick s = _('You are now known as %s') % nick
self.print_conversation(s, 'info', graphics=False) self.print_conversation(s, 'info', graphics=False)
iter_ = self.add_contact_to_roster(obj.nick, obj.show, role, iter_ = self.add_contact_to_roster(obj.nick, obj.show, role,
@ -2080,9 +2083,6 @@ class GroupchatControl(ChatControlBase):
right_changed: right_changed:
st = '' st = ''
if obj.show == 'offline':
if obj.nick in self.attention_list:
self.attention_list.remove(obj.nick)
if obj.show == 'offline' and print_status in ('all', 'in_and_out') \ if obj.show == 'offline' and print_status in ('all', 'in_and_out') \
and (not obj.status_code or '307' not in obj.status_code): and (not obj.status_code or '307' not in obj.status_code):
st = _('%s has left') % nick_jid st = _('%s has left') % nick_jid
@ -2469,6 +2469,10 @@ class GroupchatControl(ChatControlBase):
self.print_conversation(_('%(jid)s has been invited in this room') % self.print_conversation(_('%(jid)s has been invited in this room') %
{'jid': contact_jid}, graphics=False) {'jid': contact_jid}, graphics=False)
def _jid_not_blocked(self, bare_jid: str) -> bool:
fjid = self.room_jid + '/' + bare_jid
return not helpers.jid_is_blocked(self.account, fjid)
def _on_message_textview_key_press_event(self, widget, event): def _on_message_textview_key_press_event(self, widget, event):
res = ChatControlBase._on_message_textview_key_press_event(self, widget, res = ChatControlBase._on_message_textview_key_press_event(self, widget,
event) event)
@ -2508,25 +2512,17 @@ class GroupchatControl(ChatControlBase):
self.nick_hits.append(self.nick_hits[0]) self.nick_hits.append(self.nick_hits[0])
begin = self.nick_hits.pop(0) begin = self.nick_hits.pop(0)
else: else:
self.nick_hits = [] # clear the hit list
list_nick = app.contacts.get_nick_list(self.account, list_nick = app.contacts.get_nick_list(self.account,
self.room_jid) self.room_jid)
list_nick.sort(key=str.lower) # case-insensitive sort list_nick = list(filter(self._jid_not_blocked, list_nick))
if begin == '':
# empty message, show lasts nicks that highlighted us first log.debug("Nicks to be considered for autosuggestions: %s",
for nick in self.attention_list: list_nick)
if nick in list_nick: self.nick_hits = self._nick_completion.generate_suggestions(
list_nick.remove(nick) nicks=list_nick, beginning=begin)
list_nick.insert(0, nick) log.debug("Nicks filtered for autosuggestions: %s",
self.nick_hits)
if self.nick in list_nick:
list_nick.remove(self.nick) # Skip self
for nick in list_nick:
fjid = self.room_jid + '/' + nick
if nick.lower().startswith(begin.lower()) and not \
helpers.jid_is_blocked(self.account, fjid):
# the word is the beginning of a nick
self.nick_hits.append(nick)
if self.nick_hits: if self.nick_hits:
if len(splitted_text) < 2 or with_refer_to_nick_char: if len(splitted_text) < 2 or with_refer_to_nick_char:
# This is the 1st word of the line or no word or we are cycling # This is the 1st word of the line or no word or we are cycling

View File

@ -1,3 +1,6 @@
# Copyright (C) 2018 Marcin Mielniczuk <marmistrz.dev AT zoho.eu>
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# This file is part of Gajim. # This file is part of Gajim.
# #
# Gajim is free software; you can redistribute it and/or modify # Gajim is free software; you can redistribute it and/or modify
@ -42,6 +45,89 @@ if _icon_theme is not None:
log = logging.getLogger('gajim.gtk.util') log = logging.getLogger('gajim.gtk.util')
class NickCompletionGenerator:
def __init__(self, self_nick: str) -> None:
self.nick = self_nick
self.sender_list = [] # type: List[str]
self.attention_list = [] # type: List[str]
def change_nick(self, new_nick: str) -> None:
self.nick = new_nick
def record_message(self, contact: str, highlight: bool) -> None:
if contact == self.nick:
return
log.debug('Recorded a message from %s, highlight; %s', contact,
highlight)
if highlight:
try:
self.attention_list.remove(contact)
except ValueError:
pass
if len(self.attention_list) > 6:
self.attention_list.pop(0) # remove older
self.attention_list.append(contact)
# TODO implement it in a more efficient way
# Currently it's O(n*m + n*s), where n is the number of participants and
# m is the number of messages processed, s - the number of times the
# suggestions are requested
#
# A better way to do it would be to keep a dict: contact -> timestamp
# with expected O(1) insert, and sort it by timestamps in O(n log n)
# for each suggestion (currently generating the suggestions is O(n))
# this would give the expected complexity of O(m + s * n log n)
try:
self.sender_list.remove(contact)
except ValueError:
pass
self.sender_list.append(contact)
def contact_renamed(self, contact_old: str, contact_new: str) -> None:
log.debug('Contact %s renamed to %s', contact_old, contact_new)
for lst in (self.attention_list, self.sender_list):
for idx, contact in enumerate(lst):
if contact == contact_old:
lst[idx] = contact_new
def generate_suggestions(self, nicks: List[str],
beginning: str) -> List[str]:
"""
Generate the order of suggested MUC autocompletions
`nicks` is the list of contacts currently participating in a MUC
`beginning` is the text already typed by the user
"""
def nick_matching(nick: str) -> bool:
return nick != self.nick \
and nick.lower().startswith(beginning.lower())
if beginning == '':
# empty message, so just suggest recent mentions
potential_matches = self.attention_list
else:
# nick partially typed, try completing it
potential_matches = self.sender_list
potential_matches_set = set(potential_matches)
log.debug('Priority matches: %s', potential_matches_set)
matches = [n for n in potential_matches if nick_matching(n)]
# the most recent nick is the last one on the list
matches.reverse()
# handle people who have not posted/mentioned us
other_nicks = [
n for n in nicks if nick_matching(n) and n not in potential_matches_set
]
other_nicks.sort(key=str.lower)
log.debug('Other matches: %s', other_nicks)
return matches + other_nicks
class Builder: class Builder:
def __init__(self, def __init__(self,
filename: str, filename: str,

View File

@ -0,0 +1,48 @@
import unittest
from gajim.gtk.util import NickCompletionGenerator
class Test(unittest.TestCase):
def test_generate_suggestions(self):
gen = NickCompletionGenerator('meeeee')
l = ['aaaa', 'meeeee', 'fooo', 'xxxxz', 'xaaaz']
for n in l:
gen.record_message(n, False)
l2 = ['xxx'] + l
r = gen.generate_suggestions(nicks=l2, beginning='x')
self.assertEqual(r, ['xaaaz', 'xxxxz', 'xxx'])
r = gen.generate_suggestions(
nicks=l2,
beginning='m'
)
self.assertEqual(r, [])
for n in ['xaaaz', 'xxxxz']:
gen.record_message(n, True)
r = gen.generate_suggestions(
nicks=l2,
beginning='x'
)
self.assertEqual(r, ['xxxxz', 'xaaaz', 'xxx'])
r = gen.generate_suggestions(
nicks=l2,
beginning=''
)
self.assertEqual(r, ['xxxxz', 'xaaaz', 'aaaa', 'fooo', 'xxx'])
l2[1] = 'bbbb'
gen.contact_renamed('aaaa', 'bbbb')
r = gen.generate_suggestions(
nicks=l2,
beginning=''
)
self.assertEqual(r, ['xxxxz', 'xaaaz', 'bbbb', 'fooo', 'xxx'])
if __name__ == "__main__":
unittest.main()