From 9b2922a3594d22da2de39c4c95a0a5f3a4215bdf Mon Sep 17 00:00:00 2001 From: wurstsalat Date: Sun, 2 Oct 2022 21:03:57 +0200 Subject: imprv: GroupChatNickCompletion: Simplify suggestions - remove shell_like_completion - query nicks from DB (no state needed) Fixes #11155 --- gajim/common/setting_values.py | 5 - gajim/common/storage/archive.py | 33 ++++ gajim/gtk/groupchat_nick_completion.py | 345 ++++++++++----------------------- 3 files changed, 137 insertions(+), 246 deletions(-) diff --git a/gajim/common/setting_values.py b/gajim/common/setting_values.py index 94973d27c..e2d9319f6 100644 --- a/gajim/common/setting_values.py +++ b/gajim/common/setting_values.py @@ -79,7 +79,6 @@ BoolSettings = Literal[ 'remote_control', 'save_main_window_position', 'send_on_ctrl_enter', - 'shell_like_completion', 'show_chatstate_in_banner', 'show_help_start_chat', 'show_in_taskbar', @@ -253,7 +252,6 @@ APP_SETTINGS = { 'save_main_window_position': True, 'search_engine': 'https://duckduckgo.com/?q=%s', 'send_on_ctrl_enter': False, - 'shell_like_completion': False, 'show_chatstate_in_banner': True, 'show_help_start_chat': True, 'show_in_taskbar': True, @@ -696,9 +694,6 @@ ADVANCED_SETTINGS = { 'search_engine': '', 'send_on_ctrl_enter': _( 'Send message on Ctrl+Enter and make a new line with Enter.'), - 'shell_like_completion': _( - 'If enabled, completion in group chats will be like a shell ' - 'auto-completion.'), 'stun_server': _('STUN server to use when using Jingle'), 'trayicon_notification_on_events': _( 'Notify of events in the notification area.'), diff --git a/gajim/common/storage/archive.py b/gajim/common/storage/archive.py index bb2926482..bf0092f03 100644 --- a/gajim/common/storage/archive.py +++ b/gajim/common/storage/archive.py @@ -45,6 +45,7 @@ from gajim.common.helpers import AdditionalDataDict from gajim.common.const import MAX_MESSAGE_CORRECTION_DELAY from gajim.common.const import KindConstant from gajim.common.const import JIDConstant +from gajim.common.modules.contacts import GroupchatContact from gajim.common.storage.base import SqliteStorage from gajim.common.storage.base import timeit @@ -831,6 +832,38 @@ class MessageArchiveStorage(SqliteStorage): sql, tuple(jids) + (date_ts, delta_ts)).fetchone() + @timeit + def get_recent_muc_nicks(self, contact: GroupchatContact) -> list[str]: + ''' + Queries the last 50 message rows and gathers nicknames in a list + ''' + jids = [contact.jid] + account_id = self.get_account_id(contact.account) + kinds = map(str, [KindConstant.STATUS, + KindConstant.GCSTATUS, + KindConstant.ERROR]) + + sql = ''' + SELECT contact_name + FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) + AND account_id = {account_id} + AND kind NOT IN ({kinds}) + ORDER BY time DESC + '''.format(jids=', '.join('?' * len(jids)), + account_id=account_id, + kinds=', '.join(kinds)) + + result = self._con.execute( + sql, + tuple(jids)).fetchmany(50) + + nicknames: list[str] = [] + for row in result: + if row.contact_name not in nicknames: + nicknames.append(row.contact_name) + + return nicknames + @timeit def deduplicate_muc_message(self, account: str, diff --git a/gajim/gtk/groupchat_nick_completion.py b/gajim/gtk/groupchat_nick_completion.py index 89513aca1..ac52f6aae 100644 --- a/gajim/gtk/groupchat_nick_completion.py +++ b/gajim/gtk/groupchat_nick_completion.py @@ -18,19 +18,15 @@ from typing import Optional import logging - from gi.repository import Gtk from gi.repository import Gdk from gajim.common import app from gajim.common import ged -from gajim.common import types from gajim.common.events import GcMessageReceived -from gajim.common.events import MUCNicknameChanged from gajim.common.ged import EventHelper from gajim.common.helpers import jid_is_blocked -from gajim.common.helpers import message_needs_highlight - +from gajim.common.modules.contacts import GroupchatContact log = logging.getLogger('gajim.gui.groupchat_nick_completion') @@ -39,154 +35,24 @@ class GroupChatNickCompletion(EventHelper): def __init__(self) -> None: EventHelper.__init__(self) - self._account: Optional[str] = None - self._contact: Optional[types.GroupchatContactT] = None + self._contact: Optional[GroupchatContact] = None - self._sender_list: list[str] = [] - self._highlight_list: list[str] = [] - self._nick_hits: list[str] = [] + self._suggestions: list[str] = [] self._last_key_tab = False - self._nick_data: dict[str, tuple[list[str], list[str]]] = {} - self.register_event( - 'gc-message-received', ged.GUI1, self._on_gc_message_received) + 'gc-message-received', ged.GUI2, self._on_gc_message_received) - def switch_contact(self, contact: types.GroupchatContactT) -> None: - self._nick_hits.clear() + def switch_contact(self, contact: GroupchatContact) -> None: + self._suggestions.clear() self._last_key_tab = False - - if self._contact is not None: - self._contact.disconnect_all_from_obj(self) - self._nick_data[str(self._contact.jid)] = ( - self._sender_list, self._highlight_list) - - nick_data = self._nick_data.get(str(contact.jid)) - if nick_data is None: - self._sender_list.clear() - self._highlight_list.clear() - else: - self._sender_list, self._highlight_list = nick_data - - self._account = contact.account self._contact = contact - self._contact.connect( - 'user-nickname-changed', self._on_user_nickname_changed) - - def _on_user_nickname_changed(self, - _contact: types.GroupchatContact, - _signal_name: str, - event: MUCNicknameChanged, - old_contact: types.GroupchatParticipant, - new_contact: types.GroupchatParticipant - ) -> None: - - if event.is_self: - return - - old_name = old_contact.name - new_name = new_contact.name - - log.debug('Contact %s renamed to %s', old_name, new_name) - for lst in (self._highlight_list, self._sender_list): - for idx, contact in enumerate(lst): - if contact == old_name: - lst[idx] = new_name - - def _on_gc_message_received(self, event: GcMessageReceived) -> None: - if event.properties.muc_nickname is None: - # Message from server - return - - client = app.get_client(event.account) - gc_contact = client.get_module('Contacts').get_contact( - event.room_jid) - - participant_nick = event.properties.muc_nickname - if participant_nick == gc_contact.nickname: - return - - highlight = message_needs_highlight( - event.msgtxt, gc_contact.nickname, client.get_own_jid().bare) - self._process_message(participant_nick, highlight, event.room_jid) - - def _process_message(self, - participant_nick: str, - highlight: bool, - room_jid: str - ) -> None: - nick_data = self._nick_data.get(room_jid) - if nick_data is None: - return - - sender_list, highlight_list = nick_data - if highlight: - try: - highlight_list.remove(participant_nick) - except ValueError: - pass - if len(highlight_list) > 6: - highlight_list.pop(0) # remove older - highlight_list.append(participant_nick) - - # 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: - sender_list.remove(participant_nick) - except ValueError: - pass - sender_list.append(participant_nick) - - 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: - assert self._contact - return (nick != self._contact.nickname and - nick.lower().startswith(beginning.lower())) - - if beginning == '': - # empty message, so just suggest recent mentions - potential_matches = self._highlight_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 def process_key_press(self, textview: Gtk.TextView, event: Gdk.EventKey ) -> bool: + if (event.get_state() & Gdk.ModifierType.SHIFT_MASK or event.get_state() & Gdk.ModifierType.CONTROL_MASK or event.keyval not in (Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Tab)): @@ -199,111 +65,108 @@ class GroupChatNickCompletion(EventHelper): end_iter = message_buffer.get_iter_at_mark(cursor_position) text = message_buffer.get_text(start_iter, end_iter, False) - text_split = text.split() + if text.split(): + # Store last word for autocompletion + prefix = text.split()[-1] + else: + prefix = '' + + # Configurable string to be displayed after the nick: + # e.g. "user," or "user:" + ref_ext = app.settings.get('gc_refer_to_nick_char') + has_ref_ext = False + + # Default suffix to 1: space printed after completion + suffix_len = 1 + + if ref_ext and text.endswith(ref_ext + ' '): + has_ref_ext = True + suffix_len = len(ref_ext + ' ') + + if not self._last_key_tab or not self._suggestions: + self._suggestions = self._generate_suggestions(prefix) - # check if tab is pressed with empty message - if text_split: # if there are any words - begin = text_split[-1] # last word we typed + if not self._suggestions: + self._last_key_tab = True + return False + + if (self._last_key_tab and + text[:-suffix_len].endswith(self._suggestions[0])): + # Cycle suggestions list + self._suggestions.append(self._suggestions[0]) + prefix = self._suggestions.pop(0) + + if len(text.split()) < 2 or has_ref_ext: + suffix = ref_ext + ' ' else: - begin = '' - - gc_refer_to_nick_char = app.settings.get('gc_refer_to_nick_char') - with_refer_to_nick_char = False - after_nick_len = 1 # the space that is printed after we type [Tab] - - # first part of this if : works fine even if refer_to_nick_char - if (gc_refer_to_nick_char and - text.endswith(gc_refer_to_nick_char + ' ')): - with_refer_to_nick_char = True - after_nick_len = len(gc_refer_to_nick_char + ' ') - if (self._nick_hits and self._last_key_tab and - text[:-after_nick_len].endswith(self._nick_hits[0])): - # we should cycle - # Previous nick in list may had a space inside, so we check text - # and not text_split and store it into 'begin' var - self._nick_hits.append(self._nick_hits[0]) - begin = self._nick_hits.pop(0) + suffix = ' ' + + start_iter = end_iter.copy() + if (self._last_key_tab and has_ref_ext or (text and text[-1] == ' ')): + # Mind the added space from last completion; + # ref_ext may also consist of more than one char + start_iter.backward_chars(len(prefix) + len(suffix)) else: - assert self._contact - list_nick = self._contact.get_user_nicknames() - list_nick = list(filter(self._jid_not_blocked, list_nick)) - - log.debug('Nicks to be considered for autosuggestions: %s', - list_nick) - self._nick_hits = self._generate_suggestions( - nicks=list_nick, beginning=begin) - log.debug('Nicks filtered for autosuggestions: %s', - self._nick_hits) - if not self._nick_hits: - self._last_key_tab = True - return False + start_iter.backward_chars(len(prefix)) + + assert self._contact is not None + client = app.get_client(self._contact.account) + client.get_module('Chatstate').block_chatstates(self._contact, True) - if self._nick_hits: - shell_like_completion = app.settings.get('shell_like_completion') - - if len(text_split) < 2 or with_refer_to_nick_char: - # This is the 1st word of the line or no word or we are - # cycling at the beginning, possibly with a space in one nick - add = gc_refer_to_nick_char + ' ' - else: - add = ' ' - start_iter = end_iter.copy() - if (self._last_key_tab and - with_refer_to_nick_char or (text and text[-1] == ' ')): - # have to accommodate for the added space from last completion - # gc_refer_to_nick_char may be more than one char! - start_iter.backward_chars(len(begin) + len(add)) - elif self._last_key_tab and not shell_like_completion: - # have to accommodate for the added space from last - # completion - start_iter.backward_chars( - len(begin) + len(gc_refer_to_nick_char)) - else: - start_iter.backward_chars(len(begin)) - - assert self._account - client = app.get_client(self._account) - client.get_module('Chatstate').block_chatstates( - self._contact, True) - - message_buffer.delete(start_iter, end_iter) - # get a shell-like completion - # if there's more than one nick for this completion, complete - # only the part that all these nicks have in common - if shell_like_completion and len(self._nick_hits) > 1: - end = False - completion = '' - add = '' # if nick is not complete, don't add anything - while not end and len(completion) < len(self._nick_hits[0]): - completion = self._nick_hits[0][:len(completion) + 1] - for nick in self._nick_hits: - if completion.lower() not in nick.lower(): - end = True - completion = completion[:-1] - break - # if the current nick matches a COMPLETE existing nick, - # and if the user tab TWICE, complete that nick (with the - # "add") - if self._last_key_tab: - for nick in self._nick_hits: - if nick == completion: - # The user seems to want this nick, so - # complete it as if it were the only nick - # available - add = gc_refer_to_nick_char + ' ' - else: - completion = self._nick_hits[0] - message_buffer.insert_at_cursor(completion + add) - - client.get_module('Chatstate').block_chatstates( - self._contact, False) + message_buffer.delete(start_iter, end_iter) + completion = self._suggestions[0] + message_buffer.insert_at_cursor(completion + suffix) + + client.get_module('Chatstate').block_chatstates(self._contact, False) self._last_key_tab = True + return True - def _jid_not_blocked(self, resource: str) -> bool: - assert self._account - assert self._contact - resource_contact = self._contact.get_resource(resource) - return not jid_is_blocked( - self._account, str(resource_contact.jid)) + def _generate_suggestions(self, prefix: str) -> list[str]: + def _nick_matching(nick: str) -> bool: + assert self._contact is not None + if nick == self._contact.nickname: + return False + + participant = self._contact.get_resource(nick) + if jid_is_blocked(self._contact.account, str(participant.jid)): + return False + + if prefix == '': + return True + + return nick.lower().startswith(prefix.lower()) + + assert self._contact is not None + # Get recent nicknames from DB. This enables us to suggest + # nicknames even if no message arrived since Gajim was started. + recent_nicknames = app.storage.archive.get_recent_muc_nicks( + self._contact) + + matches: list[str] = [] + for nick in recent_nicknames: + if _nick_matching(nick): + matches.append(nick) + + # Add all other MUC participants + other_nicks: list[str] = [] + for contact in self._contact.get_participants(): + if _nick_matching(contact.name): + if contact.name not in matches: + other_nicks.append(contact.name) + other_nicks.sort(key=str.lower) + + return matches + other_nicks + + def _on_gc_message_received(self, event: GcMessageReceived) -> None: + if self._contact is None: + return + + if event.room_jid != self._contact.jid: + return + + if not self._last_key_tab: + # Clear suggestions if not actively using them + # (new messages may have new nicks) + self._suggestions.clear() -- cgit v1.2.3