Merge branch 'alexdev'

This commit is contained in:
Maze 2024-01-04 09:57:24 +01:00
commit 71db456ced
11 changed files with 760 additions and 2 deletions

View File

@ -229,6 +229,9 @@ class MatrixClient(nio.AsyncClient):
# {reacted_event_id: {emoji: [user_id]}}
self.unassigned_reaction_events: Dict[str, Dict[str, List[str]]] = {}
# {replaced_event_id: [replace_event]}}
self.unassigned_replace_events: Dict[str, List[Dict[str, str]]] = {}
self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent()
self.ignored_user_ids: Set[str] = set()
@ -2425,6 +2428,7 @@ class MatrixClient(nio.AsyncClient):
self.backend.notification_avatar_cache[mxc] = path
return path
async def register_reaction(
self,
room: nio.MatrixRoom,
@ -2469,6 +2473,75 @@ class MatrixClient(nio.AsyncClient):
content=key, hidden=True, **fields,
)
async def register_message_replacement(
self,
room: nio.MatrixRoom,
ev: Union[nio.Event, nio.BadEvent],
event_id: str = "",
override_fetch_profile: Optional[bool] = None,
**fields,
) -> Event:
"""Register/update a message replacement."""
event_id = event_id or ev.event_id
relates_to = ev.source.get("content", {}).get("m.relates_to", {})
replaced_event_id = relates_to.get("event_id")
model = self.models[self.user_id, room.room_id, "events"]
replaced_event = model.get(replaced_event_id)
if not replaced_event: # local echo
for item in model.values():
if item.event_id == replaced_event_id:
replaced_event = item
# content
content = fields.get("content", "").strip()
inline_content = fields.get("inline_content", "").strip()
if content and "inline_content" not in fields:
inline_content = HTML.filter(content, inline=True)
content_history = {
"id": event_id,
"date": datetime.fromtimestamp(ev.server_timestamp / 1000),
"content": content,
"content_diff": "",
"inline_content": inline_content,
"body": ev.source.get("content", {})
.get("m.new_content", {})
.get("body") or inline_content,
"links": Event.parse_links(content),
}
# message is already loaded: update message instantly
if replaced_event:
history = replaced_event.content_history or []
if history:
content_history["content_diff"] = utils.diff_body(
history[-1]["content"], content_history["content"])
history.append(content_history)
replaced_event.set_fields(
replaced = True,
content = content,
inline_content = inline_content,
content_history = history,
)
replaced_event.source.body = content_history["body"]
replaced_event.notify_change(
"replaced", "content", "inline_content", "content_history")
# message not loaded yet: register the replacement for later update
else:
if replaced_event_id not in self.unassigned_replace_events:
self.unassigned_replace_events[replaced_event_id] = []
self.unassigned_replace_events[replaced_event_id].append(
content_history)
await self.register_nio_event(
room, ev, event_id, override_fetch_profile,
type_specifier=TypeSpecifier.MessageReplace,
hidden=True, **fields,
)
async def register_nio_event(
self,
room: nio.MatrixRoom,
@ -2531,6 +2604,15 @@ class MatrixClient(nio.AsyncClient):
**fields,
)
item.content_history = [{
"id": item.id,
"date": item.date,
"content": item.content,
"content_diff": item.content,
"inline_content": item.inline_content,
"body": ev.source.get("content", {}).get("body", item.content),
"links": item.links,
}]
# Add the Event to model
@ -2558,6 +2640,20 @@ class MatrixClient(nio.AsyncClient):
item.type_specifier = TypeSpecifier.ReactionRedaction
item.hidden = True
replace_events = self.unassigned_replace_events.get(item.id)
if replace_events:
item.replaced = True
item.content_history += sorted(
replace_events, key=lambda r: r["date"])
for index in range(1, len(item.content_history)):
item.content_history[index]["content_diff"] = utils.diff_body(
item.content_history[index - 1]["body"],
item.content_history[index]["body"])
item.content = item.content_history[-1]["content"]
item.inline_content = item.content_history[-1]["inline_content"]
item.source.body = item.content_history[-1]["body"]
del self.unassigned_replace_events[item.id]
model[item.id] = item
await self.set_room_last_event(room.room_id, item)

View File

