Only mention tab-completed usernames

This commit is contained in:
miruka 2020-08-21 01:17:29 -04:00
parent 5ba669444d
commit 063f9d2b1d
6 changed files with 97 additions and 69 deletions

View File

@ -3,7 +3,7 @@
"""HTML and Markdown processing tools.""" """HTML and Markdown processing tools."""
import re import re
from typing import DefaultDict, Dict, List, Tuple from typing import DefaultDict, Dict, List, Optional, Tuple
from urllib.parse import unquote from urllib.parse import unquote
import html_sanitizer.sanitizer as sanitizer import html_sanitizer.sanitizer as sanitizer
@ -174,9 +174,6 @@ class HTMLProcessor:
extra_newlines_regex = re.compile(r"\n(\n*)") 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: def __init__(self) -> None:
# The whitespace remover doesn't take <pre> into account # The whitespace remover doesn't take <pre> into account
@ -214,7 +211,7 @@ class HTMLProcessor:
def user_id_link_in_html(self, html: str, user_id: str) -> bool: 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) regex = re.compile(rf"https?://matrix.to/#/{user_id}", re.IGNORECASE)
@ -227,10 +224,10 @@ class HTMLProcessor:
def from_markdown( def from_markdown(
self, self,
text: str, text: str,
inline: bool = False, inline: bool = False,
outgoing: bool = False, outgoing: bool = False,
room_id: str = "", display_name_mentions: Optional[Dict[str, str]] = None,
) -> str: ) -> str:
"""Return filtered HTML from Markdown text.""" """Return filtered HTML from Markdown text."""
@ -238,20 +235,22 @@ class HTMLProcessor:
self._markdown_to_html(text), self._markdown_to_html(text),
inline, inline,
outgoing, outgoing,
room_id, display_name_mentions,
) )
def filter( def filter(
self, self,
html: str, html: str,
inline: bool = False, inline: bool = False,
outgoing: bool = False, outgoing: bool = False,
room_id: str = "", display_name_mentions: Optional[Dict[str, str]] = None,
) -> str: ) -> str:
"""Filter and return HTML.""" """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") html = sanit.sanitize(html).rstrip("\n")
if not html.strip(): if not html.strip():
@ -262,7 +261,7 @@ class HTMLProcessor:
) )
for a_tag in tree.iterdescendants("a"): 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: if not outgoing:
self._matrix_to_links_add_classes(a_tag) self._matrix_to_links_add_classes(a_tag)
@ -286,7 +285,10 @@ class HTMLProcessor:
def sanitize_settings( 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: ) -> dict:
"""Return an html_sanitizer configuration.""" """Return an html_sanitizer configuration."""
@ -309,13 +311,10 @@ class HTMLProcessor:
}, },
}} }}
username_link_regexes = [] username_link_regexes = [re.compile(r) for r in [
rf"(?<!\w)(?P<body>{re.escape(name)})(?!\w)(?P<host>)"
if outgoing: for name in (display_name_mentions or {})
username_link_regexes = [re.compile(r) for r in [ ]]
rf"(?<!\w)(?P<body>{re.escape(username)})(?!\w)(?P<host>)"
for username in self.rooms_user_id_names[room_id].values()
]]
return { return {
"tags": inline_tags if inline else all_tags, "tags": inline_tags if inline else all_tags,
@ -472,11 +471,14 @@ class HTMLProcessor:
def _mentions_to_matrix_to_links( 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: ) -> 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. `<a href="@foo:bar.com">@foo:bar.com</a>`. link text, e.g. `<a href="@foo:bar.com">@foo:bar.com</a>`.
We turn them into proper matrix.to URL in this function. 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']}" el.attrib["href"] = f"https://matrix.to/#/{el.attrib['href']}"
return el return el
if not outgoing or room_id not in self.rooms_user_id_names: for name, user_id in (display_name_mentions or {}).items():
return el if unquote(el.attrib["href"]) == name:
for user_id, username in self.rooms_user_id_names[room_id].items():
if unquote(el.attrib["href"]) == username:
el.attrib["href"] = f"https://matrix.to/#/{user_id}" el.attrib["href"] = f"https://matrix.to/#/{user_id}"
return el return el
@ -512,7 +511,6 @@ class HTMLProcessor:
if not href or not el.text: if not href or not el.text:
return el return el
# This must be first, or link will be mistaken by room ID/alias regex # This must be first, or link will be mistaken by room ID/alias regex
if self.link_is_message_id_regex.match(href): if self.link_is_message_id_regex.match(href):
el.attrib["class"] = "mention message-id-mention" el.attrib["class"] = "mention message-id-mention"

View File

@ -509,11 +509,17 @@ class MatrixClient(nio.AsyncClient):
async def send_text( 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: ) -> None:
"""Send a markdown `m.text` or `m.notice` (with `/me`) message .""" """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 escape = False
if text.startswith("//") or text.startswith(r"\/"): if text.startswith("//") or text.startswith(r"\/"):
@ -909,9 +915,7 @@ class MatrixClient(nio.AsyncClient):
content = event_fields.get("content", "").strip() content = event_fields.get("content", "").strip()
if content and "inline_content" not in event_fields: if content and "inline_content" not in event_fields:
event_fields["inline_content"] = HTML.filter( event_fields["inline_content"] = HTML.filter(content, inline=True)
content, inline=True, room_id=room_id,
)
event = Event( event = Event(
id = f"echo-{transaction_id}", id = f"echo-{transaction_id}",
@ -1797,8 +1801,7 @@ class MatrixClient(nio.AsyncClient):
plain_topic = room.topic or "", plain_topic = room.topic or "",
topic = HTML.filter( topic = HTML.filter(
utils.plain2html(room.topic or ""), utils.plain2html(room.topic or ""),
inline = True, inline = True,
room_id = room.room_id,
), ),
inviter_id = inviter, inviter_id = inviter,
inviter_name = room.user_name(inviter) if inviter else "", 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] = \ self.models[self.user_id, room.room_id, "members"][user_id] = \
member_item 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: async def remove_member(self, room: nio.MatrixRoom, user_id: str) -> None:
"""Remove a room member from our models.""" """Remove a room member from our models."""
self.models[self.user_id, room.room_id, "members"].pop(user_id, None) 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) 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() content = fields.get("content", "").strip()
if content and "inline_content" not in fields: if content and "inline_content" not in fields:
fields["inline_content"] = HTML.filter( fields["inline_content"] = HTML.filter(content, inline=True)
content, inline=True, room_id=room.room_id,
)
# Create Event ModelItem # Create Event ModelItem

