+// 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
+ }
+}