@ -14,7 +14,7 @@ import lxml # nosec
import nio
from ..presence import Presence
from ..utils import AutoStrEnum, auto, strip_html_tags
from ..utils import AutoStrEnum, auto, strip_html_tags, serialize_value_for_qml
from .model_item import ModelItem
OptionalExceptionType = Union[Type[None], Type[Exception]]
@ -30,6 +30,7 @@ class TypeSpecifier(AutoStrEnum):
MembershipChange = auto()
Reaction = auto()
ReactionRedaction = auto()
MessageReplace = auto()
class PingStatus(AutoStrEnum):
@ -361,6 +362,9 @@ class Event(ModelItem):
reactions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
replaced: bool = False
content_history: List[Dict[str, Any]] = field(default_factory=list)
type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = ""
@ -438,5 +442,7 @@ class Event(ModelItem):
if field == "source":
source_dict = asdict(self.source) if self.source else {}
return json.dumps(source_dict)
if field == "content_history":
return serialize_value_for_qml(self.content_history)
return super().serialized_field(field)

View File

@ -162,6 +162,14 @@ class NioCallbacks:
mention_list = HTML_PROCESSOR.mentions_in_html(co)
# message replacement
relates_to = ev.source.get("content", {}).get("m.relates_to", {})
if relates_to.get("rel_type") == "m.replace":
await self.client.register_message_replacement(
room, ev, content=co, mentions=mention_list,
)
return
await self.client.register_nio_event(
room, ev, content=co, mentions=mention_list,
)

View File

@ -14,6 +14,7 @@ import xml.etree.cElementTree as xml_etree
from concurrent.futures import ProcessPoolExecutor
from contextlib import suppress
from datetime import date, datetime, time, timedelta
from difflib import SequenceMatcher
from enum import Enum
from enum import auto as autostr
from pathlib import Path
@ -206,6 +207,28 @@ def strip_html_tags(text: str) -> str:
return re.sub(r"<\/?[^>]+(>|$)", "", text)
def remove_reply(text: str):
return re.sub(r"<mx-reply.*?>.*?<\/mx-reply>", "", text)
def diff_body(a: str, b: str):
sm = SequenceMatcher(None, remove_reply(a), remove_reply(b))
output = []
for opcode, a0, a1, b0, b1 in sm.get_opcodes():
if opcode == "equal":
output.append(sm.a[a0:a1])
elif opcode == "insert":
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
elif opcode == "delete":
output.append(f"<del>{sm.a[a0:a1]}</del>")
elif opcode == "replace":
output.append(f"<del>{sm.a[a0:a1]}</del>")
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
else:
raise RuntimeError(f"unexpected opcode: {opcode}")
return "".join(output)
def serialize_value_for_qml(
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
) -> Any:

View File