View File

@ -154,8 +154,6 @@ class NioCallbacks:
ev.formatted_body ev.formatted_body
if ev.format == "org.matrix.custom.html" else if ev.format == "org.matrix.custom.html" else
plain2html(ev.body), plain2html(ev.body),
room_id = room.room_id,
) )
mention_list = HTML_PROCESSOR.mentions_in_html(co) mention_list = HTML_PROCESSOR.mentions_in_html(co)
@ -627,10 +625,8 @@ class NioCallbacks:
self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent, self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent,
) -> None: ) -> None:
if ev.topic: if ev.topic:
topic = HTML_PROCESSOR.filter( topic = HTML_PROCESSOR.filter(plain2html(ev.topic), inline=True)
plain2html(ev.topic), inline=True, room_id=room.room_id, co = f"%1 changed the room's topic to \"{topic}\""
)
co = f"%1 changed the room's topic to \"{topic}\""
else: else:
co = "%1 removed the room's topic" co = "%1 removed the room's topic"

View File

@ -50,6 +50,7 @@ Rectangle {
MessageArea { MessageArea {
id: messageArea id: messageArea
autoCompletionOpen: userCompletion.open autoCompletionOpen: userCompletion.open
usersCompleted: userCompletion.usersCompleted
onAutoCompletePrevious: userCompletion.previous() onAutoCompletePrevious: userCompletion.previous()
onAutoCompleteNext: userCompletion.next() onAutoCompleteNext: userCompletion.next()
@ -57,7 +58,7 @@ Rectangle {
onExtraCharacterCloseAutoCompletion: onExtraCharacterCloseAutoCompletion:
! userCompletion.autoOpen || ! userCompletion.autoOpen ||
userCompletion.autoOpenCompleted ? userCompletion.autoOpenCompleted ?
userCompletion.open = false : userCompletion.accept() :
null null
} }
} }

