diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index e48e2079..9b81eccf 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -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]["body"], content_history["body"]) + 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) diff --git a/src/backend/models/items.py b/src/backend/models/items.py index 7fdfa8ed..e6cef121 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -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) diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index b2cac242..1a5135f7 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -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, ) diff --git a/src/backend/utils.py b/src/backend/utils.py index 7a7e5b8e..ea6d3994 100644 --- a/src/backend/utils.py +++ b/src/backend/utils.py @@ -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,24 @@ def strip_html_tags(text: str) -> str: return re.sub(r"<\/?[^>]+(>|$)", "", text) +def diff_body(a: str, b: str): + sm = SequenceMatcher(None, plain2html(a), plain2html(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"{sm.b[b0:b1]}") + elif opcode == "delete": + output.append(f"{sm.a[a0:a1]}") + elif opcode == "replace": + output.append(f"{sm.a[a0:a1]}") + output.append(f"{sm.b[b0:b1]}") + 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: diff --git a/src/gui/Base/HSelectableLabel.qml b/src/gui/Base/HSelectableLabel.qml index c017dcb1..04e6f221 100644 --- a/src/gui/Base/HSelectableLabel.qml +++ b/src/gui/Base/HSelectableLabel.qml @@ -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 { diff --git a/src/gui/Pages/Chat/Timeline/EventContent.qml b/src/gui/Pages/Chat/Timeline/EventContent.qml index eae7c36e..3cdcdc72 100644 --- a/src/gui/Pages/Chat/Timeline/EventContent.qml +++ b/src/gui/Pages/Chat/Timeline/EventContent.qml @@ -51,6 +51,16 @@ HRowLayout { readonly property var reactions: model.reactions + readonly property var contentHistory: model.content_history + readonly property string replacedText: + `` + + ` ðŸ–‰` : // U+1F589 + + ">" + ) + "" + 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 + "" + + replacedText + stateText transform: Translate { x: xOffset } diff --git a/src/gui/Pages/Chat/Timeline/EventList.qml b/src/gui/Pages/Chat/Timeline/EventList.qml index 7127c368..23ca977b 100644 --- a/src/gui/Pages/Chat/Timeline/EventList.qml +++ b/src/gui/Pages/Chat/Timeline/EventList.qml @@ -284,6 +284,8 @@ Rectangle { function focusNextVisibleMessage() { decrementCurrentIndex() while ( currentIndex > -1 && model.get(currentIndex).hidden ) { + if ( currentIndex === 0 ) + currentIndex = -1; decrementCurrentIndex() } } diff --git a/src/gui/Pages/Chat/Timeline/HistoryContent.qml b/src/gui/Pages/Chat/Timeline/HistoryContent.qml new file mode 100644 index 00000000..7016932a --- /dev/null +++ b/src/gui/Pages/Chat/Timeline/HistoryContent.qml @@ -0,0 +1,275 @@ +// Copyright Mirage authors & contributors +// 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 "" + } + 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 "" + } + + + 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: + onRight ? + Math.min( + contentColumn.width - contentLabel.paintedWidth - + contentLabel.leftPadding - contentLabel.rightPadding, + + contentColumn.width - linksRepeater.widestChild - + ( + pureMedia ? + 0 : contentLabel.leftPadding + contentLabel.rightPadding + ), + ) : + 0 + + readonly property int maxMessageWidth: + contentText.includes("
") || contentText.includes("") ?
+        -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: onRight ? Qt.RightToLeft: 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.
+                "  " +
+                `` +
+                timeText +
+                ""
+
+            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 :
+
+                       isOwn?
+                       theme.chat.message.ownBackground :
+
+                       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 {}
+}
diff --git a/src/gui/Pages/Chat/Timeline/HistoryDelegate.qml b/src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
new file mode 100644
index 00000000..3fda66f0
--- /dev/null
+++ b/src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
@@ -0,0 +1,93 @@
+// Copyright Mirage authors & contributors 
+// 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)
+    }
+}
diff --git a/src/gui/Pages/Chat/Timeline/HistoryList.qml b/src/gui/Pages/Chat/Timeline/HistoryList.qml
new file mode 100644
index 00000000..1cea1563
--- /dev/null
+++ b/src/gui/Pages/Chat/Timeline/HistoryList.qml
@@ -0,0 +1,206 @@
+// Copyright Mirage authors & contributors 
+// 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
+        }
+
+    }
+}
diff --git a/src/gui/Popups/MessageReplaceHistoryPopup.qml b/src/gui/Popups/MessageReplaceHistoryPopup.qml
new file mode 100644
index 00000000..6d1d0f24
--- /dev/null
+++ b/src/gui/Popups/MessageReplaceHistoryPopup.qml
@@ -0,0 +1,44 @@
+// Copyright Mirage authors & contributors 
+// 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
+    }
+}