adds edits
This commit is contained in:
parent
f5691fd8be
commit
fc23274c94
|
@ -229,6 +229,9 @@ class MatrixClient(nio.AsyncClient):
|
||||||
# {reacted_event_id: {emoji: [user_id]}}
|
# {reacted_event_id: {emoji: [user_id]}}
|
||||||
self.unassigned_reaction_events: Dict[str, Dict[str, List[str]]] = {}
|
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.push_rules: nio.PushRulesEvent = nio.PushRulesEvent()
|
||||||
self.ignored_user_ids: Set[str] = set()
|
self.ignored_user_ids: Set[str] = set()
|
||||||
|
|
||||||
|
@ -2425,6 +2428,7 @@ class MatrixClient(nio.AsyncClient):
|
||||||
self.backend.notification_avatar_cache[mxc] = path
|
self.backend.notification_avatar_cache[mxc] = path
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
async def register_reaction(
|
async def register_reaction(
|
||||||
self,
|
self,
|
||||||
room: nio.MatrixRoom,
|
room: nio.MatrixRoom,
|
||||||
|
@ -2469,6 +2473,75 @@ class MatrixClient(nio.AsyncClient):
|
||||||
content=key, hidden=True, **fields,
|
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(
|
async def register_nio_event(
|
||||||
self,
|
self,
|
||||||
room: nio.MatrixRoom,
|
room: nio.MatrixRoom,
|
||||||
|
@ -2531,6 +2604,15 @@ class MatrixClient(nio.AsyncClient):
|
||||||
|
|
||||||
**fields,
|
**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
|
# Add the Event to model
|
||||||
|
|
||||||
|
@ -2558,6 +2640,20 @@ class MatrixClient(nio.AsyncClient):
|
||||||
item.type_specifier = TypeSpecifier.ReactionRedaction
|
item.type_specifier = TypeSpecifier.ReactionRedaction
|
||||||
item.hidden = True
|
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
|
model[item.id] = item
|
||||||
await self.set_room_last_event(room.room_id, item)
|
await self.set_room_last_event(room.room_id, item)
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ import lxml # nosec
|
||||||
import nio
|
import nio
|
||||||
|
|
||||||
from ..presence import Presence
|
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
|
from .model_item import ModelItem
|
||||||
|
|
||||||
OptionalExceptionType = Union[Type[None], Type[Exception]]
|
OptionalExceptionType = Union[Type[None], Type[Exception]]
|
||||||
|
@ -30,6 +30,7 @@ class TypeSpecifier(AutoStrEnum):
|
||||||
MembershipChange = auto()
|
MembershipChange = auto()
|
||||||
Reaction = auto()
|
Reaction = auto()
|
||||||
ReactionRedaction = auto()
|
ReactionRedaction = auto()
|
||||||
|
MessageReplace = auto()
|
||||||
|
|
||||||
|
|
||||||
class PingStatus(AutoStrEnum):
|
class PingStatus(AutoStrEnum):
|
||||||
|
@ -361,6 +362,9 @@ class Event(ModelItem):
|
||||||
|
|
||||||
reactions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
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
|
type_specifier: TypeSpecifier = TypeSpecifier.Unset
|
||||||
|
|
||||||
target_id: str = ""
|
target_id: str = ""
|
||||||
|
@ -438,5 +442,7 @@ class Event(ModelItem):
|
||||||
if field == "source":
|
if field == "source":
|
||||||
source_dict = asdict(self.source) if self.source else {}
|
source_dict = asdict(self.source) if self.source else {}
|
||||||
return json.dumps(source_dict)
|
return json.dumps(source_dict)
|
||||||
|
if field == "content_history":
|
||||||
|
return serialize_value_for_qml(self.content_history)
|
||||||
|
|
||||||
return super().serialized_field(field)
|
return super().serialized_field(field)
|
||||||
|
|
|
@ -162,6 +162,14 @@ class NioCallbacks:
|
||||||
|
|
||||||
mention_list = HTML_PROCESSOR.mentions_in_html(co)
|
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(
|
await self.client.register_nio_event(
|
||||||
room, ev, content=co, mentions=mention_list,
|
room, ev, content=co, mentions=mention_list,
|
||||||
)
|
)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import xml.etree.cElementTree as xml_etree
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from difflib import SequenceMatcher
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from enum import auto as autostr
|
from enum import auto as autostr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -206,6 +207,24 @@ def strip_html_tags(text: str) -> str:
|
||||||
return re.sub(r"<\/?[^>]+(>|$)", "", text)
|
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"<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(
|
def serialize_value_for_qml(
|
||||||
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
|
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|
|
@ -35,7 +35,9 @@ TextEdit {
|
||||||
focus: false
|
focus: false
|
||||||
selectByMouse: true
|
selectByMouse: true
|
||||||
|
|
||||||
onLinkActivated: if (enableLinkActivation && link !== '#state-text')
|
onLinkActivated: if (enableLinkActivation
|
||||||
|
&& link !== '#state-text'
|
||||||
|
&& link !== '#replaced-text')
|
||||||
Qt.openUrlExternally(link)
|
Qt.openUrlExternally(link)
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
|
|
@ -51,6 +51,16 @@ HRowLayout {
|
||||||
|
|
||||||
readonly property var reactions: model.reactions
|
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}"> 🖉` : // U+1F589
|
||||||
|
|
||||||
|
">"
|
||||||
|
) + "</font></font></a>"
|
||||||
|
|
||||||
readonly property bool pureMedia: ! contentText && linksRepeater.count
|
readonly property bool pureMedia: ! contentText && linksRepeater.count
|
||||||
|
|
||||||
readonly property bool hoveredSelectable: contentHover.hovered
|
readonly property bool hoveredSelectable: contentHover.hovered
|
||||||
|
@ -125,6 +135,13 @@ HRowLayout {
|
||||||
id: contentLabel
|
id: contentLabel
|
||||||
visible: ! pureMedia
|
visible: ! pureMedia
|
||||||
enableLinkActivation: ! eventList.selectedCount
|
enableLinkActivation: ! eventList.selectedCount
|
||||||
|
onLinkActivated:
|
||||||
|
if(link === "#replaced-text") window.makePopup(
|
||||||
|
"Popups/MessageReplaceHistoryPopup.qml",
|
||||||
|
{
|
||||||
|
contentHistory: contentHistory
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
selectByMouse:
|
selectByMouse:
|
||||||
eventList.selectedCount <= 1 &&
|
eventList.selectedCount <= 1 &&
|
||||||
|
@ -165,6 +182,7 @@ HRowLayout {
|
||||||
timeText +
|
timeText +
|
||||||
"</font>" +
|
"</font>" +
|
||||||
|
|
||||||
|
replacedText +
|
||||||
stateText
|
stateText
|
||||||
|
|
||||||
transform: Translate { x: xOffset }
|
transform: Translate { x: xOffset }
|
||||||
|
|
|
@ -284,6 +284,8 @@ Rectangle {
|
||||||
function focusNextVisibleMessage() {
|
function focusNextVisibleMessage() {
|
||||||
decrementCurrentIndex()
|
decrementCurrentIndex()
|
||||||
while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
|
while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
|
||||||
|
if ( currentIndex === 0 )
|
||||||
|
currentIndex = -1;
|
||||||
decrementCurrentIndex()
|
decrementCurrentIndex()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
275
src/gui/Pages/Chat/Timeline/HistoryContent.qml
Normal file
275
src/gui/Pages/Chat/Timeline/HistoryContent.qml
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
// 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:
|
||||||
|
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("<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: 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.
|
||||||
|
" " +
|
||||||
|
`<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 :
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
93
src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
Normal file
93
src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
206
src/gui/Pages/Chat/Timeline/HistoryList.qml
Normal file
206
src/gui/Pages/Chat/Timeline/HistoryList.qml
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
44
src/gui/Popups/MessageReplaceHistoryPopup.qml
Normal file
44
src/gui/Popups/MessageReplaceHistoryPopup.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user