View File

@ -9,7 +9,9 @@ HTextArea {
id: area id: area
property HListView eventList property HListView eventList
property bool autoCompletionOpen
property bool autoCompletionOpen: false
property var usersCompleted: ({})
property string indent: " " property string indent: " "
@ -88,7 +90,10 @@ HTextArea {
function sendText() { function sendText() {
if (! toSend) return 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) py.callClientCoro(writingUserId, "send_text", args)
area.clear() area.clear()
@ -184,7 +189,7 @@ HTextArea {
autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo() autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo()
Keys.onReturnPressed: ev => { Keys.onReturnPressed: ev => {
extraCharacterCloseAutoCompletion() if (autoCompletionOpen) extraCharacterCloseAutoCompletion()
ev.accepted = true ev.accepted = true
ev.modifiers & Qt.ShiftModifier || ev.modifiers & Qt.ShiftModifier ||
@ -197,7 +202,7 @@ HTextArea {
Keys.onEnterPressed: ev => Keys.returnPressed(ev) Keys.onEnterPressed: ev => Keys.returnPressed(ev)
Keys.onMenuPressed: ev => { Keys.onMenuPressed: ev => {
extraCharacterCloseAutoCompletion() if (autoCompletionOpen) extraCharacterCloseAutoCompletion()
if (eventList && eventList.currentItem) if (eventList && eventList.currentItem)
eventList.currentItem.openContextMenu() eventList.currentItem.openContextMenu()
@ -230,7 +235,7 @@ HTextArea {
} }
Keys.onPressed: ev => { Keys.onPressed: ev => {
if (ev.text) extraCharacterCloseAutoCompletion() if (ev.text && autoCompletionOpen) extraCharacterCloseAutoCompletion()
if (ev.matches(StandardKey.Copy) && if (ev.matches(StandardKey.Copy) &&
! area.selectedText && ! area.selectedText &&

View File

@ -6,14 +6,17 @@ import "../../.."
import "../../../Base" import "../../../Base"
import "../../../Base/HTile" import "../../../Base/HTile"
// FIXME: a b -> a @p b @p doesn't trigger completion
// FIXME: close autocomplete when cursor moves away
HListView { HListView {
id: listView id: root
property HTextArea textArea property HTextArea textArea
property bool open: false property bool open: false
property string originalText: "" property string originalText: ""
property bool autoOpenCompleted: false property bool autoOpenCompleted: false
property var usersCompleted: ({}) // {displayName: userId}
readonly property bool autoOpen: readonly property bool autoOpen:
autoOpenCompleted || textArea.text.match(/.*(^|\W)@[^\s]+$/) autoOpenCompleted || textArea.text.match(/.*(^|\W)@[^\s]+$/)
@ -26,11 +29,12 @@ HListView {
"" ""
function replaceLastWord(withText) { function replaceLastWord(withText) {
const lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index let lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index
const isTextStart =
lastWordStart === 0 && ! textArea.text[0].match(/\s/)
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) textArea.insertAtCursor(withText)
} }
@ -56,11 +60,22 @@ HListView {
py.callCoro("set_string_filter", args, incrementCurrentIndex) 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() { function cancel() {
if (originalText) if (originalText)
replaceLastWord(originalText.split(/\s/).splice(-1)[0]) 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") model: ModelStore.get(chat.userId, chat.roomId, "autocompleted_members")
delegate: HTile { delegate: HTile {
width: listView.width width: root.width
contentItem: HLabel { text: model.display_name + " (" + model.id + ")"} contentItem: HLabel { text: model.display_name + " (" + model.id + ")"}
onClicked: { onClicked: {
currentIndex = model.index currentIndex = model.index
listView.open = false root.open = false
} }
} }
@ -102,4 +117,21 @@ HListView {
Behavior on opacity { HNumberAnimation {} } Behavior on opacity { HNumberAnimation {} }
Behavior on implicitHeight { 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()
}
}
} }