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()
+        }
+    }
 }