Only mention tab-completed usernames
This commit is contained in:
parent
5ba669444d
commit
063f9d2b1d
|
@ -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)
|
||||||
|
|
||||||
|
@ -230,7 +227,7 @@ class HTMLProcessor:
|
||||||
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,7 +235,7 @@ class HTMLProcessor:
|
||||||
self._markdown_to_html(text),
|
self._markdown_to_html(text),
|
||||||
inline,
|
inline,
|
||||||
outgoing,
|
outgoing,
|
||||||
room_id,
|
display_name_mentions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -247,11 +244,13 @@ class HTMLProcessor:
|
||||||
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,12 +311,9 @@ class HTMLProcessor:
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
username_link_regexes = []
|
|
||||||
|
|
||||||
if outgoing:
|
|
||||||
username_link_regexes = [re.compile(r) for r in [
|
username_link_regexes = [re.compile(r) for r in [
|
||||||
rf"(?<!\w)(?P<body>{re.escape(username)})(?!\w)(?P<host>)"
|
rf"(?<!\w)(?P<body>{re.escape(name)})(?!\w)(?P<host>)"
|
||||||
for username in self.rooms_user_id_names[room_id].values()
|
for name in (display_name_mentions or {})
|
||||||
]]
|
]]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -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"
|
||||||
|
|
|
@ -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}",
|
||||||
|
@ -1798,7 +1802,6 @@ class MatrixClient(nio.AsyncClient):
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -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,9 +625,7 @@ 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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 &&
|
||||||
|
|
|
@ -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,10 +60,21 @@ 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])
|
||||||
|
|
||||||
|
currentIndex = -1
|
||||||
open = false
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user