diff --git a/src/backend/html_markdown.py b/src/backend/html_markdown.py index 776c965f..beafee41 100644 --- a/src/backend/html_markdown.py +++ b/src/backend/html_markdown.py @@ -3,7 +3,7 @@ """HTML and Markdown processing tools.""" import re -from typing import DefaultDict, Dict, List, Tuple +from typing import DefaultDict, Dict, List, Optional, Tuple from urllib.parse import unquote import html_sanitizer.sanitizer as sanitizer @@ -174,9 +174,6 @@ class HTMLProcessor: extra_newlines_regex = re.compile(r"\n(\n*)") - # {room_id: {user_id: username}} - rooms_user_id_names: DefaultDict[str, Dict[str, str]] = DefaultDict(dict) - def __init__(self) -> None: # The whitespace remover doesn't take
into account @@ -214,7 +211,7 @@ class HTMLProcessor: def user_id_link_in_html(self, html: str, user_id: str) -> bool: - """Return whether html contains a mention link for user_id.""" + """Return whether html contains a mention link for `user_id`.""" regex = re.compile(rf"https?://matrix.to/#/{user_id}", re.IGNORECASE) @@ -227,10 +224,10 @@ class HTMLProcessor: def from_markdown( self, - text: str, - inline: bool = False, - outgoing: bool = False, - room_id: str = "", + text: str, + inline: bool = False, + outgoing: bool = False, + display_name_mentions: Optional[Dict[str, str]] = None, ) -> str: """Return filtered HTML from Markdown text.""" @@ -238,20 +235,22 @@ class HTMLProcessor: self._markdown_to_html(text), inline, outgoing, - room_id, + display_name_mentions, ) def filter( self, - html: str, - inline: bool = False, - outgoing: bool = False, - room_id: str = "", + html: str, + inline: bool = False, + outgoing: bool = False, + display_name_mentions: Optional[Dict[str, str]] = None, ) -> str: """Filter and return HTML.""" - sanit = Sanitizer(self.sanitize_settings(inline, outgoing, room_id)) + mentions = display_name_mentions + + sanit = Sanitizer(self.sanitize_settings(inline, outgoing, mentions)) html = sanit.sanitize(html).rstrip("\n") if not html.strip(): @@ -262,7 +261,7 @@ class HTMLProcessor: ) for a_tag in tree.iterdescendants("a"): - self._mentions_to_matrix_to_links(a_tag, room_id, outgoing) + self._mentions_to_matrix_to_links(a_tag, mentions, outgoing) if not outgoing: self._matrix_to_links_add_classes(a_tag) @@ -286,7 +285,10 @@ class HTMLProcessor: def sanitize_settings( - self, inline: bool = False, outgoing: bool = False, room_id: str = "", + self, + inline: bool = False, + outgoing: bool = False, + display_name_mentions: Optional[Dict[str, str]] = None, ) -> dict: """Return an html_sanitizer configuration.""" @@ -309,13 +311,10 @@ class HTMLProcessor: }, }} - username_link_regexes = [] - - if outgoing: - username_link_regexes = [re.compile(r) for r in [ - rf"(?{re.escape(username)})(?!\w)(?P)" - for username in self.rooms_user_id_names[room_id].values() - ]] + username_link_regexes = [re.compile(r) for r in [ + rf"(?{re.escape(name)})(?!\w)(?P )" + for name in (display_name_mentions or {}) + ]] return { "tags": inline_tags if inline else all_tags, @@ -472,11 +471,14 @@ class HTMLProcessor: def _mentions_to_matrix_to_links( - self, el: HtmlElement, room_id: str = "", outgoing: bool = False, + self, + el: HtmlElement, + display_name_mentions: Optional[Dict[str, str]] = None, + outgoing: bool = False, ) -> HtmlElement: - """Turn user ID/names and room ID/aliases into matrix.to URL. + """Turn user ID, usernames and room ID/aliases into matrix.to URL. - After the HTML sanitizer autolinks these, the links's hrefs will be the + After the HTML sanitizer autolinks these, the links's hrefs are the link text, e.g. `@foo:bar.com`. We turn them into proper matrix.to URL in this function. """ @@ -493,11 +495,8 @@ class HTMLProcessor: el.attrib["href"] = f"https://matrix.to/#/{el.attrib['href']}" return el - if not outgoing or room_id not in self.rooms_user_id_names: - return el - - for user_id, username in self.rooms_user_id_names[room_id].items(): - if unquote(el.attrib["href"]) == username: + for name, user_id in (display_name_mentions or {}).items(): + if unquote(el.attrib["href"]) == name: el.attrib["href"] = f"https://matrix.to/#/{user_id}" return el @@ -512,7 +511,6 @@ class HTMLProcessor: if not href or not el.text: return el - # This must be first, or link will be mistaken by room ID/alias regex if self.link_is_message_id_regex.match(href): el.attrib["class"] = "mention message-id-mention" diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index d31a063e..22abe0a8 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -509,11 +509,17 @@ class MatrixClient(nio.AsyncClient): async def send_text( - self, room_id: str, text: str, reply_to_event_id: Optional[str] = None, + self, + room_id: str, + text: str, + display_name_mentions: Optional[Dict[str, str]] = None, # {name: id} + reply_to_event_id: Optional[str] = None, ) -> None: """Send a markdown `m.text` or `m.notice` (with `/me`) message .""" - from_md = partial(HTML.from_markdown, room_id=room_id) + from_md = partial( + HTML.from_markdown, display_name_mentions=display_name_mentions, + ) escape = False if text.startswith("//") or text.startswith(r"\/"): @@ -909,9 +915,7 @@ class MatrixClient(nio.AsyncClient): content = event_fields.get("content", "").strip() if content and "inline_content" not in event_fields: - event_fields["inline_content"] = HTML.filter( - content, inline=True, room_id=room_id, - ) + event_fields["inline_content"] = HTML.filter(content, inline=True) event = Event( id = f"echo-{transaction_id}", @@ -1797,8 +1801,7 @@ class MatrixClient(nio.AsyncClient): plain_topic = room.topic or "", topic = HTML.filter( utils.plain2html(room.topic or ""), - inline = True, - room_id = room.room_id, + inline = True, ), inviter_id = inviter, inviter_name = room.user_name(inviter) if inviter else "", @@ -1869,16 +1872,11 @@ class MatrixClient(nio.AsyncClient): self.models[self.user_id, room.room_id, "members"][user_id] = \ member_item - if member.display_name: - HTML.rooms_user_id_names[room.room_id][user_id] = \ - member.display_name - async def remove_member(self, room: nio.MatrixRoom, user_id: str) -> None: """Remove a room member from our models.""" self.models[self.user_id, room.room_id, "members"].pop(user_id, None) - HTML.rooms_user_id_names[room.room_id].pop(user_id, None) room_item = self.models[self.user_id, "rooms"].get(room.room_id) @@ -1972,9 +1970,7 @@ class MatrixClient(nio.AsyncClient): content = fields.get("content", "").strip() if content and "inline_content" not in fields: - fields["inline_content"] = HTML.filter( - content, inline=True, room_id=room.room_id, - ) + fields["inline_content"] = HTML.filter(content, inline=True) # Create Event ModelItem diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 75e3f549..22c0baf1 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -154,8 +154,6 @@ class NioCallbacks: ev.formatted_body if ev.format == "org.matrix.custom.html" else plain2html(ev.body), - - room_id = room.room_id, ) mention_list = HTML_PROCESSOR.mentions_in_html(co) @@ -627,10 +625,8 @@ class NioCallbacks: self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent, ) -> None: if ev.topic: - topic = HTML_PROCESSOR.filter( - plain2html(ev.topic), inline=True, room_id=room.room_id, - ) - co = f"%1 changed the room's topic to \"{topic}\"" + topic = HTML_PROCESSOR.filter(plain2html(ev.topic), inline=True) + co = f"%1 changed the room's topic to \"{topic}\"" else: co = "%1 removed the room's topic" diff --git a/src/gui/Pages/Chat/Composer/Composer.qml b/src/gui/Pages/Chat/Composer/Composer.qml index 84c437f6..1ef75187 100644 --- a/src/gui/Pages/Chat/Composer/Composer.qml +++ b/src/gui/Pages/Chat/Composer/Composer.qml @@ -50,6 +50,7 @@ Rectangle { MessageArea { id: messageArea autoCompletionOpen: userCompletion.open + usersCompleted: userCompletion.usersCompleted onAutoCompletePrevious: userCompletion.previous() onAutoCompleteNext: userCompletion.next() @@ -57,7 +58,7 @@ Rectangle { onExtraCharacterCloseAutoCompletion: ! userCompletion.autoOpen || userCompletion.autoOpenCompleted ? - userCompletion.open = false : + userCompletion.accept() : null } } diff --git a/src/gui/Pages/Chat/Composer/MessageArea.qml b/src/gui/Pages/Chat/Composer/MessageArea.qml index d27f65fd..e8f5de37 100644 --- a/src/gui/Pages/Chat/Composer/MessageArea.qml +++ b/src/gui/Pages/Chat/Composer/MessageArea.qml @@ -9,7 +9,9 @@ HTextArea { id: area property HListView eventList - property bool autoCompletionOpen + + property bool autoCompletionOpen: false + property var usersCompleted: ({}) property string indent: " " @@ -88,7 +90,10 @@ HTextArea { function sendText() { if (! toSend) return - const args = [chat.roomId, toSend, chat.replyToEventId] + // Need to copy usersCompleted because the completion UI closing will + // clear it before it reaches Python. + const mentions = Object.assign({}, usersCompleted) + const args = [chat.roomId, toSend, mentions, chat.replyToEventId] py.callClientCoro(writingUserId, "send_text", args) area.clear() @@ -184,7 +189,7 @@ HTextArea { autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo() Keys.onReturnPressed: ev => { - extraCharacterCloseAutoCompletion() + if (autoCompletionOpen) extraCharacterCloseAutoCompletion() ev.accepted = true ev.modifiers & Qt.ShiftModifier || @@ -197,7 +202,7 @@ HTextArea { Keys.onEnterPressed: ev => Keys.returnPressed(ev) Keys.onMenuPressed: ev => { - extraCharacterCloseAutoCompletion() + if (autoCompletionOpen) extraCharacterCloseAutoCompletion() if (eventList && eventList.currentItem) eventList.currentItem.openContextMenu() @@ -230,7 +235,7 @@ HTextArea { } Keys.onPressed: ev => { - if (ev.text) extraCharacterCloseAutoCompletion() + if (ev.text && autoCompletionOpen) extraCharacterCloseAutoCompletion() if (ev.matches(StandardKey.Copy) && ! area.selectedText && diff --git a/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml b/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml index a9aadb80..66b735d3 100644 --- a/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml +++ b/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml @@ -6,14 +6,17 @@ import "../../.." import "../../../Base" import "../../../Base/HTile" +// FIXME: a b -> a @p b → @p doesn't trigger completion +// FIXME: close autocomplete when cursor moves away HListView { - id: listView + id: root property HTextArea textArea property bool open: false property string originalText: "" property bool autoOpenCompleted: false + property var usersCompleted: ({}) // {displayName: userId} readonly property bool autoOpen: autoOpenCompleted || textArea.text.match(/.*(^|\W)@[^\s]+$/) @@ -26,11 +29,12 @@ HListView { "" function replaceLastWord(withText) { - const lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index - const isTextStart = - lastWordStart === 0 && ! textArea.text[0].match(/\s/) + let lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index - textArea.remove(lastWordStart + (isTextStart ? 0 : 1), textArea.length) + if (! (lastWordStart === 0 && ! textArea.text[0].match(/\s/))) + lastWordStart += 1 + + textArea.remove(lastWordStart, textArea.length) textArea.insertAtCursor(withText) } @@ -56,11 +60,22 @@ HListView { py.callCoro("set_string_filter", args, incrementCurrentIndex) } + function accept() { + if (currentIndex !== -1) { + const member = model.get(currentIndex) + usersCompleted[member.display_name] = member.id + usersCompletedChanged() + } + + open = false + } + function cancel() { if (originalText) replaceLastWord(originalText.split(/\s/).splice(-1)[0]) - open = false + currentIndex = -1 + open = false } @@ -70,11 +85,11 @@ HListView { model: ModelStore.get(chat.userId, chat.roomId, "autocompleted_members") delegate: HTile { - width: listView.width + width: root.width contentItem: HLabel { text: model.display_name + " (" + model.id + ")"} onClicked: { - currentIndex = model.index - listView.open = false + currentIndex = model.index + root.open = false } } @@ -102,4 +117,21 @@ HListView { Behavior on opacity { HNumberAnimation {} } Behavior on implicitHeight { HNumberAnimation {} } + + Connections { + target: root.textArea + + function onTextChanged() { + let changed = false + + for (const displayName of Object.keys(root.usersCompleted)) { + if (! root.textArea.text.includes(displayName)) { + delete root.usersCompleted[displayName] + changed = true + } + } + + if (changed) root.usersCompletedChanged() + } + } }