@ -35,7 +35,9 @@ TextEdit {
focus: false
selectByMouse: true
onLinkActivated: if (enableLinkActivation && link !== '#state-text')
onLinkActivated: if (enableLinkActivation
&& link !== '#state-text'
&& link !== '#replaced-text')
Qt.openUrlExternally(link)
MouseArea {

View File

@ -51,6 +51,16 @@ HRowLayout {
readonly property var reactions: model.reactions
readonly property var contentHistory: model.content_history
readonly property string replacedText:
`<a href="#replaced-text" style="text-decoration: none">` +
`<font size=${theme.fontSize.small}px><font ` + (
model.replaced ?
`color="${theme.chat.message.readCounter}">&nbsp;🖉` : // U+1F589
">"
) + "</font></font></a>"
readonly property bool pureMedia: ! contentText && linksRepeater.count
readonly property bool hoveredSelectable: contentHover.hovered
@ -125,6 +135,13 @@ HRowLayout {
id: contentLabel
visible: ! pureMedia
enableLinkActivation: ! eventList.selectedCount
onLinkActivated:
if(link === "#replaced-text") window.makePopup(
"Popups/MessageReplaceHistoryPopup.qml",
{
contentHistory: contentHistory
},
)
selectByMouse:
eventList.selectedCount <= 1 &&
@ -165,6 +182,7 @@ HRowLayout {
timeText +
"</font>" +
replacedText +
stateText
transform: Translate { x: xOffset }

View File

@ -284,6 +284,8 @@ Rectangle {
function focusNextVisibleMessage() {
decrementCurrentIndex()
while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
if ( currentIndex === 0 )
currentIndex = -1;
decrementCurrentIndex()
}
}

View File

@ -0,0 +1,260 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../../Base"
import "../../.."
HRowLayout {
id: historyContent
readonly property var mentions: []
readonly property string mentionsCSS: {
const lines = []
for (const [name, link] of mentions) {
if (! link.match(/^https?:\/\/matrix.to\/#\/@.+/)) continue
lines.push(
`.mention[data-mention='${utils.escapeHtml(name)}'] ` +
`{ color: ${utils.nameColor(name)} }`
)
}
return "<style type='text/css'>" + lines.join("\n") + "</style>"
}
readonly property string diffCSS: {
const lines = [
"del { background-color: #f8d7da; color: #721c24; text-decoration: line-through; }",
"ins { background-color: #d4edda; color: #155724; text-decoration: underline; }",
]
return "<style type='text/css'>" + lines.join("\n") + "</style>"
}
readonly property string senderText: ""
property string contentText: model.content_diff
readonly property string timeText: utils.formatTime(model.date, false)
readonly property bool pureMedia: false
readonly property bool hoveredSelectable: contentHover.hovered
readonly property string hoveredLink:
linksRepeater.lastHovered && linksRepeater.lastHovered.hovered ?
linksRepeater.lastHovered.mediaUrl :
contentLabel.hoveredLink
readonly property alias contentLabel: contentLabel
readonly property int xOffset: 0
readonly property int maxMessageWidth:
contentText.includes("<pre>") || contentText.includes("<table>") ?
-1 :
window.settings.Chat.max_messages_line_length < 0 ?
-1 :
Math.ceil(
mainUI.fontMetrics.averageCharacterWidth *
window.settings.Chat.max_messages_line_length
)
readonly property alias selectedText: contentLabel.selectedPlainText
spacing: theme.chat.message.horizontalSpacing
layoutDirection: Qt.LeftToRight
HColumnLayout {
id: contentColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
HSelectableLabel {
id: contentLabel
visible: ! pureMedia
enableLinkActivation: ! historyList.selectedCount
selectByMouse:
historyList.selectedCount <= 1 &&
historyDelegate.checked &&
textSelectionBlocker.point.scenePosition === Qt.point(0, 0)
topPadding: theme.chat.message.verticalSpacing
bottomPadding: topPadding
leftPadding: historyContent.spacing
rightPadding: leftPadding
color: theme.chat.message.body
font.italic: false
wrapMode: TextEdit.Wrap
textFormat: Text.RichText
text:
// CSS
theme.chat.message.styleInclude + mentionsCSS + diffCSS +
// Sender name & message body
(
compact && contentText.match(/^\s*<(p|h[1-6])>/) ?
contentText.replace(
/(^\s*<(p|h[1-6])>)/, "$1" + senderText,
) :
senderText + contentText
) +
// Time
// For some reason, if there's only one space,
// times will be on their own lines most of the time.
" " +
`<font size=${theme.fontSize.small}px ` +
`color=${theme.chat.message.date}>` +
timeText +
"</font>"
transform: Translate { x: xOffset }
Layout.maximumWidth: historyContent.maxMessageWidth
Layout.fillWidth: true
onSelectedTextChanged: if (selectedPlainText) {
historyList.delegateWithSelectedText = model.id
historyList.selectedText = selectedPlainText
} else if (historyList.delegateWithSelectedText === model.id) {
historyList.delegateWithSelectedText = ""
historyList.selectedText = ""
}
Connections {
target: historyList
onCheckedChanged: contentLabel.deselect()
onDelegateWithSelectedTextChanged: {
if (historyList.delegateWithSelectedText !== model.id)
contentLabel.deselect()
}
}
HoverHandler { id: contentHover }
PointHandler {
id: mousePointHandler
property bool checkedNow: false
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
acceptedPointerTypes:
PointerDevice.GenericPointer | PointerDevice.Eraser
onActiveChanged: {
if (active &&
! historyDelegate.checked &&
(! parent.hoveredLink ||
! parent.enableLinkActivation)) {
historyList.check(model.index)
checkedNow = true
}
if (! active && historyDelegate.checked) {
checkedNow ?
checkedNow = false :
historyList.uncheck(model.index)
}
}
}
PointHandler {
id: mouseShiftPointHandler
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.ShiftModifier
acceptedPointerTypes:
PointerDevice.GenericPointer | PointerDevice.Eraser
onActiveChanged: {
if (active &&
! historyDelegate.checked &&
(! parent.hoveredLink ||
! parent.enableLinkActivation)) {
historyList.checkFromLastToHere(model.index)
}
}
}
TapHandler {
id: touchTapHandler
acceptedButtons: Qt.LeftButton
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
onTapped:
if (! parent.hoveredLink || ! parent.enableLinkActivation)
historyDelegate.toggleChecked()
}
TapHandler {
id: textSelectionBlocker
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
}
Rectangle {
id: contentBackground
width: Math.max(
parent.paintedWidth +
parent.leftPadding + parent.rightPadding,
linksRepeater.summedWidth +
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
)
height: contentColumn.height
radius: theme.chat.message.radius
z: -100
color: historyDelegate.checked &&
! contentLabel.selectedPlainText &&
! mousePointHandler.active &&
! mouseShiftPointHandler.active ?
theme.chat.message.checkedBackground :
theme.chat.message.background
}
}
HRepeater {
id: linksRepeater
property EventMediaLoader lastHovered: null
model: {
const links = historyDelegate.currentModel.links
if (historyDelegate.currentModel.media_url)
links.push(historyDelegate.currentModel.media_url)
return links
}
EventMediaLoader {
singleMediaInfo: historyDelegate.currentModel
mediaUrl: modelData
showSender: pureMedia ? senderText : ""
showDate: pureMedia ? timeText : ""
showLocalEcho: pureMedia && (
singleMediaInfo.is_local_echo ||
singleMediaInfo.read_by_count
) ? stateText : ""
transform: Translate { x: xOffset }
onHoveredChanged: if (hovered) linksRepeater.lastHovered = this
Layout.bottomMargin: pureMedia ? 0 : contentLabel.bottomPadding
Layout.leftMargin: pureMedia ? 0 : historyContent.spacing
Layout.rightMargin: pureMedia ? 0 : historyContent.spacing
Layout.preferredWidth: item ? item.width : -1
Layout.preferredHeight: item ? item.height : -1
}
}
}
HSpacer {}
}

View File

@ -0,0 +1,93 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
HColumnLayout {
id: historyDelegate
// Remember timeline goes from newest message at index 0 to oldest
readonly property var previousModel: historyList.model.get(model.index + 1)
readonly property var nextModel: historyList.model.get(model.index - 1)
readonly property QtObject currentModel: model
readonly property bool isFocused: model.index === historyList.currentIndex
readonly property bool compact: window.settings.General.compact
readonly property bool checked: model.id in historyList.checked
readonly property bool isOwn: true
readonly property bool isRedacted: false
readonly property bool onRight: ! historyList.ownEventsOnLeft && isOwn
readonly property bool combine: false
readonly property bool talkBreak: false
readonly property bool dayBreak:
model.index === 0 ? true : historyList.canDayBreak(previousModel, model)
readonly property bool hideNameLine: true
readonly property int cursorShape:
historyContent.hoveredLink ? Qt.PointingHandCursor :
historyContent.hoveredSelectable ? Qt.IBeamCursor :
Qt.ArrowCursor
readonly property int separationSpacing: theme.spacing * (
dayBreak ? 4 :
talkBreak ? 6 :
combine && compact ? 0.25 :
combine ? 0.5 :
compact ? 1 :
2
)
readonly property alias historyContent: historyContent
function toggleChecked() {
historyList.toggleCheck(model.index)
}
width: historyList.width - historyList.leftMargin - historyList.rightMargin
// Needed because of historyList's MouseArea which steals the
// HSelectableLabel's MouseArea hover events
onCursorShapeChanged: historyList.cursorShape = cursorShape
ListView.onRemove: historyList.uncheck(model.id)
DelegateTransitionFixer {}
Item {
Layout.fillWidth: true
visible: model.index !== 0
Layout.preferredHeight: separationSpacing
}
DayBreak {
visible: dayBreak
Layout.fillWidth: true
Layout.minimumWidth: parent.width
Layout.bottomMargin: separationSpacing
}
HistoryContent {
id: historyContent
Layout.fillWidth: true
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
onTapped: toggleChecked()
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.ShiftModifier
onTapped: historyList.checkFromLastToHere(model.index)
}
}

View File

@ -0,0 +1,206 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtQuick.Window 2.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
import "../../../PythonBridge"
import "../../../ShortcutBundles"
Rectangle {
readonly property alias historyList: historyList
color: theme.chat.eventList.background
HShortcut {
sequences: window.settings.Keys.Messages.unfocus_or_deselect
onActivated: {
historyList.selectedCount ?
historyList.checked = {} :
historyList.currentIndex = -1
}
}
HShortcut {
sequences: window.settings.Keys.Messages.previous
onActivated: historyList.focusPreviousMessage()
}
HShortcut {
sequences: window.settings.Keys.Messages.next
onActivated: historyList.focusNextMessage()
}
HShortcut {
active: historyList.currentItem
sequences: window.settings.Keys.Messages.select
onActivated: historyList.toggleCheck(historyList.currentIndex)
}
HShortcut {
active: historyList.currentItem
sequences: window.settings.Keys.Messages.select_until_here
onActivated:
historyList.checkFromLastToHere(historyList.currentIndex)
}
HShortcut {
sequences: window.settings.Keys.Messages.open_links_files
onActivated: {
const indice =
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
for (const i of Array.from(indice).sort().reverse()) {
const event = historyList.model.get(i)
for (const url of JSON.parse(event.links)) {
utils.getLinkType(url) === Utils.Media.Image ?
historyList.openImageViewer(event, url) :
Qt.openUrlExternally(url)
}
}
}
}
HShortcut {
sequences: window.settings.Keys.Messages.open_links_files_externally
onActivated: {
const indice =
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
for (const i of Array.from(indice).sort().reverse()) {
const event = historyList.model.get(i)
for (const url of JSON.parse(event.links))
Qt.openUrlExternally(url)
}
}
}
HListView {
id: historyList
property bool ownEventsOnLeft: false
property string delegateWithSelectedText: ""
property string selectedText: ""
property bool showFocusedSeenTooltips: false
property alias cursorShape: cursorShapeArea.cursorShape
function focusCenterMessage() {
const previous = highlightRangeMode
highlightRangeMode = HListView.NoHighlightRange
currentIndex = indexAt(0, contentY + height / 2)
highlightRangeMode = previous
}
function focusPreviousMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() :
incrementCurrentIndex()
}
function focusNextMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() :
historyList.currentIndex === 0 ?
historyList.currentIndex = -1 :
decrementCurrentIndex()
}
function copySelectedDelegates() {
if (historyList.selectedText) {
Clipboard.text = historyList.selectedText
return
}
if (! historyList.selectedCount && historyList.currentIndex !== -1) {
const model = historyList.model.get(historyList.currentIndex)
const source = JSON.parse(model.source)
Clipboard.text =
model.media_http_url &&
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
model.media_http_url :
"body" in source ?
source.body :
utils.stripHtmlTags(utils.processedEventText(model))
return
}
const contents = []
for (const model of historyList.getSortedChecked()) {
const source = JSON.parse(model.source)
contents.push(
model.media_http_url &&
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
model.media_http_url :
"body" in source ?
source.body :
utils.stripHtmlTags(utils.processedEventText(model))
)
}
Clipboard.text = contents.join("\n\n")
}
function canDayBreak(item, itemAfter) {
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
return false
return item.date.getDate() !== itemAfter.date.getDate()
}
function getFocusedOrSelectedOrLastMediaEvents(acceptLinks=false) {
if (historyList.selectedCount) return historyList.checkedIndice
if (historyList.currentIndex !== -1) return [historyList.currentIndex]
// Find most recent event that's a media or contains links
for (let i = 0; i < historyList.model.count && i <= 1000; i++) {
const ev = historyList.model.get(i)
const links = JSON.parse(ev.links)
if (ev.media_url || (acceptLinks && links.length)) return [i]
}
}
anchors.fill: parent
clip: true
keyNavigationWraps: false
leftMargin: theme.spacing
rightMargin: theme.spacing
topMargin: theme.spacing
bottomMargin: theme.spacing
// model: ModelStore.get(chat.userRoomId[0], chat.userRoomId[1], "events")
model: []
delegate: HistoryDelegate {}
highlight: Rectangle {
color: theme.chat.message.focusedHighlight
opacity: theme.chat.message.focusedHighlightOpacity
}
MouseArea {
id: cursorShapeArea
anchors.fill: parent
acceptedButtons: Qt.NoButton
}
}
}

View File

@ -0,0 +1,44 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../Base"
import "../Base/Buttons"
import "../Pages/Chat/Timeline"
HColumnPopup {
id: popup
contentWidthLimit:
window.settings.Chat.max_messages_line_length < 0 ?
theme.controls.popup.defaultWidth * 2 :
Math.ceil(
mainUI.fontMetrics.averageCharacterWidth *
window.settings.Chat.max_messages_line_length
)
property var contentHistory
page.footer: AutoDirectionLayout {
CancelButton {
id: cancelButton
onClicked: popup.close()
}
}
onOpened: cancelButton.forceActiveFocus()
SummaryLabel {
text: qsTr("Message History")
textFormat: Text.StyledText
}
HistoryList {
id: historyList
historyList.model: contentHistory
height: 400
Layout.fillWidth: true
Layout.fillHeight: true
}
}