mirror of
https://github.com/Nheko-Reborn/nheko.git
synced 2024-10-31 01:50:47 +03:00
Merge branch 'master' of github.com:Nheko-Reborn/nheko
This commit is contained in:
commit
b5669310e5
39 changed files with 2465 additions and 1227 deletions
|
@ -252,7 +252,8 @@ set(SRC_FILES
|
||||||
|
|
||||||
|
|
||||||
# Timeline
|
# Timeline
|
||||||
src/timeline/ReactionsModel.cpp
|
src/timeline/EventStore.cpp
|
||||||
|
src/timeline/Reaction.cpp
|
||||||
src/timeline/TimelineViewManager.cpp
|
src/timeline/TimelineViewManager.cpp
|
||||||
src/timeline/TimelineModel.cpp
|
src/timeline/TimelineModel.cpp
|
||||||
src/timeline/DelegateChooser.cpp
|
src/timeline/DelegateChooser.cpp
|
||||||
|
@ -340,7 +341,7 @@ if(USE_BUNDLED_MTXCLIENT)
|
||||||
FetchContent_Declare(
|
FetchContent_Declare(
|
||||||
MatrixClient
|
MatrixClient
|
||||||
GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
|
GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
|
||||||
GIT_TAG 744018c86a8094acbda9821d6d7b5a890d4aac47
|
GIT_TAG d8666a3f1a5b709b78ccea2b545d540a8cb502ca
|
||||||
)
|
)
|
||||||
FetchContent_MakeAvailable(MatrixClient)
|
FetchContent_MakeAvailable(MatrixClient)
|
||||||
else()
|
else()
|
||||||
|
@ -463,7 +464,8 @@ qt5_wrap_cpp(MOC_HEADERS
|
||||||
src/emoji/Provider.h
|
src/emoji/Provider.h
|
||||||
|
|
||||||
# Timeline
|
# Timeline
|
||||||
src/timeline/ReactionsModel.h
|
src/timeline/EventStore.h
|
||||||
|
src/timeline/Reaction.h
|
||||||
src/timeline/TimelineViewManager.h
|
src/timeline/TimelineViewManager.h
|
||||||
src/timeline/TimelineModel.h
|
src/timeline/TimelineModel.h
|
||||||
src/timeline/DelegateChooser.h
|
src/timeline/DelegateChooser.h
|
||||||
|
|
|
@ -75,6 +75,14 @@ sudo eselect repository enable matrix
|
||||||
sudo emerge -a nheko
|
sudo emerge -a nheko
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Nix(os)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-env -iA nixpkgs.nheko
|
||||||
|
# or
|
||||||
|
nix-shell -p nheko --run nheko
|
||||||
|
```
|
||||||
|
|
||||||
#### Alpine Linux (and postmarketOS)
|
#### Alpine Linux (and postmarketOS)
|
||||||
|
|
||||||
Make sure you have the testing repositories from `edge` enabled. Note that this is not needed on postmarketOS.
|
Make sure you have the testing repositories from `edge` enabled. Note that this is not needed on postmarketOS.
|
||||||
|
@ -124,6 +132,7 @@ Nheko can use bundled version for most of those libraries automatically, if the
|
||||||
To use them, you can enable the hunter integration by passing `-DHUNTER_ENABLED=ON`.
|
To use them, you can enable the hunter integration by passing `-DHUNTER_ENABLED=ON`.
|
||||||
It is probably wise to link those dependencies statically by passing `-DBUILD_SHARED_LIBS=OFF`
|
It is probably wise to link those dependencies statically by passing `-DBUILD_SHARED_LIBS=OFF`
|
||||||
You can select which bundled dependencies you want to use py passing various `-DUSE_BUNDLED_*` flags. By default all dependencies are bundled *if* you enable hunter.
|
You can select which bundled dependencies you want to use py passing various `-DUSE_BUNDLED_*` flags. By default all dependencies are bundled *if* you enable hunter.
|
||||||
|
If you experience build issues and you are trying to link `mtxclient` library without hunter, make sure the library version(commit) as mentioned in the `CMakeList.txt` is used. Sometimes we have to make breaking changes in `mtxclient` and for that period the master branch of both repos may not be compatible.
|
||||||
|
|
||||||
The bundle flags are currently:
|
The bundle flags are currently:
|
||||||
|
|
||||||
|
|
|
@ -146,7 +146,7 @@
|
||||||
"name": "mtxclient",
|
"name": "mtxclient",
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"commit": "744018c86a8094acbda9821d6d7b5a890d4aac47",
|
"commit": "d8666a3f1a5b709b78ccea2b545d540a8cb502ca",
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Nheko-Reborn/mtxclient.git"
|
"url": "https://github.com/Nheko-Reborn/mtxclient.git"
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ TextEdit {
|
||||||
readOnly: true
|
readOnly: true
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
selectByMouse: true
|
selectByMouse: true
|
||||||
|
activeFocusOnPress: false
|
||||||
color: colors.text
|
color: colors.text
|
||||||
|
|
||||||
onLinkActivated: {
|
onLinkActivated: {
|
||||||
|
|
|
@ -30,11 +30,11 @@ Flow {
|
||||||
implicitHeight: contentItem.childrenRect.height
|
implicitHeight: contentItem.childrenRect.height
|
||||||
|
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.text: model.users
|
ToolTip.text: modelData.users
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
console.debug("Picked " + model.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + model.selfReactedEvent)
|
console.debug("Picked " + modelData.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + modelData.selfReactedEvent)
|
||||||
timelineManager.reactToMessage(reactionFlow.roomId, reactionFlow.eventId, model.key, model.selfReactedEvent)
|
timelineManager.queueReactionMessage(reactionFlow.eventId, modelData.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,13 +49,13 @@ Flow {
|
||||||
font.family: settings.emojiFont
|
font.family: settings.emojiFont
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
elideWidth: 150
|
elideWidth: 150
|
||||||
text: model.key
|
text: modelData.key
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.baseline: reactionCounter.baseline
|
anchors.baseline: reactionCounter.baseline
|
||||||
id: reactionText
|
id: reactionText
|
||||||
text: textMetrics.elidedText + (textMetrics.elidedText == model.key ? "" : "…")
|
text: textMetrics.elidedText + (textMetrics.elidedText == modelData.key ? "" : "…")
|
||||||
font.family: settings.emojiFont
|
font.family: settings.emojiFont
|
||||||
color: reaction.hovered ? colors.highlight : colors.text
|
color: reaction.hovered ? colors.highlight : colors.text
|
||||||
maximumLineCount: 1
|
maximumLineCount: 1
|
||||||
|
@ -65,13 +65,13 @@ Flow {
|
||||||
id: divider
|
id: divider
|
||||||
height: Math.floor(reactionCounter.implicitHeight * 1.4)
|
height: Math.floor(reactionCounter.implicitHeight * 1.4)
|
||||||
width: 1
|
width: 1
|
||||||
color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text
|
color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.verticalCenter: divider.verticalCenter
|
anchors.verticalCenter: divider.verticalCenter
|
||||||
id: reactionCounter
|
id: reactionCounter
|
||||||
text: model.counter
|
text: modelData.count
|
||||||
font: reaction.font
|
font: reaction.font
|
||||||
color: reaction.hovered ? colors.highlight : colors.text
|
color: reaction.hovered ? colors.highlight : colors.text
|
||||||
}
|
}
|
||||||
|
@ -82,8 +82,8 @@ Flow {
|
||||||
|
|
||||||
implicitWidth: reaction.implicitWidth
|
implicitWidth: reaction.implicitWidth
|
||||||
implicitHeight: reaction.implicitHeight
|
implicitHeight: reaction.implicitHeight
|
||||||
border.color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text
|
border.color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text
|
||||||
color: model.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base
|
color: modelData.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base
|
||||||
border.width: 1
|
border.width: 1
|
||||||
radius: reaction.height / 2.0
|
radius: reaction.height / 2.0
|
||||||
}
|
}
|
||||||
|
|
|
@ -106,6 +106,6 @@ MouseArea {
|
||||||
//How long the scrollbar will remain visible
|
//How long the scrollbar will remain visible
|
||||||
interval: 500
|
interval: 500
|
||||||
// Hide the scrollbars
|
// Hide the scrollbars
|
||||||
onTriggered: flickable.cancelFlick();
|
onTriggered: { flickable.cancelFlick(); flickable.movementEnded(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,22 +8,25 @@ import im.nheko 1.0
|
||||||
import "./delegates"
|
import "./delegates"
|
||||||
import "./emoji"
|
import "./emoji"
|
||||||
|
|
||||||
MouseArea {
|
Item {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
height: row.height
|
height: row.height
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
propagateComposedEvents: true
|
propagateComposedEvents: true
|
||||||
preventStealing: true
|
preventStealing: true
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
acceptedButtons: Qt.AllButtons
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (mouse.button === Qt.RightButton)
|
if (mouse.button === Qt.RightButton)
|
||||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row)
|
messageContextMenu.show(model.id, model.type, model.isEncrypted, row)
|
||||||
}
|
}
|
||||||
onPressAndHold: {
|
onPressAndHold: {
|
||||||
if (mouse.source === Qt.MouseEventNotSynthesized)
|
messageContextMenu.show(model.id, model.type, model.isEncrypted, row, mapToItem(timelineRoot, mouse.x, mouse.y))
|
||||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row)
|
}
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent"
|
color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent"
|
||||||
|
@ -45,7 +48,7 @@ MouseArea {
|
||||||
// fancy reply, if this is a reply
|
// fancy reply, if this is a reply
|
||||||
Reply {
|
Reply {
|
||||||
visible: model.replyTo
|
visible: model.replyTo
|
||||||
modelData: chat.model.getDump(model.replyTo)
|
modelData: chat.model.getDump(model.replyTo, model.id)
|
||||||
userColor: timelineManager.userColor(modelData.userId, colors.window)
|
userColor: timelineManager.userColor(modelData.userId, colors.window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,7 +93,6 @@ MouseArea {
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.text: qsTr("React")
|
ToolTip.text: qsTr("React")
|
||||||
emojiPicker: emojiPopup
|
emojiPicker: emojiPopup
|
||||||
room_id: model.roomId
|
|
||||||
event_id: model.id
|
event_id: model.id
|
||||||
}
|
}
|
||||||
ImageButton {
|
ImageButton {
|
||||||
|
@ -128,6 +130,7 @@ MouseArea {
|
||||||
Label {
|
Label {
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop
|
||||||
text: model.timestamp.toLocaleTimeString("HH:mm")
|
text: model.timestamp.toLocaleTimeString("HH:mm")
|
||||||
|
width: Math.max(implicitWidth, text.length*fontMetrics.maximumCharacterWidth)
|
||||||
color: inactiveColors.text
|
color: inactiveColors.text
|
||||||
|
|
||||||
MouseArea{
|
MouseArea{
|
||||||
|
|
|
@ -11,6 +11,8 @@ import "./delegates"
|
||||||
import "./emoji"
|
import "./emoji"
|
||||||
|
|
||||||
Page {
|
Page {
|
||||||
|
id: timelineRoot
|
||||||
|
|
||||||
property var colors: currentActivePalette
|
property var colors: currentActivePalette
|
||||||
property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled }
|
property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled }
|
||||||
property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive
|
property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive
|
||||||
|
@ -40,19 +42,24 @@ Page {
|
||||||
id: messageContextMenu
|
id: messageContextMenu
|
||||||
modal: true
|
modal: true
|
||||||
|
|
||||||
function show(eventId_, eventType_, isEncrypted_, showAt) {
|
function show(eventId_, eventType_, isEncrypted_, showAt_, position) {
|
||||||
eventId = eventId_
|
eventId = eventId_
|
||||||
eventType = eventType_
|
eventType = eventType_
|
||||||
isEncrypted = isEncrypted_
|
isEncrypted = isEncrypted_
|
||||||
popup(showAt)
|
|
||||||
|
if (position)
|
||||||
|
popup(position, showAt_)
|
||||||
|
else
|
||||||
|
popup(showAt_)
|
||||||
}
|
}
|
||||||
|
|
||||||
property string eventId
|
property string eventId
|
||||||
property int eventType
|
property int eventType
|
||||||
property bool isEncrypted
|
property bool isEncrypted
|
||||||
|
|
||||||
MenuItem {
|
MenuItem {
|
||||||
text: qsTr("React")
|
text: qsTr("React")
|
||||||
onClicked: chat.model.reactAction(messageContextMenu.eventId)
|
onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId)
|
||||||
}
|
}
|
||||||
MenuItem {
|
MenuItem {
|
||||||
text: qsTr("Reply")
|
text: qsTr("Reply")
|
||||||
|
@ -87,8 +94,6 @@ Page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
id: timelineRoot
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: colors.window
|
color: colors.window
|
||||||
|
@ -113,7 +118,7 @@ Page {
|
||||||
ListView {
|
ListView {
|
||||||
id: chat
|
id: chat
|
||||||
|
|
||||||
visible: timelineManager.timeline != null
|
visible: !!timelineManager.timeline
|
||||||
|
|
||||||
cacheBuffer: 400
|
cacheBuffer: 400
|
||||||
|
|
||||||
|
@ -181,7 +186,7 @@ Page {
|
||||||
|
|
||||||
id: wrapper
|
id: wrapper
|
||||||
property Item section
|
property Item section
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
|
||||||
width: chat.delegateMaxWidth
|
width: chat.delegateMaxWidth
|
||||||
height: section ? section.height + timelinerow.height : timelinerow.height
|
height: section ? section.height + timelinerow.height : timelinerow.height
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
@ -205,14 +210,13 @@ Page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Binding {
|
Connections {
|
||||||
target: chat.model
|
target: chat
|
||||||
property: "currentIndex"
|
function onMovementEnded() {
|
||||||
when: y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height
|
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
|
||||||
value: index
|
chat.model.currentIndex = index;
|
||||||
delayed: true
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
|
@ -354,7 +358,7 @@ Page {
|
||||||
anchors.rightMargin: 20
|
anchors.rightMargin: 20
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
|
|
||||||
modelData: chat.model ? chat.model.getDump(chat.model.reply) : {}
|
modelData: chat.model ? chat.model.getDump(chat.model.reply, chat.model.id) : {}
|
||||||
userColor: timelineManager.userColor(modelData.userId, colors.window)
|
userColor: timelineManager.userColor(modelData.userId, colors.window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,8 @@ Item {
|
||||||
property double divisor: model.isReply ? 4 : 2
|
property double divisor: model.isReply ? 4 : 2
|
||||||
property bool tooHigh: tempHeight > timelineRoot.height / divisor
|
property bool tooHigh: tempHeight > timelineRoot.height / divisor
|
||||||
|
|
||||||
height: tooHigh ? timelineRoot.height / divisor : tempHeight
|
height: Math.round(tooHigh ? timelineRoot.height / divisor : tempHeight)
|
||||||
width: tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth
|
width: Math.round(tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth)
|
||||||
|
|
||||||
Image {
|
Image {
|
||||||
id: blurhash
|
id: blurhash
|
||||||
|
|
|
@ -66,6 +66,12 @@ Item {
|
||||||
text: qsTr("redacted")
|
text: qsTr("redacted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: MtxEvent.Redaction
|
||||||
|
Pill {
|
||||||
|
text: qsTr("redacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
DelegateChoice {
|
DelegateChoice {
|
||||||
roleValue: MtxEvent.Encryption
|
roleValue: MtxEvent.Encryption
|
||||||
Pill {
|
Pill {
|
||||||
|
@ -108,6 +114,12 @@ Item {
|
||||||
text: qsTr("%1 ended the call.").arg(model.data.userName)
|
text: qsTr("%1 ended the call.").arg(model.data.userName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: MtxEvent.CallCandidates
|
||||||
|
NoticeMessage {
|
||||||
|
text: qsTr("Negotiating call...")
|
||||||
|
}
|
||||||
|
}
|
||||||
DelegateChoice {
|
DelegateChoice {
|
||||||
// TODO: make a more complex formatter for the power levels.
|
// TODO: make a more complex formatter for the power levels.
|
||||||
roleValue: MtxEvent.PowerLevels
|
roleValue: MtxEvent.PowerLevels
|
||||||
|
|
|
@ -9,7 +9,7 @@ Rectangle {
|
||||||
id: bg
|
id: bg
|
||||||
radius: 10
|
radius: 10
|
||||||
color: colors.dark
|
color: colors.dark
|
||||||
height: content.height + 24
|
height: Math.round(content.height + 24)
|
||||||
width: parent ? parent.width : undefined
|
width: parent ? parent.width : undefined
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|
|
@ -4,7 +4,7 @@ MatrixText {
|
||||||
property string formatted: model.data.formattedBody
|
property string formatted: model.data.formattedBody
|
||||||
text: "<style type=\"text/css\">a { color:"+colors.link+";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>")
|
text: "<style type=\"text/css\">a { color:"+colors.link+";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>")
|
||||||
width: parent ? parent.width : undefined
|
width: parent ? parent.width : undefined
|
||||||
height: isReply ? Math.min(chat.height / 8, implicitHeight) : undefined
|
height: isReply ? Math.round(Math.min(timelineRoot.height / 8, implicitHeight)) : undefined
|
||||||
clip: true
|
clip: true
|
||||||
font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize
|
font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,11 +8,10 @@ import "../"
|
||||||
ImageButton {
|
ImageButton {
|
||||||
property var colors: currentActivePalette
|
property var colors: currentActivePalette
|
||||||
property var emojiPicker
|
property var emojiPicker
|
||||||
property string room_id
|
|
||||||
property string event_id
|
property string event_id
|
||||||
|
|
||||||
image: ":/icons/icons/ui/smile.png"
|
image: ":/icons/icons/ui/smile.png"
|
||||||
id: emojiButton
|
id: emojiButton
|
||||||
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, room_id, event_id)
|
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,17 +10,17 @@ import "../"
|
||||||
|
|
||||||
Popup {
|
Popup {
|
||||||
|
|
||||||
function show(showAt, room_id, event_id) {
|
function show(showAt, event_id) {
|
||||||
console.debug("Showing emojiPicker for " + event_id + "in room " + room_id)
|
console.debug("Showing emojiPicker for " + event_id)
|
||||||
|
if (showAt){
|
||||||
parent = showAt
|
parent = showAt
|
||||||
x = Math.round((showAt.width - width) / 2)
|
x = Math.round((showAt.width - width) / 2)
|
||||||
y = showAt.height
|
y = showAt.height
|
||||||
emojiPopup.room_id = room_id
|
}
|
||||||
emojiPopup.event_id = event_id
|
emojiPopup.event_id = event_id
|
||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
property string room_id
|
|
||||||
property string event_id
|
property string event_id
|
||||||
property var colors
|
property var colors
|
||||||
property alias model: gridView.model
|
property alias model: gridView.model
|
||||||
|
@ -102,9 +102,9 @@ Popup {
|
||||||
}
|
}
|
||||||
// TODO: maybe add favorites at some point?
|
// TODO: maybe add favorites at some point?
|
||||||
onClicked: {
|
onClicked: {
|
||||||
console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id + " in room " + emojiPopup.room_id)
|
console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id)
|
||||||
emojiPopup.close()
|
emojiPopup.close()
|
||||||
timelineManager.queueReactionMessage(emojiPopup.room_id, emojiPopup.event_id, model.unicode)
|
timelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
920
src/Cache.cpp
920
src/Cache.cpp
File diff suppressed because it is too large
Load diff
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
@ -38,9 +39,6 @@
|
||||||
#include "CacheCryptoStructs.h"
|
#include "CacheCryptoStructs.h"
|
||||||
#include "CacheStructs.h"
|
#include "CacheStructs.h"
|
||||||
|
|
||||||
int
|
|
||||||
numeric_key_comparison(const MDB_val *a, const MDB_val *b);
|
|
||||||
|
|
||||||
class Cache : public QObject
|
class Cache : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
@ -172,6 +170,47 @@ public:
|
||||||
//! Add all notifications containing a user mention to the db.
|
//! Add all notifications containing a user mention to the db.
|
||||||
void saveTimelineMentions(const mtx::responses::Notifications &res);
|
void saveTimelineMentions(const mtx::responses::Notifications &res);
|
||||||
|
|
||||||
|
//! retrieve events in timeline and related functions
|
||||||
|
struct Messages
|
||||||
|
{
|
||||||
|
mtx::responses::Timeline timeline;
|
||||||
|
uint64_t next_index;
|
||||||
|
bool end_of_cache = false;
|
||||||
|
};
|
||||||
|
Messages getTimelineMessages(lmdb::txn &txn,
|
||||||
|
const std::string &room_id,
|
||||||
|
uint64_t index = std::numeric_limits<uint64_t>::max(),
|
||||||
|
bool forward = false);
|
||||||
|
|
||||||
|
std::optional<mtx::events::collections::TimelineEvent> getEvent(
|
||||||
|
const std::string &room_id,
|
||||||
|
const std::string &event_id);
|
||||||
|
void storeEvent(const std::string &room_id,
|
||||||
|
const std::string &event_id,
|
||||||
|
const mtx::events::collections::TimelineEvent &event);
|
||||||
|
std::vector<std::string> relatedEvents(const std::string &room_id,
|
||||||
|
const std::string &event_id);
|
||||||
|
|
||||||
|
struct TimelineRange
|
||||||
|
{
|
||||||
|
uint64_t first, last;
|
||||||
|
};
|
||||||
|
std::optional<TimelineRange> getTimelineRange(const std::string &room_id);
|
||||||
|
std::optional<uint64_t> getTimelineIndex(const std::string &room_id,
|
||||||
|
std::string_view event_id);
|
||||||
|
std::optional<std::string> getTimelineEventId(const std::string &room_id, uint64_t index);
|
||||||
|
|
||||||
|
std::string previousBatchToken(const std::string &room_id);
|
||||||
|
uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res);
|
||||||
|
void savePendingMessage(const std::string &room_id,
|
||||||
|
const mtx::events::collections::TimelineEvent &message);
|
||||||
|
std::optional<mtx::events::collections::TimelineEvent> firstPendingMessage(
|
||||||
|
const std::string &room_id);
|
||||||
|
void removePendingStatus(const std::string &room_id, const std::string &txn_id);
|
||||||
|
|
||||||
|
//! clear timeline keeping only the latest batch
|
||||||
|
void clearTimeline(const std::string &room_id);
|
||||||
|
|
||||||
//! Remove old unused data.
|
//! Remove old unused data.
|
||||||
void deleteOldMessages();
|
void deleteOldMessages();
|
||||||
void deleteOldData() noexcept;
|
void deleteOldData() noexcept;
|
||||||
|
@ -250,8 +289,6 @@ private:
|
||||||
const std::string &room_id,
|
const std::string &room_id,
|
||||||
const mtx::responses::Timeline &res);
|
const mtx::responses::Timeline &res);
|
||||||
|
|
||||||
mtx::responses::Timeline getTimelineMessages(lmdb::txn &txn, const std::string &room_id);
|
|
||||||
|
|
||||||
//! Remove a room from the cache.
|
//! Remove a room from the cache.
|
||||||
// void removeLeftRoom(lmdb::txn &txn, const std::string &room_id);
|
// void removeLeftRoom(lmdb::txn &txn, const std::string &room_id);
|
||||||
template<class T>
|
template<class T>
|
||||||
|
@ -402,13 +439,46 @@ private:
|
||||||
return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE);
|
return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
lmdb::dbi getMessagesDb(lmdb::txn &txn, const std::string &room_id)
|
lmdb::dbi getEventsDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
{
|
{
|
||||||
auto db =
|
return lmdb::dbi::open(txn, std::string(room_id + "/events").c_str(), MDB_CREATE);
|
||||||
lmdb::dbi::open(txn, std::string(room_id + "/messages").c_str(), MDB_CREATE);
|
}
|
||||||
lmdb::dbi_set_compare(txn, db, numeric_key_comparison);
|
|
||||||
|
|
||||||
return db;
|
lmdb::dbi getEventOrderDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
{
|
||||||
|
return lmdb::dbi::open(
|
||||||
|
txn, std::string(room_id + "/event_order").c_str(), MDB_CREATE | MDB_INTEGERKEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// inverse of EventOrderDb
|
||||||
|
lmdb::dbi getEventToOrderDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
{
|
||||||
|
return lmdb::dbi::open(
|
||||||
|
txn, std::string(room_id + "/event2order").c_str(), MDB_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
lmdb::dbi getMessageToOrderDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
{
|
||||||
|
return lmdb::dbi::open(
|
||||||
|
txn, std::string(room_id + "/msg2order").c_str(), MDB_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
lmdb::dbi getOrderToMessageDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
{
|
||||||
|
return lmdb::dbi::open(
|
||||||
|
txn, std::string(room_id + "/order2msg").c_str(), MDB_CREATE | MDB_INTEGERKEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
lmdb::dbi getPendingMessagesDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
{
|
||||||
|
return lmdb::dbi::open(
|
||||||
|
txn, std::string(room_id + "/pending").c_str(), MDB_CREATE | MDB_INTEGERKEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
lmdb::dbi getRelationsDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
{
|
||||||
|
return lmdb::dbi::open(
|
||||||
|
txn, std::string(room_id + "/related").c_str(), MDB_CREATE | MDB_DUPSORT);
|
||||||
}
|
}
|
||||||
|
|
||||||
lmdb::dbi getInviteStatesDb(lmdb::txn &txn, const std::string &room_id)
|
lmdb::dbi getInviteStatesDb(lmdb::txn &txn, const std::string &room_id)
|
||||||
|
|
123
src/ChatPage.cpp
123
src/ChatPage.cpp
|
@ -165,6 +165,11 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
||||||
trySync();
|
trySync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connect(text_input_,
|
||||||
|
&TextInputWidget::clearRoomTimeline,
|
||||||
|
view_manager_,
|
||||||
|
&TimelineViewManager::clearCurrentRoomTimeline);
|
||||||
|
|
||||||
connect(
|
connect(
|
||||||
new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
|
new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
|
||||||
if (isVisible())
|
if (isVisible())
|
||||||
|
@ -254,7 +259,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
||||||
room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
|
room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
|
||||||
|
|
||||||
connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) {
|
connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) {
|
||||||
view_manager_->addRoom(room_id);
|
|
||||||
joinRoom(room_id);
|
joinRoom(room_id);
|
||||||
room_list_->removeRoom(room_id, currentRoom() == room_id);
|
room_list_->removeRoom(room_id, currentRoom() == room_id);
|
||||||
});
|
});
|
||||||
|
@ -323,9 +327,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
||||||
.toStdString();
|
.toStdString();
|
||||||
member.membership = mtx::events::state::Membership::Join;
|
member.membership = mtx::events::state::Membership::Join;
|
||||||
|
|
||||||
http::client()
|
http::client()->send_state_event(
|
||||||
->send_state_event<mtx::events::state::Member,
|
|
||||||
mtx::events::EventType::RoomMember>(
|
|
||||||
currentRoom().toStdString(),
|
currentRoom().toStdString(),
|
||||||
http::client()->user_id().to_string(),
|
http::client()->user_id().to_string(),
|
||||||
member,
|
member,
|
||||||
|
@ -584,12 +586,8 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
||||||
emit notificationsRetrieved(std::move(res));
|
emit notificationsRetrieved(std::move(res));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync, Qt::QueuedConnection);
|
connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync);
|
||||||
connect(this,
|
connect(this, &ChatPage::syncTags, communitiesList_, &CommunitiesList::syncTags);
|
||||||
&ChatPage::syncTags,
|
|
||||||
communitiesList_,
|
|
||||||
&CommunitiesList::syncTags,
|
|
||||||
Qt::QueuedConnection);
|
|
||||||
connect(
|
connect(
|
||||||
this, &ChatPage::syncTopBar, this, [this](const std::map<QString, RoomInfo> &updates) {
|
this, &ChatPage::syncTopBar, this, [this](const std::map<QString, RoomInfo> &updates) {
|
||||||
if (updates.find(currentRoom()) != updates.end())
|
if (updates.find(currentRoom()) != updates.end())
|
||||||
|
@ -614,6 +612,12 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
||||||
[this]() { QTimer::singleShot(RETRY_TIMEOUT, this, &ChatPage::trySync); },
|
[this]() { QTimer::singleShot(RETRY_TIMEOUT, this, &ChatPage::trySync); },
|
||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
connect(this,
|
||||||
|
&ChatPage::newSyncResponse,
|
||||||
|
this,
|
||||||
|
&ChatPage::handleSyncResponse,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
|
connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
|
||||||
|
|
||||||
connectCallMessage<mtx::events::msg::CallInvite>();
|
connectCallMessage<mtx::events::msg::CallInvite>();
|
||||||
|
@ -841,9 +845,6 @@ ChatPage::loadStateFromCache()
|
||||||
|
|
||||||
nhlog::db()->info("restoring state from cache");
|
nhlog::db()->info("restoring state from cache");
|
||||||
|
|
||||||
getProfileInfo();
|
|
||||||
|
|
||||||
QtConcurrent::run([this]() {
|
|
||||||
try {
|
try {
|
||||||
cache::restoreSessions();
|
cache::restoreSessions();
|
||||||
olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY);
|
olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY);
|
||||||
|
@ -859,13 +860,11 @@ ChatPage::loadStateFromCache()
|
||||||
|
|
||||||
} catch (const mtx::crypto::olm_exception &e) {
|
} catch (const mtx::crypto::olm_exception &e) {
|
||||||
nhlog::crypto()->critical("failed to restore olm account: {}", e.what());
|
nhlog::crypto()->critical("failed to restore olm account: {}", e.what());
|
||||||
emit dropToLoginPageCb(
|
emit dropToLoginPageCb(tr("Failed to restore OLM account. Please login again."));
|
||||||
tr("Failed to restore OLM account. Please login again."));
|
|
||||||
return;
|
return;
|
||||||
} catch (const lmdb::error &e) {
|
} catch (const lmdb::error &e) {
|
||||||
nhlog::db()->critical("failed to restore cache: {}", e.what());
|
nhlog::db()->critical("failed to restore cache: {}", e.what());
|
||||||
emit dropToLoginPageCb(
|
emit dropToLoginPageCb(tr("Failed to restore save data. Please login again."));
|
||||||
tr("Failed to restore save data. Please login again."));
|
|
||||||
return;
|
return;
|
||||||
} catch (const json::exception &e) {
|
} catch (const json::exception &e) {
|
||||||
nhlog::db()->critical("failed to parse cache data: {}", e.what());
|
nhlog::db()->critical("failed to parse cache data: {}", e.what());
|
||||||
|
@ -875,9 +874,10 @@ ChatPage::loadStateFromCache()
|
||||||
nhlog::crypto()->info("ed25519 : {}", olm::client()->identity_keys().ed25519);
|
nhlog::crypto()->info("ed25519 : {}", olm::client()->identity_keys().ed25519);
|
||||||
nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519);
|
nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519);
|
||||||
|
|
||||||
|
getProfileInfo();
|
||||||
|
|
||||||
// Start receiving events.
|
// Start receiving events.
|
||||||
emit trySyncCb();
|
emit trySyncCb();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
|
@ -1056,44 +1056,8 @@ ChatPage::startInitialSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
ChatPage::trySync()
|
ChatPage::handleSyncResponse(mtx::responses::Sync res)
|
||||||
{
|
{
|
||||||
mtx::http::SyncOpts opts;
|
|
||||||
opts.set_presence = currentPresence();
|
|
||||||
|
|
||||||
if (!connectivityTimer_.isActive())
|
|
||||||
connectivityTimer_.start();
|
|
||||||
|
|
||||||
try {
|
|
||||||
opts.since = cache::nextBatchToken();
|
|
||||||
} catch (const lmdb::error &e) {
|
|
||||||
nhlog::db()->error("failed to retrieve next batch token: {}", e.what());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
http::client()->sync(
|
|
||||||
opts, [this](const mtx::responses::Sync &res, mtx::http::RequestErr err) {
|
|
||||||
if (err) {
|
|
||||||
const auto error = QString::fromStdString(err->matrix_error.error);
|
|
||||||
const auto msg = tr("Please try to login again: %1").arg(error);
|
|
||||||
const auto err_code = mtx::errors::to_string(err->matrix_error.errcode);
|
|
||||||
const int status_code = static_cast<int>(err->status_code);
|
|
||||||
|
|
||||||
if ((http::is_logged_in() &&
|
|
||||||
(err->matrix_error.errcode ==
|
|
||||||
mtx::errors::ErrorCode::M_UNKNOWN_TOKEN ||
|
|
||||||
err->matrix_error.errcode ==
|
|
||||||
mtx::errors::ErrorCode::M_MISSING_TOKEN)) ||
|
|
||||||
!http::is_logged_in()) {
|
|
||||||
emit dropToLoginPageCb(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nhlog::net()->error("sync error: {} {}", status_code, err_code);
|
|
||||||
emit tryDelayedSyncCb();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nhlog::net()->debug("sync completed: {}", res.next_batch);
|
nhlog::net()->debug("sync completed: {}", res.next_batch);
|
||||||
|
|
||||||
// Ensure that we have enough one-time keys available.
|
// Ensure that we have enough one-time keys available.
|
||||||
|
@ -1128,6 +1092,55 @@ ChatPage::trySync()
|
||||||
}
|
}
|
||||||
|
|
||||||
emit trySyncCb();
|
emit trySyncCb();
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
ChatPage::trySync()
|
||||||
|
{
|
||||||
|
mtx::http::SyncOpts opts;
|
||||||
|
opts.set_presence = currentPresence();
|
||||||
|
|
||||||
|
if (!connectivityTimer_.isActive())
|
||||||
|
connectivityTimer_.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
opts.since = cache::nextBatchToken();
|
||||||
|
} catch (const lmdb::error &e) {
|
||||||
|
nhlog::db()->error("failed to retrieve next batch token: {}", e.what());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
http::client()->sync(
|
||||||
|
opts,
|
||||||
|
[this, since = cache::nextBatchToken()](const mtx::responses::Sync &res,
|
||||||
|
mtx::http::RequestErr err) {
|
||||||
|
if (since != cache::nextBatchToken()) {
|
||||||
|
nhlog::net()->warn("Duplicate sync, dropping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
const auto error = QString::fromStdString(err->matrix_error.error);
|
||||||
|
const auto msg = tr("Please try to login again: %1").arg(error);
|
||||||
|
const auto err_code = mtx::errors::to_string(err->matrix_error.errcode);
|
||||||
|
const int status_code = static_cast<int>(err->status_code);
|
||||||
|
|
||||||
|
if ((http::is_logged_in() &&
|
||||||
|
(err->matrix_error.errcode ==
|
||||||
|
mtx::errors::ErrorCode::M_UNKNOWN_TOKEN ||
|
||||||
|
err->matrix_error.errcode ==
|
||||||
|
mtx::errors::ErrorCode::M_MISSING_TOKEN)) ||
|
||||||
|
!http::is_logged_in()) {
|
||||||
|
emit dropToLoginPageCb(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nhlog::net()->error("sync error: {} {}", status_code, err_code);
|
||||||
|
emit tryDelayedSyncCb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit newSyncResponse(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -140,6 +140,7 @@ signals:
|
||||||
void trySyncCb();
|
void trySyncCb();
|
||||||
void tryDelayedSyncCb();
|
void tryDelayedSyncCb();
|
||||||
void tryInitialSyncCb();
|
void tryInitialSyncCb();
|
||||||
|
void newSyncResponse(mtx::responses::Sync res);
|
||||||
void leftRoom(const QString &room_id);
|
void leftRoom(const QString &room_id);
|
||||||
|
|
||||||
void initializeRoomList(QMap<QString, RoomInfo>);
|
void initializeRoomList(QMap<QString, RoomInfo>);
|
||||||
|
@ -174,6 +175,7 @@ private slots:
|
||||||
|
|
||||||
void joinRoom(const QString &room);
|
void joinRoom(const QString &room);
|
||||||
void sendTypingNotifications();
|
void sendTypingNotifications();
|
||||||
|
void handleSyncResponse(mtx::responses::Sync res);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static ChatPage *instance_;
|
static ChatPage *instance_;
|
||||||
|
|
20
src/CompletionModel.h
Normal file
20
src/CompletionModel.h
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Class for showing a limited amount of completions at a time
|
||||||
|
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
|
||||||
|
class CompletionModel : public QSortFilterProxyModel
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CompletionModel(QAbstractItemModel *model, QObject *parent = nullptr)
|
||||||
|
: QSortFilterProxyModel(parent)
|
||||||
|
{
|
||||||
|
setSourceModel(model);
|
||||||
|
}
|
||||||
|
int rowCount(const QModelIndex &parent) const override
|
||||||
|
{
|
||||||
|
auto row_count = QSortFilterProxyModel::rowCount(parent);
|
||||||
|
return (row_count < 7) ? row_count : 7;
|
||||||
|
}
|
||||||
|
};
|
|
@ -248,6 +248,20 @@ struct EventInReplyTo
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct EventRelatesTo
|
||||||
|
{
|
||||||
|
template<class Content>
|
||||||
|
using related_ev_id_t = decltype(Content::relates_to.event_id);
|
||||||
|
template<class T>
|
||||||
|
std::string operator()(const mtx::events::Event<T> &e)
|
||||||
|
{
|
||||||
|
if constexpr (is_detected<related_ev_id_t, T>::value) {
|
||||||
|
return e.content.relates_to.event_id;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
struct EventTransactionId
|
struct EventTransactionId
|
||||||
{
|
{
|
||||||
template<class T>
|
template<class T>
|
||||||
|
@ -409,6 +423,11 @@ mtx::accessors::in_reply_to_event(const mtx::events::collections::TimelineEvents
|
||||||
{
|
{
|
||||||
return std::visit(EventInReplyTo{}, event);
|
return std::visit(EventInReplyTo{}, event);
|
||||||
}
|
}
|
||||||
|
std::string
|
||||||
|
mtx::accessors::relates_to_event_id(const mtx::events::collections::TimelineEvents &event)
|
||||||
|
{
|
||||||
|
return std::visit(EventRelatesTo{}, event);
|
||||||
|
}
|
||||||
|
|
||||||
std::string
|
std::string
|
||||||
mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event)
|
mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event)
|
||||||
|
|
|
@ -56,6 +56,8 @@ mimetype(const mtx::events::collections::TimelineEvents &event);
|
||||||
std::string
|
std::string
|
||||||
in_reply_to_event(const mtx::events::collections::TimelineEvents &event);
|
in_reply_to_event(const mtx::events::collections::TimelineEvents &event);
|
||||||
std::string
|
std::string
|
||||||
|
relates_to_event_id(const mtx::events::collections::TimelineEvents &event);
|
||||||
|
std::string
|
||||||
transaction_id(const mtx::events::collections::TimelineEvents &event);
|
transaction_id(const mtx::events::collections::TimelineEvents &event);
|
||||||
|
|
||||||
int64_t
|
int64_t
|
||||||
|
|
81
src/Olm.cpp
81
src/Olm.cpp
|
@ -3,6 +3,7 @@
|
||||||
#include "Olm.h"
|
#include "Olm.h"
|
||||||
|
|
||||||
#include "Cache.h"
|
#include "Cache.h"
|
||||||
|
#include "Cache_p.h"
|
||||||
#include "Logging.h"
|
#include "Logging.h"
|
||||||
#include "MatrixClient.h"
|
#include "MatrixClient.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
|
@ -316,22 +317,25 @@ send_key_request_for(const std::string &room_id,
|
||||||
using namespace mtx::events;
|
using namespace mtx::events;
|
||||||
|
|
||||||
nhlog::crypto()->debug("sending key request: {}", json(e).dump(2));
|
nhlog::crypto()->debug("sending key request: {}", json(e).dump(2));
|
||||||
auto payload = json{{"action", "request"},
|
|
||||||
{"request_id", http::client()->generate_txn_id()},
|
|
||||||
{"requesting_device_id", http::client()->device_id()},
|
|
||||||
{"body",
|
|
||||||
{{"algorithm", MEGOLM_ALGO},
|
|
||||||
{"room_id", room_id},
|
|
||||||
{"sender_key", e.content.sender_key},
|
|
||||||
{"session_id", e.content.session_id}}}};
|
|
||||||
|
|
||||||
json body;
|
mtx::events::msg::KeyRequest request;
|
||||||
body["messages"][e.sender] = json::object();
|
request.action = mtx::events::msg::RequestAction::Request;
|
||||||
body["messages"][e.sender][e.content.device_id] = payload;
|
request.algorithm = MEGOLM_ALGO;
|
||||||
|
request.room_id = room_id;
|
||||||
|
request.sender_key = e.content.sender_key;
|
||||||
|
request.session_id = e.content.session_id;
|
||||||
|
request.request_id = "key_request." + http::client()->generate_txn_id();
|
||||||
|
request.requesting_device_id = http::client()->device_id();
|
||||||
|
|
||||||
nhlog::crypto()->debug("m.room_key_request: {}", body.dump(2));
|
nhlog::crypto()->debug("m.room_key_request: {}", json(request).dump(2));
|
||||||
|
|
||||||
http::client()->send_to_device("m.room_key_request", body, [e](mtx::http::RequestErr err) {
|
std::map<mtx::identifiers::User, std::map<std::string, decltype(request)>> body;
|
||||||
|
body[mtx::identifiers::parse<mtx::identifiers::User>(e.sender)][e.content.device_id] =
|
||||||
|
request;
|
||||||
|
body[http::client()->user_id()]["*"] = request;
|
||||||
|
|
||||||
|
http::client()->send_to_device(
|
||||||
|
http::client()->generate_txn_id(), body, [e](mtx::http::RequestErr err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
nhlog::net()->warn("failed to send "
|
nhlog::net()->warn("failed to send "
|
||||||
"send_to_device "
|
"send_to_device "
|
||||||
|
@ -339,8 +343,9 @@ send_key_request_for(const std::string &room_id,
|
||||||
err->matrix_error.error);
|
err->matrix_error.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
nhlog::net()->info(
|
nhlog::net()->info("m.room_key_request sent to {}:{} and your own devices",
|
||||||
"m.room_key_request sent to {}:{}", e.sender, e.content.device_id);
|
e.sender,
|
||||||
|
e.content.device_id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -551,4 +556,50 @@ send_megolm_key_to_device(const std::string &user_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DecryptionResult
|
||||||
|
decryptEvent(const MegolmSessionIndex &index,
|
||||||
|
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &event)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!cache::client()->inboundMegolmSessionExists(index)) {
|
||||||
|
return {DecryptionErrorCode::MissingSession, std::nullopt, std::nullopt};
|
||||||
|
}
|
||||||
|
} catch (const lmdb::error &e) {
|
||||||
|
return {DecryptionErrorCode::DbError, e.what(), std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Lookup index,event_id,origin_server_ts tuple for replay attack errors
|
||||||
|
// TODO: Verify sender_key
|
||||||
|
|
||||||
|
std::string msg_str;
|
||||||
|
try {
|
||||||
|
auto session = cache::client()->getInboundMegolmSession(index);
|
||||||
|
auto res = olm::client()->decrypt_group_message(session, event.content.ciphertext);
|
||||||
|
msg_str = std::string((char *)res.data.data(), res.data.size());
|
||||||
|
} catch (const lmdb::error &e) {
|
||||||
|
return {DecryptionErrorCode::DbError, e.what(), std::nullopt};
|
||||||
|
} catch (const mtx::crypto::olm_exception &e) {
|
||||||
|
return {DecryptionErrorCode::DecryptionFailed, e.what(), std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add missing fields for the event.
|
||||||
|
json body = json::parse(msg_str);
|
||||||
|
body["event_id"] = event.event_id;
|
||||||
|
body["sender"] = event.sender;
|
||||||
|
body["origin_server_ts"] = event.origin_server_ts;
|
||||||
|
body["unsigned"] = event.unsigned_data;
|
||||||
|
|
||||||
|
// relations are unencrypted in content...
|
||||||
|
if (json old_ev = event; old_ev["content"].count("m.relates_to") != 0)
|
||||||
|
body["content"]["m.relates_to"] = old_ev["content"]["m.relates_to"];
|
||||||
|
|
||||||
|
mtx::events::collections::TimelineEvent te;
|
||||||
|
try {
|
||||||
|
mtx::events::collections::from_json(body, te);
|
||||||
|
} catch (std::exception &e) {
|
||||||
|
return {DecryptionErrorCode::ParsingFailed, e.what(), std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {std::nullopt, std::nullopt, std::move(te.data)};
|
||||||
|
}
|
||||||
} // namespace olm
|
} // namespace olm
|
||||||
|
|
24
src/Olm.h
24
src/Olm.h
|
@ -7,10 +7,30 @@
|
||||||
#include <mtx/events/encrypted.hpp>
|
#include <mtx/events/encrypted.hpp>
|
||||||
#include <mtxclient/crypto/client.hpp>
|
#include <mtxclient/crypto/client.hpp>
|
||||||
|
|
||||||
|
#include <CacheCryptoStructs.h>
|
||||||
|
|
||||||
constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2";
|
constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2";
|
||||||
|
|
||||||
namespace olm {
|
namespace olm {
|
||||||
|
|
||||||
|
enum class DecryptionErrorCode
|
||||||
|
{
|
||||||
|
MissingSession, // Session was not found, retrieve from backup or request from other devices
|
||||||
|
// and try again
|
||||||
|
DbError, // DB read failed
|
||||||
|
DecryptionFailed, // libolm error
|
||||||
|
ParsingFailed, // Failed to parse the actual event
|
||||||
|
ReplayAttack, // Megolm index reused
|
||||||
|
UnknownFingerprint, // Unknown device Fingerprint
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DecryptionResult
|
||||||
|
{
|
||||||
|
std::optional<DecryptionErrorCode> error;
|
||||||
|
std::optional<std::string> error_message;
|
||||||
|
std::optional<mtx::events::collections::TimelineEvents> event;
|
||||||
|
};
|
||||||
|
|
||||||
struct OlmMessage
|
struct OlmMessage
|
||||||
{
|
{
|
||||||
std::string sender_key;
|
std::string sender_key;
|
||||||
|
@ -65,6 +85,10 @@ encrypt_group_message(const std::string &room_id,
|
||||||
const std::string &device_id,
|
const std::string &device_id,
|
||||||
nlohmann::json body);
|
nlohmann::json body);
|
||||||
|
|
||||||
|
DecryptionResult
|
||||||
|
decryptEvent(const MegolmSessionIndex &index,
|
||||||
|
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &event);
|
||||||
|
|
||||||
void
|
void
|
||||||
mark_keys_as_published();
|
mark_keys_as_published();
|
||||||
|
|
||||||
|
|
|
@ -15,9 +15,11 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
#include <QAbstractItemView>
|
||||||
#include <QAbstractTextDocumentLayout>
|
#include <QAbstractTextDocumentLayout>
|
||||||
#include <QBuffer>
|
#include <QBuffer>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
|
#include <QCompleter>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QMimeData>
|
#include <QMimeData>
|
||||||
#include <QMimeDatabase>
|
#include <QMimeDatabase>
|
||||||
|
@ -28,9 +30,12 @@
|
||||||
|
|
||||||
#include "Cache.h"
|
#include "Cache.h"
|
||||||
#include "ChatPage.h"
|
#include "ChatPage.h"
|
||||||
|
#include "CompletionModel.h"
|
||||||
#include "Logging.h"
|
#include "Logging.h"
|
||||||
#include "TextInputWidget.h"
|
#include "TextInputWidget.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
|
#include "emoji/EmojiSearchModel.h"
|
||||||
|
#include "emoji/Provider.h"
|
||||||
#include "ui/FlatButton.h"
|
#include "ui/FlatButton.h"
|
||||||
#include "ui/LoadingIndicator.h"
|
#include "ui/LoadingIndicator.h"
|
||||||
|
|
||||||
|
@ -61,6 +66,23 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
|
||||||
connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged);
|
connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged);
|
||||||
setAcceptRichText(false);
|
setAcceptRichText(false);
|
||||||
|
|
||||||
|
completer_ = new QCompleter(this);
|
||||||
|
completer_->setWidget(this);
|
||||||
|
auto model = new emoji::EmojiSearchModel(this);
|
||||||
|
model->sort(0, Qt::AscendingOrder);
|
||||||
|
completer_->setModel((emoji_completion_model_ = new CompletionModel(model, this)));
|
||||||
|
completer_->setModelSorting(QCompleter::UnsortedModel);
|
||||||
|
completer_->popup()->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
completer_->popup()->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
|
||||||
|
connect(completer_,
|
||||||
|
QOverload<const QModelIndex &>::of(&QCompleter::activated),
|
||||||
|
[this](auto &index) {
|
||||||
|
emoji_popup_open_ = false;
|
||||||
|
auto emoji = index.data(emoji::EmojiModel::Unicode).toString();
|
||||||
|
insertCompletion(emoji);
|
||||||
|
});
|
||||||
|
|
||||||
typingTimer_ = new QTimer(this);
|
typingTimer_ = new QTimer(this);
|
||||||
typingTimer_->setInterval(1000);
|
typingTimer_->setInterval(1000);
|
||||||
typingTimer_->setSingleShot(true);
|
typingTimer_->setSingleShot(true);
|
||||||
|
@ -101,6 +123,18 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
|
||||||
previewDialog_.hide();
|
previewDialog_.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
FilteredTextEdit::insertCompletion(QString completion)
|
||||||
|
{
|
||||||
|
// Paint the current word and replace it with 'completion'
|
||||||
|
auto cur_text = textAfterPosition(trigger_pos_);
|
||||||
|
auto tc = textCursor();
|
||||||
|
tc.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, cur_text.length());
|
||||||
|
tc.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, cur_text.length());
|
||||||
|
tc.insertText(completion);
|
||||||
|
setTextCursor(tc);
|
||||||
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
FilteredTextEdit::showResults(const std::vector<SearchResult> &results)
|
FilteredTextEdit::showResults(const std::vector<SearchResult> &results)
|
||||||
{
|
{
|
||||||
|
@ -167,6 +201,21 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (emoji_popup_open_) {
|
||||||
|
auto fake_key = (event->key() == Qt::Key_Backtab) ? Qt::Key_Up : Qt::Key_Down;
|
||||||
|
switch (event->key()) {
|
||||||
|
case Qt::Key_Backtab:
|
||||||
|
case Qt::Key_Tab: {
|
||||||
|
// Simulate up/down arrow press
|
||||||
|
auto ev = new QKeyEvent(QEvent::KeyPress, fake_key, Qt::NoModifier);
|
||||||
|
QCoreApplication::postEvent(completer_->popup(), ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (event->key()) {
|
switch (event->key()) {
|
||||||
case Qt::Key_At:
|
case Qt::Key_At:
|
||||||
atTriggerPosition_ = textCursor().position();
|
atTriggerPosition_ = textCursor().position();
|
||||||
|
@ -195,8 +244,26 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case Qt::Key_Colon: {
|
||||||
|
QTextEdit::keyPressEvent(event);
|
||||||
|
trigger_pos_ = textCursor().position() - 1;
|
||||||
|
emoji_completion_model_->setFilterRegExp("");
|
||||||
|
emoji_popup_open_ = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case Qt::Key_Return:
|
case Qt::Key_Return:
|
||||||
case Qt::Key_Enter:
|
case Qt::Key_Enter:
|
||||||
|
if (emoji_popup_open_) {
|
||||||
|
if (!completer_->popup()->currentIndex().isValid()) {
|
||||||
|
// No completion to select, do normal behavior
|
||||||
|
completer_->popup()->hide();
|
||||||
|
emoji_popup_open_ = false;
|
||||||
|
} else {
|
||||||
|
event->ignore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!(event->modifiers() & Qt::ShiftModifier)) {
|
if (!(event->modifiers() & Qt::ShiftModifier)) {
|
||||||
stopTyping();
|
stopTyping();
|
||||||
submit();
|
submit();
|
||||||
|
@ -243,6 +310,21 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
|
||||||
if (isModifier)
|
if (isModifier)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (emoji_popup_open_ && textAfterPosition(trigger_pos_).length() > 2) {
|
||||||
|
// Update completion
|
||||||
|
emoji_completion_model_->setFilterRegExp(textAfterPosition(trigger_pos_));
|
||||||
|
completer_->complete(completerRect());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emoji_popup_open_ && (completer_->completionCount() < 1 ||
|
||||||
|
!textAfterPosition(trigger_pos_)
|
||||||
|
.contains(QRegularExpression(":[^\r\n\t\f\v :]+$")))) {
|
||||||
|
// No completions for this word or another word than the completer was
|
||||||
|
// started with
|
||||||
|
emoji_popup_open_ = false;
|
||||||
|
completer_->popup()->hide();
|
||||||
|
}
|
||||||
|
|
||||||
if (textCursor().position() == 0) {
|
if (textCursor().position() == 0) {
|
||||||
resetAnchor();
|
resetAnchor();
|
||||||
closeSuggestions();
|
closeSuggestions();
|
||||||
|
@ -352,6 +434,29 @@ FilteredTextEdit::stopTyping()
|
||||||
emit stoppedTyping();
|
emit stoppedTyping();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QRect
|
||||||
|
FilteredTextEdit::completerRect()
|
||||||
|
{
|
||||||
|
// Move left edge to the beginning of the word
|
||||||
|
auto cursor = textCursor();
|
||||||
|
auto rect = cursorRect();
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor::Left, QTextCursor::MoveAnchor, textAfterPosition(trigger_pos_).length());
|
||||||
|
auto cursor_global_x = viewport()->mapToGlobal(cursorRect(cursor).topLeft()).x();
|
||||||
|
auto rect_global_left = viewport()->mapToGlobal(rect.bottomLeft()).x();
|
||||||
|
auto dx = qAbs(rect_global_left - cursor_global_x);
|
||||||
|
rect.moveLeft(rect.left() - dx);
|
||||||
|
|
||||||
|
auto item_height = completer_->popup()->sizeHintForRow(0);
|
||||||
|
auto max_height = item_height * completer_->maxVisibleItems();
|
||||||
|
auto height = (completer_->completionCount() > completer_->maxVisibleItems())
|
||||||
|
? max_height
|
||||||
|
: completer_->completionCount() * item_height;
|
||||||
|
rect.setWidth(completer_->popup()->sizeHintForColumn(0));
|
||||||
|
rect.moveBottom(-height);
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
QSize
|
QSize
|
||||||
FilteredTextEdit::sizeHint() const
|
FilteredTextEdit::sizeHint() const
|
||||||
{
|
{
|
||||||
|
@ -581,27 +686,29 @@ void
|
||||||
TextInputWidget::command(QString command, QString args)
|
TextInputWidget::command(QString command, QString args)
|
||||||
{
|
{
|
||||||
if (command == "me") {
|
if (command == "me") {
|
||||||
sendEmoteMessage(args);
|
emit sendEmoteMessage(args);
|
||||||
} else if (command == "join") {
|
} else if (command == "join") {
|
||||||
sendJoinRoomRequest(args);
|
emit sendJoinRoomRequest(args);
|
||||||
} else if (command == "invite") {
|
} else if (command == "invite") {
|
||||||
sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
emit sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||||
} else if (command == "kick") {
|
} else if (command == "kick") {
|
||||||
sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||||
} else if (command == "ban") {
|
} else if (command == "ban") {
|
||||||
sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
emit sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||||
} else if (command == "unban") {
|
} else if (command == "unban") {
|
||||||
sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||||
} else if (command == "roomnick") {
|
} else if (command == "roomnick") {
|
||||||
changeRoomNick(args);
|
emit changeRoomNick(args);
|
||||||
} else if (command == "shrug") {
|
} else if (command == "shrug") {
|
||||||
sendTextMessage("¯\\_(ツ)_/¯");
|
emit sendTextMessage("¯\\_(ツ)_/¯");
|
||||||
} else if (command == "fliptable") {
|
} else if (command == "fliptable") {
|
||||||
sendTextMessage("(╯°□°)╯︵ ┻━┻");
|
emit sendTextMessage("(╯°□°)╯︵ ┻━┻");
|
||||||
} else if (command == "unfliptable") {
|
} else if (command == "unfliptable") {
|
||||||
sendTextMessage(" ┯━┯╭( º _ º╭)");
|
emit sendTextMessage(" ┯━┯╭( º _ º╭)");
|
||||||
} else if (command == "sovietflip") {
|
} else if (command == "sovietflip") {
|
||||||
sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
|
emit sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
|
||||||
|
} else if (command == "clear-timeline") {
|
||||||
|
emit clearRoomTimeline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -633,7 +740,7 @@ TextInputWidget::showUploadSpinner()
|
||||||
topLayout_->removeWidget(sendFileBtn_);
|
topLayout_->removeWidget(sendFileBtn_);
|
||||||
sendFileBtn_->hide();
|
sendFileBtn_->hide();
|
||||||
|
|
||||||
topLayout_->insertWidget(0, spinner_);
|
topLayout_->insertWidget(1, spinner_);
|
||||||
spinner_->start();
|
spinner_->start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -641,7 +748,7 @@ void
|
||||||
TextInputWidget::hideUploadSpinner()
|
TextInputWidget::hideUploadSpinner()
|
||||||
{
|
{
|
||||||
topLayout_->removeWidget(spinner_);
|
topLayout_->removeWidget(spinner_);
|
||||||
topLayout_->insertWidget(0, sendFileBtn_);
|
topLayout_->insertWidget(1, sendFileBtn_);
|
||||||
sendFileBtn_->show();
|
sendFileBtn_->show();
|
||||||
spinner_->stop();
|
spinner_->stop();
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,8 +33,10 @@
|
||||||
|
|
||||||
struct SearchResult;
|
struct SearchResult;
|
||||||
|
|
||||||
|
class CompletionModel;
|
||||||
class FlatButton;
|
class FlatButton;
|
||||||
class LoadingIndicator;
|
class LoadingIndicator;
|
||||||
|
class QCompleter;
|
||||||
|
|
||||||
class FilteredTextEdit : public QTextEdit
|
class FilteredTextEdit : public QTextEdit
|
||||||
{
|
{
|
||||||
|
@ -80,8 +82,12 @@ protected:
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
bool emoji_popup_open_ = false;
|
||||||
|
CompletionModel *emoji_completion_model_;
|
||||||
std::deque<QString> true_history_, working_history_;
|
std::deque<QString> true_history_, working_history_;
|
||||||
|
int trigger_pos_; // Where emoji completer was triggered
|
||||||
size_t history_index_;
|
size_t history_index_;
|
||||||
|
QCompleter *completer_;
|
||||||
QTimer *typingTimer_;
|
QTimer *typingTimer_;
|
||||||
|
|
||||||
SuggestionsPopup suggestionsPopup_;
|
SuggestionsPopup suggestionsPopup_;
|
||||||
|
@ -103,19 +109,27 @@ private:
|
||||||
{
|
{
|
||||||
return pos == atTriggerPosition_ + anchorWidth(anchor);
|
return pos == atTriggerPosition_ + anchorWidth(anchor);
|
||||||
}
|
}
|
||||||
|
QRect completerRect();
|
||||||
QString query()
|
QString query()
|
||||||
{
|
{
|
||||||
auto cursor = textCursor();
|
auto cursor = textCursor();
|
||||||
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
|
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
|
||||||
return cursor.selectedText();
|
return cursor.selectedText();
|
||||||
}
|
}
|
||||||
|
QString textAfterPosition(int pos)
|
||||||
|
{
|
||||||
|
auto tc = textCursor();
|
||||||
|
tc.setPosition(pos);
|
||||||
|
tc.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||||
|
return tc.selectedText();
|
||||||
|
}
|
||||||
|
|
||||||
dialogs::PreviewUploadOverlay previewDialog_;
|
dialogs::PreviewUploadOverlay previewDialog_;
|
||||||
|
|
||||||
//! Latest position of the '@' character that triggers the username completer.
|
//! Latest position of the '@' character that triggers the username completer.
|
||||||
int atTriggerPosition_ = -1;
|
int atTriggerPosition_ = -1;
|
||||||
|
|
||||||
|
void insertCompletion(QString completion);
|
||||||
void textChanged();
|
void textChanged();
|
||||||
void uploadData(const QByteArray data, const QString &media, const QString &filename);
|
void uploadData(const QByteArray data, const QString &media, const QString &filename);
|
||||||
void afterCompletion(int);
|
void afterCompletion(int);
|
||||||
|
@ -158,6 +172,7 @@ private slots:
|
||||||
signals:
|
signals:
|
||||||
void sendTextMessage(const QString &msg);
|
void sendTextMessage(const QString &msg);
|
||||||
void sendEmoteMessage(QString msg);
|
void sendEmoteMessage(QString msg);
|
||||||
|
void clearRoomTimeline();
|
||||||
void heightChanged(int height);
|
void heightChanged(int height);
|
||||||
|
|
||||||
void uploadMedia(const QSharedPointer<QIODevice> data,
|
void uploadMedia(const QSharedPointer<QIODevice> data,
|
||||||
|
|
|
@ -176,7 +176,7 @@ createAnswer(GstPromise *promise, gpointer webrtc)
|
||||||
g_signal_emit_by_name(webrtc, "create-answer", nullptr, promise);
|
g_signal_emit_by_name(webrtc, "create-answer", nullptr, promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if GST_CHECK_VERSION(1, 17, 0)
|
#if GST_CHECK_VERSION(1, 18, 0)
|
||||||
void
|
void
|
||||||
iceGatheringStateChanged(GstElement *webrtc,
|
iceGatheringStateChanged(GstElement *webrtc,
|
||||||
GParamSpec *pspec G_GNUC_UNUSED,
|
GParamSpec *pspec G_GNUC_UNUSED,
|
||||||
|
@ -223,6 +223,10 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
|
||||||
{
|
{
|
||||||
nhlog::ui()->debug("WebRTC: local candidate: (m-line:{}):{}", mlineIndex, candidate);
|
nhlog::ui()->debug("WebRTC: local candidate: (m-line:{}):{}", mlineIndex, candidate);
|
||||||
|
|
||||||
|
#if GST_CHECK_VERSION(1, 18, 0)
|
||||||
|
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
|
||||||
|
return;
|
||||||
|
#else
|
||||||
if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) {
|
if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) {
|
||||||
emit WebRTCSession::instance().newICECandidate(
|
emit WebRTCSession::instance().newICECandidate(
|
||||||
{"audio", (uint16_t)mlineIndex, candidate});
|
{"audio", (uint16_t)mlineIndex, candidate});
|
||||||
|
@ -232,9 +236,8 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
|
||||||
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
|
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
|
||||||
|
|
||||||
// GStreamer v1.16: webrtcbin's notify::ice-gathering-state triggers
|
// GStreamer v1.16: webrtcbin's notify::ice-gathering-state triggers
|
||||||
// GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.17.
|
// GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.18.
|
||||||
// Use a 100ms timeout in the meantime
|
// Use a 100ms timeout in the meantime
|
||||||
#if !GST_CHECK_VERSION(1, 17, 0)
|
|
||||||
static guint timerid = 0;
|
static guint timerid = 0;
|
||||||
if (timerid)
|
if (timerid)
|
||||||
g_source_remove(timerid);
|
g_source_remove(timerid);
|
||||||
|
@ -282,11 +285,11 @@ linkNewPad(GstElement *decodebin G_GNUC_UNUSED, GstPad *newpad, GstElement *pipe
|
||||||
GstElement *resample = gst_element_factory_make("audioresample", nullptr);
|
GstElement *resample = gst_element_factory_make("audioresample", nullptr);
|
||||||
GstElement *sink = gst_element_factory_make("autoaudiosink", nullptr);
|
GstElement *sink = gst_element_factory_make("autoaudiosink", nullptr);
|
||||||
gst_bin_add_many(GST_BIN(pipe), queue, convert, resample, sink, nullptr);
|
gst_bin_add_many(GST_BIN(pipe), queue, convert, resample, sink, nullptr);
|
||||||
|
gst_element_link_many(queue, convert, resample, sink, nullptr);
|
||||||
gst_element_sync_state_with_parent(queue);
|
gst_element_sync_state_with_parent(queue);
|
||||||
gst_element_sync_state_with_parent(convert);
|
gst_element_sync_state_with_parent(convert);
|
||||||
gst_element_sync_state_with_parent(resample);
|
gst_element_sync_state_with_parent(resample);
|
||||||
gst_element_sync_state_with_parent(sink);
|
gst_element_sync_state_with_parent(sink);
|
||||||
gst_element_link_many(queue, convert, resample, sink, nullptr);
|
|
||||||
queuepad = gst_element_get_static_pad(queue, "sink");
|
queuepad = gst_element_get_static_pad(queue, "sink");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -423,8 +426,12 @@ WebRTCSession::acceptICECandidates(
|
||||||
for (const auto &c : candidates) {
|
for (const auto &c : candidates) {
|
||||||
nhlog::ui()->debug(
|
nhlog::ui()->debug(
|
||||||
"WebRTC: remote candidate: (m-line:{}):{}", c.sdpMLineIndex, c.candidate);
|
"WebRTC: remote candidate: (m-line:{}):{}", c.sdpMLineIndex, c.candidate);
|
||||||
g_signal_emit_by_name(
|
if (!c.candidate.empty()) {
|
||||||
webrtc_, "add-ice-candidate", c.sdpMLineIndex, c.candidate.c_str());
|
g_signal_emit_by_name(webrtc_,
|
||||||
|
"add-ice-candidate",
|
||||||
|
c.sdpMLineIndex,
|
||||||
|
c.candidate.c_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -471,7 +478,7 @@ WebRTCSession::startPipeline(int opusPayloadType)
|
||||||
gst_element_set_state(pipe_, GST_STATE_READY);
|
gst_element_set_state(pipe_, GST_STATE_READY);
|
||||||
g_signal_connect(webrtc_, "pad-added", G_CALLBACK(addDecodeBin), pipe_);
|
g_signal_connect(webrtc_, "pad-added", G_CALLBACK(addDecodeBin), pipe_);
|
||||||
|
|
||||||
#if GST_CHECK_VERSION(1, 17, 0)
|
#if GST_CHECK_VERSION(1, 18, 0)
|
||||||
// capture ICE gathering completion
|
// capture ICE gathering completion
|
||||||
g_signal_connect(
|
g_signal_connect(
|
||||||
webrtc_, "notify::ice-gathering-state", G_CALLBACK(iceGatheringStateChanged), nullptr);
|
webrtc_, "notify::ice-gathering-state", G_CALLBACK(iceGatheringStateChanged), nullptr);
|
||||||
|
|
|
@ -151,7 +151,7 @@ EditModal::applyClicked()
|
||||||
state::Name body;
|
state::Name body;
|
||||||
body.name = newName.toStdString();
|
body.name = newName.toStdString();
|
||||||
|
|
||||||
http::client()->send_state_event<state::Name, EventType::RoomName>(
|
http::client()->send_state_event(
|
||||||
roomId_.toStdString(),
|
roomId_.toStdString(),
|
||||||
body,
|
body,
|
||||||
[proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
[proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
||||||
|
@ -169,7 +169,7 @@ EditModal::applyClicked()
|
||||||
state::Topic body;
|
state::Topic body;
|
||||||
body.topic = newTopic.toStdString();
|
body.topic = newTopic.toStdString();
|
||||||
|
|
||||||
http::client()->send_state_event<state::Topic, EventType::RoomTopic>(
|
http::client()->send_state_event(
|
||||||
roomId_.toStdString(),
|
roomId_.toStdString(),
|
||||||
body,
|
body,
|
||||||
[proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
[proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
||||||
|
@ -694,7 +694,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
|
||||||
startLoadingSpinner();
|
startLoadingSpinner();
|
||||||
resetErrorLabel();
|
resetErrorLabel();
|
||||||
|
|
||||||
http::client()->send_state_event<state::JoinRules, EventType::RoomJoinRules>(
|
http::client()->send_state_event(
|
||||||
room_id,
|
room_id,
|
||||||
join_rule,
|
join_rule,
|
||||||
[this, room_id, guest_access](const mtx::responses::EventId &,
|
[this, room_id, guest_access](const mtx::responses::EventId &,
|
||||||
|
@ -708,7 +708,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
http::client()->send_state_event<state::GuestAccess, EventType::RoomGuestAccess>(
|
http::client()->send_state_event(
|
||||||
room_id,
|
room_id,
|
||||||
guest_access,
|
guest_access,
|
||||||
[this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
[this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
||||||
|
@ -843,7 +843,7 @@ RoomSettings::updateAvatar()
|
||||||
avatar_event.image_info.size = size;
|
avatar_event.image_info.size = size;
|
||||||
avatar_event.url = res.content_uri;
|
avatar_event.url = res.content_uri;
|
||||||
|
|
||||||
http::client()->send_state_event<state::Avatar, EventType::RoomAvatar>(
|
http::client()->send_state_event(
|
||||||
room_id,
|
room_id,
|
||||||
avatar_event,
|
avatar_event,
|
||||||
[content = std::move(content), proxy = std::move(proxy)](
|
[content = std::move(content), proxy = std::move(proxy)](
|
||||||
|
|
37
src/emoji/EmojiSearchModel.h
Normal file
37
src/emoji/EmojiSearchModel.h
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "EmojiModel.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
|
||||||
|
namespace emoji {
|
||||||
|
|
||||||
|
// Map emoji data to searchable data
|
||||||
|
class EmojiSearchModel : public QSortFilterProxyModel
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
EmojiSearchModel(QObject *parent = nullptr)
|
||||||
|
: QSortFilterProxyModel(parent)
|
||||||
|
{
|
||||||
|
setSourceModel(new EmojiModel(this));
|
||||||
|
}
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::UserRole + 1) const override
|
||||||
|
{
|
||||||
|
if (role == Qt::DisplayRole) {
|
||||||
|
auto emoji = QSortFilterProxyModel::data(index, role).toString();
|
||||||
|
return emoji + " :" +
|
||||||
|
toShortcode(data(index, EmojiModel::ShortName).toString()) + ":";
|
||||||
|
}
|
||||||
|
return QSortFilterProxyModel::data(index, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString toShortcode(QString shortname) const
|
||||||
|
{
|
||||||
|
return shortname.replace(" ", "-").replace(":", "-").replace("--", "-").toLower();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
|
@ -173,11 +173,12 @@ main(int argc, char *argv[])
|
||||||
QString lang = QLocale::system().name();
|
QString lang = QLocale::system().name();
|
||||||
|
|
||||||
QTranslator qtTranslator;
|
QTranslator qtTranslator;
|
||||||
qtTranslator.load("qt_" + lang, QLibraryInfo::location(QLibraryInfo::TranslationsPath));
|
qtTranslator.load(
|
||||||
|
QLocale(), "qt", "_", QLibraryInfo::location(QLibraryInfo::TranslationsPath));
|
||||||
app.installTranslator(&qtTranslator);
|
app.installTranslator(&qtTranslator);
|
||||||
|
|
||||||
QTranslator appTranslator;
|
QTranslator appTranslator;
|
||||||
appTranslator.load("nheko_" + lang, ":/translations");
|
appTranslator.load(QLocale(), "nheko", "_", ":/translations");
|
||||||
app.installTranslator(&appTranslator);
|
app.installTranslator(&appTranslator);
|
||||||
|
|
||||||
MainWindow w;
|
MainWindow w;
|
||||||
|
|
570
src/timeline/EventStore.cpp
Normal file
570
src/timeline/EventStore.cpp
Normal file
|
@ -0,0 +1,570 @@
|
||||||
|
#include "EventStore.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
#include "Cache.h"
|
||||||
|
#include "Cache_p.h"
|
||||||
|
#include "EventAccessors.h"
|
||||||
|
#include "Logging.h"
|
||||||
|
#include "MatrixClient.h"
|
||||||
|
#include "Olm.h"
|
||||||
|
|
||||||
|
Q_DECLARE_METATYPE(Reaction)
|
||||||
|
|
||||||
|
QCache<EventStore::IdIndex, mtx::events::collections::TimelineEvents> EventStore::decryptedEvents_{
|
||||||
|
1000};
|
||||||
|
QCache<EventStore::IdIndex, mtx::events::collections::TimelineEvents> EventStore::events_by_id_{
|
||||||
|
1000};
|
||||||
|
QCache<EventStore::Index, mtx::events::collections::TimelineEvents> EventStore::events_{1000};
|
||||||
|
|
||||||
|
EventStore::EventStore(std::string room_id, QObject *)
|
||||||
|
: room_id_(std::move(room_id))
|
||||||
|
{
|
||||||
|
static auto reactionType = qRegisterMetaType<Reaction>();
|
||||||
|
(void)reactionType;
|
||||||
|
|
||||||
|
auto range = cache::client()->getTimelineRange(room_id_);
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
this->first = range->first;
|
||||||
|
this->last = range->last;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(
|
||||||
|
this,
|
||||||
|
&EventStore::eventFetched,
|
||||||
|
this,
|
||||||
|
[this](std::string id,
|
||||||
|
std::string relatedTo,
|
||||||
|
mtx::events::collections::TimelineEvents timeline) {
|
||||||
|
cache::client()->storeEvent(room_id_, id, {timeline});
|
||||||
|
|
||||||
|
if (!relatedTo.empty()) {
|
||||||
|
auto idx = idToIndex(relatedTo);
|
||||||
|
if (idx)
|
||||||
|
emit dataChanged(*idx, *idx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
connect(
|
||||||
|
this,
|
||||||
|
&EventStore::oldMessagesRetrieved,
|
||||||
|
this,
|
||||||
|
[this](const mtx::responses::Messages &res) {
|
||||||
|
//
|
||||||
|
uint64_t newFirst = cache::client()->saveOldMessages(room_id_, res);
|
||||||
|
if (newFirst == first && !res.chunk.empty())
|
||||||
|
fetchMore();
|
||||||
|
else {
|
||||||
|
emit beginInsertRows(toExternalIdx(newFirst),
|
||||||
|
toExternalIdx(this->first - 1));
|
||||||
|
this->first = newFirst;
|
||||||
|
emit endInsertRows();
|
||||||
|
emit fetchedMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
connect(this, &EventStore::processPending, this, [this]() {
|
||||||
|
if (!current_txn.empty()) {
|
||||||
|
nhlog::ui()->debug("Already processing {}", current_txn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto event = cache::client()->firstPendingMessage(room_id_);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
nhlog::ui()->debug("No event to send");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::visit(
|
||||||
|
[this](auto e) {
|
||||||
|
auto txn_id = e.event_id;
|
||||||
|
this->current_txn = txn_id;
|
||||||
|
|
||||||
|
if (txn_id.empty() || txn_id[0] != 'm') {
|
||||||
|
nhlog::ui()->debug("Invalid txn id '{}'", txn_id);
|
||||||
|
cache::client()->removePendingStatus(room_id_, txn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if constexpr (mtx::events::message_content_to_type<decltype(e.content)> !=
|
||||||
|
mtx::events::EventType::Unsupported)
|
||||||
|
http::client()->send_room_message(
|
||||||
|
room_id_,
|
||||||
|
txn_id,
|
||||||
|
e.content,
|
||||||
|
[this, txn_id](const mtx::responses::EventId &event_id,
|
||||||
|
mtx::http::RequestErr err) {
|
||||||
|
if (err) {
|
||||||
|
const int status_code =
|
||||||
|
static_cast<int>(err->status_code);
|
||||||
|
nhlog::net()->warn(
|
||||||
|
"[{}] failed to send message: {} {}",
|
||||||
|
txn_id,
|
||||||
|
err->matrix_error.error,
|
||||||
|
status_code);
|
||||||
|
emit messageFailed(txn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit messageSent(txn_id, event_id.event_id.to_string());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
event->data);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(
|
||||||
|
this,
|
||||||
|
&EventStore::messageFailed,
|
||||||
|
this,
|
||||||
|
[this](std::string txn_id) {
|
||||||
|
if (current_txn == txn_id) {
|
||||||
|
current_txn_error_count++;
|
||||||
|
if (current_txn_error_count > 10) {
|
||||||
|
nhlog::ui()->debug("failing txn id '{}'", txn_id);
|
||||||
|
cache::client()->removePendingStatus(room_id_, txn_id);
|
||||||
|
current_txn_error_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QTimer::singleShot(1000, this, [this]() {
|
||||||
|
nhlog::ui()->debug("timeout");
|
||||||
|
this->current_txn = "";
|
||||||
|
emit processPending();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
connect(
|
||||||
|
this,
|
||||||
|
&EventStore::messageSent,
|
||||||
|
this,
|
||||||
|
[this](std::string txn_id, std::string event_id) {
|
||||||
|
nhlog::ui()->debug("sent {}", txn_id);
|
||||||
|
|
||||||
|
http::client()->read_event(
|
||||||
|
room_id_, event_id, [this, event_id](mtx::http::RequestErr err) {
|
||||||
|
if (err) {
|
||||||
|
nhlog::net()->warn(
|
||||||
|
"failed to read_event ({}, {})", room_id_, event_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cache::client()->removePendingStatus(room_id_, txn_id);
|
||||||
|
this->current_txn = "";
|
||||||
|
this->current_txn_error_count = 0;
|
||||||
|
emit processPending();
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
EventStore::addPending(mtx::events::collections::TimelineEvents event)
|
||||||
|
{
|
||||||
|
if (this->thread() != QThread::currentThread())
|
||||||
|
nhlog::db()->warn("{} called from a different thread!", __func__);
|
||||||
|
|
||||||
|
cache::client()->savePendingMessage(this->room_id_, {event});
|
||||||
|
mtx::responses::Timeline events;
|
||||||
|
events.limited = false;
|
||||||
|
events.events.emplace_back(event);
|
||||||
|
handleSync(events);
|
||||||
|
|
||||||
|
emit processPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
EventStore::clearTimeline()
|
||||||
|
{
|
||||||
|
emit beginResetModel();
|
||||||
|
|
||||||
|
cache::client()->clearTimeline(room_id_);
|
||||||
|
auto range = cache::client()->getTimelineRange(room_id_);
|
||||||
|
if (range) {
|
||||||
|
nhlog::db()->info("Range {} {}", range->last, range->first);
|
||||||
|
this->last = range->last;
|
||||||
|
this->first = range->first;
|
||||||
|
} else {
|
||||||
|
this->first = std::numeric_limits<uint64_t>::max();
|
||||||
|
this->last = std::numeric_limits<uint64_t>::max();
|
||||||
|
}
|
||||||
|
nhlog::ui()->info("Range {} {}", this->last, this->first);
|
||||||
|
|
||||||
|
emit endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
EventStore::handleSync(const mtx::responses::Timeline &events)
|
||||||
|
{
|
||||||
|
if (this->thread() != QThread::currentThread())
|
||||||
|
nhlog::db()->warn("{} called from a different thread!", __func__);
|
||||||
|
|
||||||
|
auto range = cache::client()->getTimelineRange(room_id_);
|
||||||
|
if (!range)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (events.limited) {
|
||||||
|
emit beginResetModel();
|
||||||
|
this->last = range->last;
|
||||||
|
this->first = range->first;
|
||||||
|
emit endResetModel();
|
||||||
|
|
||||||
|
} else if (range->last > this->last) {
|
||||||
|
emit beginInsertRows(toExternalIdx(this->last + 1), toExternalIdx(range->last));
|
||||||
|
this->last = range->last;
|
||||||
|
emit endInsertRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &event : events.events) {
|
||||||
|
std::string relates_to;
|
||||||
|
if (auto redaction =
|
||||||
|
std::get_if<mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(
|
||||||
|
&event)) {
|
||||||
|
// fixup reactions
|
||||||
|
auto redacted = events_by_id_.object({room_id_, redaction->redacts});
|
||||||
|
if (redacted) {
|
||||||
|
auto id = mtx::accessors::relates_to_event_id(*redacted);
|
||||||
|
if (!id.empty()) {
|
||||||
|
auto idx = idToIndex(id);
|
||||||
|
if (idx) {
|
||||||
|
events_by_id_.remove(
|
||||||
|
{room_id_, redaction->redacts});
|
||||||
|
events_.remove({room_id_, toInternalIdx(*idx)});
|
||||||
|
emit dataChanged(*idx, *idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relates_to = redaction->redacts;
|
||||||
|
} else if (auto reaction =
|
||||||
|
std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(
|
||||||
|
&event)) {
|
||||||
|
relates_to = reaction->content.relates_to.event_id;
|
||||||
|
} else {
|
||||||
|
relates_to = mtx::accessors::in_reply_to_event(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relates_to.empty()) {
|
||||||
|
auto idx = cache::client()->getTimelineIndex(room_id_, relates_to);
|
||||||
|
if (idx) {
|
||||||
|
events_by_id_.remove({room_id_, relates_to});
|
||||||
|
decryptedEvents_.remove({room_id_, relates_to});
|
||||||
|
events_.remove({room_id_, *idx});
|
||||||
|
emit dataChanged(toExternalIdx(*idx), toExternalIdx(*idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto txn_id = mtx::accessors::transaction_id(event); !txn_id.empty()) {
|
||||||
|
auto idx = cache::client()->getTimelineIndex(
|
||||||
|
room_id_, mtx::accessors::event_id(event));
|
||||||
|
if (idx) {
|
||||||
|
Index index{room_id_, *idx};
|
||||||
|
events_.remove(index);
|
||||||
|
emit dataChanged(toExternalIdx(*idx), toExternalIdx(*idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList
|
||||||
|
EventStore::reactions(const std::string &event_id)
|
||||||
|
{
|
||||||
|
auto event_ids = cache::client()->relatedEvents(room_id_, event_id);
|
||||||
|
|
||||||
|
struct TempReaction
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
std::vector<std::string> users;
|
||||||
|
std::string reactedBySelf;
|
||||||
|
};
|
||||||
|
std::map<std::string, TempReaction> aggregation;
|
||||||
|
std::vector<Reaction> reactions;
|
||||||
|
|
||||||
|
auto self = http::client()->user_id().to_string();
|
||||||
|
for (const auto &id : event_ids) {
|
||||||
|
auto related_event = get(id, event_id);
|
||||||
|
if (!related_event)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (auto reaction = std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(
|
||||||
|
related_event)) {
|
||||||
|
auto &agg = aggregation[reaction->content.relates_to.key];
|
||||||
|
|
||||||
|
if (agg.count == 0) {
|
||||||
|
Reaction temp{};
|
||||||
|
temp.key_ =
|
||||||
|
QString::fromStdString(reaction->content.relates_to.key);
|
||||||
|
reactions.push_back(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
agg.count++;
|
||||||
|
agg.users.push_back(cache::displayName(room_id_, reaction->sender));
|
||||||
|
if (reaction->sender == self)
|
||||||
|
agg.reactedBySelf = reaction->event_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList temp;
|
||||||
|
for (auto &reaction : reactions) {
|
||||||
|
const auto &agg = aggregation[reaction.key_.toStdString()];
|
||||||
|
reaction.count_ = agg.count;
|
||||||
|
reaction.selfReactedEvent_ = QString::fromStdString(agg.reactedBySelf);
|
||||||
|
|
||||||
|
bool firstReaction = true;
|
||||||
|
for (const auto &user : agg.users) {
|
||||||
|
if (firstReaction)
|
||||||
|
firstReaction = false;
|
||||||
|
else
|
||||||
|
reaction.users_ += ", ";
|
||||||
|
|
||||||
|
reaction.users_ += QString::fromStdString(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
nhlog::db()->debug("key: {}, count: {}, users: {}",
|
||||||
|
reaction.key_.toStdString(),
|
||||||
|
reaction.count_,
|
||||||
|
reaction.users_.toStdString());
|
||||||
|
temp.append(QVariant::fromValue(reaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
return temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
mtx::events::collections::TimelineEvents *
|
||||||
|
EventStore::get(int idx, bool decrypt)
|
||||||
|
{
|
||||||
|
if (this->thread() != QThread::currentThread())
|
||||||
|
nhlog::db()->warn("{} called from a different thread!", __func__);
|
||||||
|
|
||||||
|
Index index{room_id_, toInternalIdx(idx)};
|
||||||
|
if (index.idx > last || index.idx < first)
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
auto event_ptr = events_.object(index);
|
||||||
|
if (!event_ptr) {
|
||||||
|
auto event_id = cache::client()->getTimelineEventId(room_id_, index.idx);
|
||||||
|
if (!event_id)
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
auto event = cache::client()->getEvent(room_id_, *event_id);
|
||||||
|
if (!event)
|
||||||
|
return nullptr;
|
||||||
|
else
|
||||||
|
event_ptr =
|
||||||
|
new mtx::events::collections::TimelineEvents(std::move(event->data));
|
||||||
|
events_.insert(index, event_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decrypt)
|
||||||
|
if (auto encrypted =
|
||||||
|
std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
|
||||||
|
event_ptr))
|
||||||
|
return decryptEvent({room_id_, encrypted->event_id}, *encrypted);
|
||||||
|
|
||||||
|
return event_ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int>
|
||||||
|
EventStore::idToIndex(std::string_view id) const
|
||||||
|
{
|
||||||
|
if (this->thread() != QThread::currentThread())
|
||||||
|
nhlog::db()->warn("{} called from a different thread!", __func__);
|
||||||
|
|
||||||
|
auto idx = cache::client()->getTimelineIndex(room_id_, id);
|
||||||
|
if (idx)
|
||||||
|
return toExternalIdx(*idx);
|
||||||
|
else
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::optional<std::string>
|
||||||
|
EventStore::indexToId(int idx) const
|
||||||
|
{
|
||||||
|
if (this->thread() != QThread::currentThread())
|
||||||
|
nhlog::db()->warn("{} called from a different thread!", __func__);
|
||||||
|
|
||||||
|
return cache::client()->getTimelineEventId(room_id_, toInternalIdx(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
mtx::events::collections::TimelineEvents *
|
||||||
|
EventStore::decryptEvent(const IdIndex &idx,
|
||||||
|
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e)
|
||||||
|
{
|
||||||
|
if (auto cachedEvent = decryptedEvents_.object(idx))
|
||||||
|
return cachedEvent;
|
||||||
|
|
||||||
|
MegolmSessionIndex index;
|
||||||
|
index.room_id = room_id_;
|
||||||
|
index.session_id = e.content.session_id;
|
||||||
|
index.sender_key = e.content.sender_key;
|
||||||
|
|
||||||
|
auto asCacheEntry = [&idx](mtx::events::collections::TimelineEvents &&event) {
|
||||||
|
auto event_ptr = new mtx::events::collections::TimelineEvents(std::move(event));
|
||||||
|
decryptedEvents_.insert(idx, event_ptr);
|
||||||
|
return event_ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto decryptionResult = olm::decryptEvent(index, e);
|
||||||
|
|
||||||
|
if (decryptionResult.error) {
|
||||||
|
mtx::events::RoomEvent<mtx::events::msg::Notice> dummy;
|
||||||
|
dummy.origin_server_ts = e.origin_server_ts;
|
||||||
|
dummy.event_id = e.event_id;
|
||||||
|
dummy.sender = e.sender;
|
||||||
|
switch (*decryptionResult.error) {
|
||||||
|
case olm::DecryptionErrorCode::MissingSession:
|
||||||
|
dummy.content.body =
|
||||||
|
tr("-- Encrypted Event (No keys found for decryption) --",
|
||||||
|
"Placeholder, when the message was not decrypted yet or can't be "
|
||||||
|
"decrypted.")
|
||||||
|
.toStdString();
|
||||||
|
nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})",
|
||||||
|
index.room_id,
|
||||||
|
index.session_id,
|
||||||
|
e.sender);
|
||||||
|
// TODO: Check if this actually works and look in key backup
|
||||||
|
olm::send_key_request_for(room_id_, e);
|
||||||
|
break;
|
||||||
|
case olm::DecryptionErrorCode::DbError:
|
||||||
|
nhlog::db()->critical(
|
||||||
|
"failed to retrieve megolm session with index ({}, {}, {})",
|
||||||
|
index.room_id,
|
||||||
|
index.session_id,
|
||||||
|
index.sender_key,
|
||||||
|
decryptionResult.error_message.value_or(""));
|
||||||
|
dummy.content.body =
|
||||||
|
tr("-- Decryption Error (failed to retrieve megolm keys from db) --",
|
||||||
|
"Placeholder, when the message can't be decrypted, because the DB "
|
||||||
|
"access "
|
||||||
|
"failed.")
|
||||||
|
.toStdString();
|
||||||
|
break;
|
||||||
|
case olm::DecryptionErrorCode::DecryptionFailed:
|
||||||
|
nhlog::crypto()->critical(
|
||||||
|
"failed to decrypt message with index ({}, {}, {}): {}",
|
||||||
|
index.room_id,
|
||||||
|
index.session_id,
|
||||||
|
index.sender_key,
|
||||||
|
decryptionResult.error_message.value_or(""));
|
||||||
|
dummy.content.body =
|
||||||
|
tr("-- Decryption Error (%1) --",
|
||||||
|
"Placeholder, when the message can't be decrypted. In this case, the "
|
||||||
|
"Olm "
|
||||||
|
"decrytion returned an error, which is passed as %1.")
|
||||||
|
.arg(
|
||||||
|
QString::fromStdString(decryptionResult.error_message.value_or("")))
|
||||||
|
.toStdString();
|
||||||
|
break;
|
||||||
|
case olm::DecryptionErrorCode::ParsingFailed:
|
||||||
|
dummy.content.body =
|
||||||
|
tr("-- Encrypted Event (Unknown event type) --",
|
||||||
|
"Placeholder, when the message was decrypted, but we couldn't parse "
|
||||||
|
"it, because "
|
||||||
|
"Nheko/mtxclient don't support that event type yet.")
|
||||||
|
.toStdString();
|
||||||
|
break;
|
||||||
|
case olm::DecryptionErrorCode::ReplayAttack:
|
||||||
|
nhlog::crypto()->critical(
|
||||||
|
"Reply attack while decryptiong event {} in room {} from {}!",
|
||||||
|
e.event_id,
|
||||||
|
room_id_,
|
||||||
|
index.sender_key);
|
||||||
|
dummy.content.body =
|
||||||
|
tr("-- Reply attack! This message index was reused! --").toStdString();
|
||||||
|
break;
|
||||||
|
case olm::DecryptionErrorCode::UnknownFingerprint:
|
||||||
|
// TODO: don't fail, just show in UI.
|
||||||
|
nhlog::crypto()->critical("Message by unverified fingerprint {}",
|
||||||
|
index.sender_key);
|
||||||
|
dummy.content.body =
|
||||||
|
tr("-- Message by unverified device! --").toStdString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return asCacheEntry(std::move(dummy));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto encInfo = mtx::accessors::file(decryptionResult.event.value());
|
||||||
|
if (encInfo)
|
||||||
|
emit newEncryptedImage(encInfo.value());
|
||||||
|
|
||||||
|
return asCacheEntry(std::move(decryptionResult.event.value()));
|
||||||
|
}
|
||||||
|
|
||||||
|
mtx::events::collections::TimelineEvents *
|
||||||
|
EventStore::get(std::string_view id, std::string_view related_to, bool decrypt)
|
||||||
|
{
|
||||||
|
if (this->thread() != QThread::currentThread())
|
||||||
|
nhlog::db()->warn("{} called from a different thread!", __func__);
|
||||||
|
|
||||||
|
if (id.empty())
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
IdIndex index{room_id_, std::string(id.data(), id.size())};
|
||||||
|
|
||||||
|
auto event_ptr = events_by_id_.object(index);
|
||||||
|
if (!event_ptr) {
|
||||||
|
auto event = cache::client()->getEvent(room_id_, index.id);
|
||||||
|
if (!event) {
|
||||||
|
http::client()->get_event(
|
||||||
|
room_id_,
|
||||||
|
index.id,
|
||||||
|
[this,
|
||||||
|
relatedTo = std::string(related_to.data(), related_to.size()),
|
||||||
|
id = index.id](const mtx::events::collections::TimelineEvents &timeline,
|
||||||
|
mtx::http::RequestErr err) {
|
||||||
|
if (err) {
|
||||||
|
nhlog::net()->error(
|
||||||
|
"Failed to retrieve event with id {}, which was "
|
||||||
|
"requested to show the replyTo for event {}",
|
||||||
|
relatedTo,
|
||||||
|
id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit eventFetched(id, relatedTo, timeline);
|
||||||
|
});
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
event_ptr = new mtx::events::collections::TimelineEvents(std::move(event->data));
|
||||||
|
events_by_id_.insert(index, event_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decrypt)
|
||||||
|
if (auto encrypted =
|
||||||
|
std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
|
||||||
|
event_ptr))
|
||||||
|
return decryptEvent(index, *encrypted);
|
||||||
|
|
||||||
|
return event_ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
EventStore::fetchMore()
|
||||||
|
{
|
||||||
|
mtx::http::MessagesOpts opts;
|
||||||
|
opts.room_id = room_id_;
|
||||||
|
opts.from = cache::client()->previousBatchToken(room_id_);
|
||||||
|
|
||||||
|
nhlog::ui()->debug("Paginating room {}, token {}", opts.room_id, opts.from);
|
||||||
|
|
||||||
|
http::client()->messages(
|
||||||
|
opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) {
|
||||||
|
if (cache::client()->previousBatchToken(room_id_) != opts.from) {
|
||||||
|
nhlog::net()->warn("Cache cleared while fetching more messages, dropping "
|
||||||
|
"/messages response");
|
||||||
|
emit fetchedMore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
nhlog::net()->error("failed to call /messages ({}): {} - {} - {}",
|
||||||
|
opts.room_id,
|
||||||
|
mtx::errors::to_string(err->matrix_error.errcode),
|
||||||
|
err->matrix_error.error,
|
||||||
|
err->parse_error);
|
||||||
|
emit fetchedMore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit oldMessagesRetrieved(std::move(res));
|
||||||
|
});
|
||||||
|
}
|
122
src/timeline/EventStore.h
Normal file
122
src/timeline/EventStore.h
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <QCache>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <qhashfunctions.h>
|
||||||
|
|
||||||
|
#include <mtx/events/collections.hpp>
|
||||||
|
#include <mtx/responses/messages.hpp>
|
||||||
|
#include <mtx/responses/sync.hpp>
|
||||||
|
|
||||||
|
#include "Reaction.h"
|
||||||
|
|
||||||
|
class EventStore : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
EventStore(std::string room_id, QObject *parent);
|
||||||
|
|
||||||
|
struct Index
|
||||||
|
{
|
||||||
|
std::string room;
|
||||||
|
uint64_t idx;
|
||||||
|
|
||||||
|
friend uint qHash(const Index &i, uint seed = 0) noexcept
|
||||||
|
{
|
||||||
|
QtPrivate::QHashCombine hash;
|
||||||
|
seed = hash(seed, QByteArray::fromRawData(i.room.data(), i.room.size()));
|
||||||
|
seed = hash(seed, i.idx);
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
friend bool operator==(const Index &a, const Index &b) noexcept
|
||||||
|
{
|
||||||
|
return a.idx == b.idx && a.room == b.room;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
struct IdIndex
|
||||||
|
{
|
||||||
|
std::string room, id;
|
||||||
|
|
||||||
|
friend uint qHash(const IdIndex &i, uint seed = 0) noexcept
|
||||||
|
{
|
||||||
|
QtPrivate::QHashCombine hash;
|
||||||
|
seed = hash(seed, QByteArray::fromRawData(i.room.data(), i.room.size()));
|
||||||
|
seed = hash(seed, QByteArray::fromRawData(i.id.data(), i.id.size()));
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
friend bool operator==(const IdIndex &a, const IdIndex &b) noexcept
|
||||||
|
{
|
||||||
|
return a.id == b.id && a.room == b.room;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchMore();
|
||||||
|
void handleSync(const mtx::responses::Timeline &events);
|
||||||
|
|
||||||
|
// optionally returns the event or nullptr and fetches it, after which it emits a
|
||||||
|
// relatedFetched event
|
||||||
|
mtx::events::collections::TimelineEvents *get(std::string_view id,
|
||||||
|
std::string_view related_to,
|
||||||
|
bool decrypt = true);
|
||||||
|
// always returns a proper event as long as the idx is valid
|
||||||
|
mtx::events::collections::TimelineEvents *get(int idx, bool decrypt = true);
|
||||||
|
|
||||||
|
QVariantList reactions(const std::string &event_id);
|
||||||
|
|
||||||
|
int size() const
|
||||||
|
{
|
||||||
|
return last != std::numeric_limits<uint64_t>::max()
|
||||||
|
? static_cast<int>(last - first) + 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
int toExternalIdx(uint64_t idx) const { return static_cast<int>(idx - first); }
|
||||||
|
uint64_t toInternalIdx(int idx) const { return first + idx; }
|
||||||
|
|
||||||
|
std::optional<int> idToIndex(std::string_view id) const;
|
||||||
|
std::optional<std::string> indexToId(int idx) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void beginInsertRows(int from, int to);
|
||||||
|
void endInsertRows();
|
||||||
|
void beginResetModel();
|
||||||
|
void endResetModel();
|
||||||
|
void dataChanged(int from, int to);
|
||||||
|
void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
|
||||||
|
void eventFetched(std::string id,
|
||||||
|
std::string relatedTo,
|
||||||
|
mtx::events::collections::TimelineEvents timeline);
|
||||||
|
void oldMessagesRetrieved(const mtx::responses::Messages &);
|
||||||
|
void fetchedMore();
|
||||||
|
|
||||||
|
void processPending();
|
||||||
|
void messageSent(std::string txn_id, std::string event_id);
|
||||||
|
void messageFailed(std::string txn_id);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void addPending(mtx::events::collections::TimelineEvents event);
|
||||||
|
void clearTimeline();
|
||||||
|
|
||||||
|
private:
|
||||||
|
mtx::events::collections::TimelineEvents *decryptEvent(
|
||||||
|
const IdIndex &idx,
|
||||||
|
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e);
|
||||||
|
|
||||||
|
std::string room_id_;
|
||||||
|
|
||||||
|
uint64_t first = std::numeric_limits<uint64_t>::max(),
|
||||||
|
last = std::numeric_limits<uint64_t>::max();
|
||||||
|
|
||||||
|
static QCache<IdIndex, mtx::events::collections::TimelineEvents> decryptedEvents_;
|
||||||
|
static QCache<Index, mtx::events::collections::TimelineEvents> events_;
|
||||||
|
static QCache<IdIndex, mtx::events::collections::TimelineEvents> events_by_id_;
|
||||||
|
|
||||||
|
std::string current_txn;
|
||||||
|
int current_txn_error_count = 0;
|
||||||
|
};
|
1
src/timeline/Reaction.cpp
Normal file
1
src/timeline/Reaction.cpp
Normal file
|
@ -0,0 +1 @@
|
||||||
|
#include "Reaction.h"
|
24
src/timeline/Reaction.h
Normal file
24
src/timeline/Reaction.h
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
struct Reaction
|
||||||
|
{
|
||||||
|
Q_GADGET
|
||||||
|
Q_PROPERTY(QString key READ key)
|
||||||
|
Q_PROPERTY(QString users READ users)
|
||||||
|
Q_PROPERTY(QString selfReactedEvent READ selfReactedEvent)
|
||||||
|
Q_PROPERTY(int count READ count)
|
||||||
|
|
||||||
|
public:
|
||||||
|
QString key() const { return key_; }
|
||||||
|
QString users() const { return users_; }
|
||||||
|
QString selfReactedEvent() const { return selfReactedEvent_; }
|
||||||
|
int count() const { return count_; }
|
||||||
|
|
||||||
|
QString key_;
|
||||||
|
QString users_;
|
||||||
|
QString selfReactedEvent_;
|
||||||
|
int count_;
|
||||||
|
};
|
|
@ -1,98 +0,0 @@
|
||||||
#include "ReactionsModel.h"
|
|
||||||
|
|
||||||
#include <Cache.h>
|
|
||||||
#include <MatrixClient.h>
|
|
||||||
|
|
||||||
QHash<int, QByteArray>
|
|
||||||
ReactionsModel::roleNames() const
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
{Key, "key"},
|
|
||||||
{Count, "counter"},
|
|
||||||
{Users, "users"},
|
|
||||||
{SelfReactedEvent, "selfReactedEvent"},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
int
|
|
||||||
ReactionsModel::rowCount(const QModelIndex &) const
|
|
||||||
{
|
|
||||||
return static_cast<int>(reactions.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
QVariant
|
|
||||||
ReactionsModel::data(const QModelIndex &index, int role) const
|
|
||||||
{
|
|
||||||
const int i = index.row();
|
|
||||||
if (i < 0 || i >= static_cast<int>(reactions.size()))
|
|
||||||
return {};
|
|
||||||
|
|
||||||
switch (role) {
|
|
||||||
case Key:
|
|
||||||
return QString::fromStdString(reactions[i].key);
|
|
||||||
case Count:
|
|
||||||
return static_cast<int>(reactions[i].reactions.size());
|
|
||||||
case Users: {
|
|
||||||
QString users;
|
|
||||||
bool first = true;
|
|
||||||
for (const auto &reaction : reactions[i].reactions) {
|
|
||||||
if (!first)
|
|
||||||
users += ", ";
|
|
||||||
else
|
|
||||||
first = false;
|
|
||||||
users += QString::fromStdString(
|
|
||||||
cache::displayName(room_id_, reaction.second.sender));
|
|
||||||
}
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
case SelfReactedEvent:
|
|
||||||
for (const auto &reaction : reactions[i].reactions)
|
|
||||||
if (reaction.second.sender == http::client()->user_id().to_string())
|
|
||||||
return QString::fromStdString(reaction.second.event_id);
|
|
||||||
return QStringLiteral("");
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void
|
|
||||||
ReactionsModel::addReaction(const std::string &room_id,
|
|
||||||
const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction)
|
|
||||||
{
|
|
||||||
room_id_ = room_id;
|
|
||||||
|
|
||||||
int idx = 0;
|
|
||||||
for (auto &storedReactions : reactions) {
|
|
||||||
if (storedReactions.key == reaction.content.relates_to.key) {
|
|
||||||
storedReactions.reactions[reaction.event_id] = reaction;
|
|
||||||
emit dataChanged(index(idx, 0), index(idx, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
beginInsertRows(QModelIndex(), idx, idx);
|
|
||||||
reactions.push_back(
|
|
||||||
KeyReaction{reaction.content.relates_to.key, {{reaction.event_id, reaction}}});
|
|
||||||
endInsertRows();
|
|
||||||
}
|
|
||||||
|
|
||||||
void
|
|
||||||
ReactionsModel::removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction)
|
|
||||||
{
|
|
||||||
int idx = 0;
|
|
||||||
for (auto &storedReactions : reactions) {
|
|
||||||
if (storedReactions.key == reaction.content.relates_to.key) {
|
|
||||||
storedReactions.reactions.erase(reaction.event_id);
|
|
||||||
|
|
||||||
if (storedReactions.reactions.size() == 0) {
|
|
||||||
beginRemoveRows(QModelIndex(), idx, idx);
|
|
||||||
reactions.erase(reactions.begin() + idx);
|
|
||||||
endRemoveRows();
|
|
||||||
} else
|
|
||||||
emit dataChanged(index(idx, 0), index(idx, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QAbstractListModel>
|
|
||||||
#include <QHash>
|
|
||||||
|
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <mtx/events/collections.hpp>
|
|
||||||
|
|
||||||
class ReactionsModel : public QAbstractListModel
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
public:
|
|
||||||
explicit ReactionsModel(QObject *parent = nullptr) { Q_UNUSED(parent); }
|
|
||||||
enum Roles
|
|
||||||
{
|
|
||||||
Key,
|
|
||||||
Count,
|
|
||||||
Users,
|
|
||||||
SelfReactedEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
QHash<int, QByteArray> roleNames() const override;
|
|
||||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
|
||||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
|
||||||
|
|
||||||
public slots:
|
|
||||||
void addReaction(const std::string &room_id,
|
|
||||||
const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction);
|
|
||||||
void removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction);
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct KeyReaction
|
|
||||||
{
|
|
||||||
std::string key;
|
|
||||||
std::map<std::string, mtx::events::RoomEvent<mtx::events::msg::Reaction>> reactions;
|
|
||||||
};
|
|
||||||
std::string room_id_;
|
|
||||||
std::vector<KeyReaction> reactions;
|
|
||||||
};
|
|
File diff suppressed because it is too large
Load diff
|
@ -9,7 +9,7 @@
|
||||||
#include <mtxclient/http/errors.hpp>
|
#include <mtxclient/http/errors.hpp>
|
||||||
|
|
||||||
#include "CacheCryptoStructs.h"
|
#include "CacheCryptoStructs.h"
|
||||||
#include "ReactionsModel.h"
|
#include "EventStore.h"
|
||||||
|
|
||||||
namespace mtx::http {
|
namespace mtx::http {
|
||||||
using RequestErr = const std::optional<mtx::http::ClientError> &;
|
using RequestErr = const std::optional<mtx::http::ClientError> &;
|
||||||
|
@ -42,6 +42,8 @@ enum EventType
|
||||||
CallAnswer,
|
CallAnswer,
|
||||||
/// m.call.hangup
|
/// m.call.hangup
|
||||||
CallHangUp,
|
CallHangUp,
|
||||||
|
/// m.call.candidates
|
||||||
|
CallCandidates,
|
||||||
/// m.room.canonical_alias
|
/// m.room.canonical_alias
|
||||||
CanonicalAlias,
|
CanonicalAlias,
|
||||||
/// m.room.create
|
/// m.room.create
|
||||||
|
@ -177,7 +179,7 @@ public:
|
||||||
QHash<int, QByteArray> roleNames() const override;
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
QVariant data(const QString &id, int role) const;
|
QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const;
|
||||||
|
|
||||||
bool canFetchMore(const QModelIndex &) const override;
|
bool canFetchMore(const QModelIndex &) const override;
|
||||||
void fetchMore(const QModelIndex &) override;
|
void fetchMore(const QModelIndex &) override;
|
||||||
|
@ -204,6 +206,15 @@ public:
|
||||||
Q_INVOKABLE void cacheMedia(QString eventId);
|
Q_INVOKABLE void cacheMedia(QString eventId);
|
||||||
Q_INVOKABLE bool saveMedia(QString eventId) const;
|
Q_INVOKABLE bool saveMedia(QString eventId) const;
|
||||||
|
|
||||||
|
std::vector<::Reaction> reactions(const std::string &event_id)
|
||||||
|
{
|
||||||
|
auto list = events.reactions(event_id);
|
||||||
|
std::vector<::Reaction> vec;
|
||||||
|
for (const auto &r : list)
|
||||||
|
vec.push_back(r.value<Reaction>());
|
||||||
|
return vec;
|
||||||
|
}
|
||||||
|
|
||||||
void updateLastMessage();
|
void updateLastMessage();
|
||||||
void addEvents(const mtx::responses::Timeline &events);
|
void addEvents(const mtx::responses::Timeline &events);
|
||||||
template<class T>
|
template<class T>
|
||||||
|
@ -214,7 +225,7 @@ public slots:
|
||||||
void setCurrentIndex(int index);
|
void setCurrentIndex(int index);
|
||||||
int currentIndex() const { return idToIndex(currentId); }
|
int currentIndex() const { return idToIndex(currentId); }
|
||||||
void markEventsAsRead(const std::vector<QString> &event_ids);
|
void markEventsAsRead(const std::vector<QString> &event_ids);
|
||||||
QVariantMap getDump(QString eventId) const;
|
QVariantMap getDump(QString eventId, QString relatedTo) const;
|
||||||
void updateTypingUsers(const std::vector<QString> &users)
|
void updateTypingUsers(const std::vector<QString> &users)
|
||||||
{
|
{
|
||||||
if (this->typingUsers_ != users) {
|
if (this->typingUsers_ != users) {
|
||||||
|
@ -240,36 +251,26 @@ public slots:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
|
void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
|
||||||
|
void clearTimeline() { events.clearTimeline(); }
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
// Add old events at the top of the timeline.
|
|
||||||
void addBackwardsEvents(const mtx::responses::Messages &msgs);
|
|
||||||
void processOnePendingMessage();
|
|
||||||
void addPendingMessage(mtx::events::collections::TimelineEvents event);
|
void addPendingMessage(mtx::events::collections::TimelineEvents event);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void oldMessagesRetrieved(const mtx::responses::Messages &res);
|
|
||||||
void messageFailed(QString txn_id);
|
|
||||||
void messageSent(QString txn_id, QString event_id);
|
|
||||||
void currentIndexChanged(int index);
|
void currentIndexChanged(int index);
|
||||||
void redactionFailed(QString id);
|
void redactionFailed(QString id);
|
||||||
void eventRedacted(QString id);
|
void eventRedacted(QString id);
|
||||||
void nextPendingMessage();
|
|
||||||
void newMessageToSend(mtx::events::collections::TimelineEvents event);
|
|
||||||
void mediaCached(QString mxcUrl, QString cacheUrl);
|
void mediaCached(QString mxcUrl, QString cacheUrl);
|
||||||
void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
|
void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
|
||||||
void eventFetched(QString requestingEvent, mtx::events::collections::TimelineEvents event);
|
|
||||||
void typingUsersChanged(std::vector<QString> users);
|
void typingUsersChanged(std::vector<QString> users);
|
||||||
void replyChanged(QString reply);
|
void replyChanged(QString reply);
|
||||||
void paginationInProgressChanged(const bool);
|
void paginationInProgressChanged(const bool);
|
||||||
void newCallEvent(const mtx::events::collections::TimelineEvents &event);
|
void newCallEvent(const mtx::events::collections::TimelineEvents &event);
|
||||||
|
|
||||||
|
void newMessageToSend(mtx::events::collections::TimelineEvents event);
|
||||||
|
void addPendingMessageToStore(mtx::events::collections::TimelineEvents event);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
DecryptionResult decryptEvent(
|
|
||||||
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e) const;
|
|
||||||
std::vector<QString> internalAddEvents(
|
|
||||||
const std::vector<mtx::events::collections::TimelineEvents> &timeline,
|
|
||||||
bool emitCallEvents);
|
|
||||||
void sendEncryptedMessageEvent(const std::string &txn_id,
|
void sendEncryptedMessageEvent(const std::string &txn_id,
|
||||||
nlohmann::json content,
|
nlohmann::json content,
|
||||||
mtx::events::EventType);
|
mtx::events::EventType);
|
||||||
|
@ -283,16 +284,12 @@ private:
|
||||||
|
|
||||||
void setPaginationInProgress(const bool paginationInProgress);
|
void setPaginationInProgress(const bool paginationInProgress);
|
||||||
|
|
||||||
QHash<QString, mtx::events::collections::TimelineEvents> events;
|
|
||||||
QSet<QString> read;
|
QSet<QString> read;
|
||||||
QList<QString> pending;
|
|
||||||
std::vector<QString> eventOrder;
|
mutable EventStore events;
|
||||||
std::map<QString, ReactionsModel> reactions;
|
|
||||||
|
|
||||||
QString room_id_;
|
QString room_id_;
|
||||||
QString prev_batch_token_;
|
|
||||||
|
|
||||||
bool isInitialSync = true;
|
|
||||||
bool decryptDescription = true;
|
bool decryptDescription = true;
|
||||||
bool m_paginationInProgress = false;
|
bool m_paginationInProgress = false;
|
||||||
|
|
||||||
|
|
|
@ -340,33 +340,36 @@ TimelineViewManager::queueEmoteMessage(const QString &msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
TimelineViewManager::reactToMessage(const QString &roomId,
|
TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey)
|
||||||
const QString &reactedEvent,
|
|
||||||
const QString &reactionKey,
|
|
||||||
const QString &selfReactedEvent)
|
|
||||||
{
|
{
|
||||||
// If selfReactedEvent is empty, that means we haven't previously reacted
|
if (!timeline_)
|
||||||
if (selfReactedEvent.isEmpty()) {
|
return;
|
||||||
queueReactionMessage(roomId, reactedEvent, reactionKey);
|
|
||||||
// Otherwise, we have previously reacted and the reaction should be redacted
|
auto reactions = timeline_->reactions(reactedEvent.toStdString());
|
||||||
} else {
|
|
||||||
auto model = models.value(roomId);
|
QString selfReactedEvent;
|
||||||
model->redactEvent(selfReactedEvent);
|
for (const auto &reaction : reactions) {
|
||||||
|
if (reactionKey == reaction.key_) {
|
||||||
|
selfReactedEvent = reaction.selfReactedEvent_;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
if (selfReactedEvent.startsWith("m"))
|
||||||
TimelineViewManager::queueReactionMessage(const QString &roomId,
|
return;
|
||||||
const QString &reactedEvent,
|
|
||||||
const QString &reactionKey)
|
// If selfReactedEvent is empty, that means we haven't previously reacted
|
||||||
{
|
if (selfReactedEvent.isEmpty()) {
|
||||||
mtx::events::msg::Reaction reaction;
|
mtx::events::msg::Reaction reaction;
|
||||||
reaction.relates_to.rel_type = mtx::common::RelationType::Annotation;
|
reaction.relates_to.rel_type = mtx::common::RelationType::Annotation;
|
||||||
reaction.relates_to.event_id = reactedEvent.toStdString();
|
reaction.relates_to.event_id = reactedEvent.toStdString();
|
||||||
reaction.relates_to.key = reactionKey.toStdString();
|
reaction.relates_to.key = reactionKey.toStdString();
|
||||||
|
|
||||||
auto model = models.value(roomId);
|
timeline_->sendMessageEvent(reaction, mtx::events::EventType::Reaction);
|
||||||
model->sendMessageEvent(reaction, mtx::events::EventType::RoomMessage);
|
// Otherwise, we have previously reacted and the reaction should be redacted
|
||||||
|
} else {
|
||||||
|
timeline_->redactEvent(selfReactedEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
|
@ -384,10 +387,13 @@ TimelineViewManager::queueImageMessage(const QString &roomid,
|
||||||
image.info.size = dsize;
|
image.info.size = dsize;
|
||||||
image.info.blurhash = blurhash.toStdString();
|
image.info.blurhash = blurhash.toStdString();
|
||||||
image.body = filename.toStdString();
|
image.body = filename.toStdString();
|
||||||
image.url = url.toStdString();
|
|
||||||
image.info.h = dimensions.height();
|
image.info.h = dimensions.height();
|
||||||
image.info.w = dimensions.width();
|
image.info.w = dimensions.width();
|
||||||
|
|
||||||
|
if (file)
|
||||||
image.file = file;
|
image.file = file;
|
||||||
|
else
|
||||||
|
image.url = url.toStdString();
|
||||||
|
|
||||||
auto model = models.value(roomid);
|
auto model = models.value(roomid);
|
||||||
if (!model->reply().isEmpty()) {
|
if (!model->reply().isEmpty()) {
|
||||||
|
@ -411,8 +417,11 @@ TimelineViewManager::queueFileMessage(
|
||||||
file.info.mimetype = mime.toStdString();
|
file.info.mimetype = mime.toStdString();
|
||||||
file.info.size = dsize;
|
file.info.size = dsize;
|
||||||
file.body = filename.toStdString();
|
file.body = filename.toStdString();
|
||||||
file.url = url.toStdString();
|
|
||||||
|
if (encryptedFile)
|
||||||
file.file = encryptedFile;
|
file.file = encryptedFile;
|
||||||
|
else
|
||||||
|
file.url = url.toStdString();
|
||||||
|
|
||||||
auto model = models.value(roomid);
|
auto model = models.value(roomid);
|
||||||
if (!model->reply().isEmpty()) {
|
if (!model->reply().isEmpty()) {
|
||||||
|
@ -436,7 +445,11 @@ TimelineViewManager::queueAudioMessage(const QString &roomid,
|
||||||
audio.info.size = dsize;
|
audio.info.size = dsize;
|
||||||
audio.body = filename.toStdString();
|
audio.body = filename.toStdString();
|
||||||
audio.url = url.toStdString();
|
audio.url = url.toStdString();
|
||||||
|
|
||||||
|
if (file)
|
||||||
audio.file = file;
|
audio.file = file;
|
||||||
|
else
|
||||||
|
audio.url = url.toStdString();
|
||||||
|
|
||||||
auto model = models.value(roomid);
|
auto model = models.value(roomid);
|
||||||
if (!model->reply().isEmpty()) {
|
if (!model->reply().isEmpty()) {
|
||||||
|
@ -459,8 +472,11 @@ TimelineViewManager::queueVideoMessage(const QString &roomid,
|
||||||
video.info.mimetype = mime.toStdString();
|
video.info.mimetype = mime.toStdString();
|
||||||
video.info.size = dsize;
|
video.info.size = dsize;
|
||||||
video.body = filename.toStdString();
|
video.body = filename.toStdString();
|
||||||
video.url = url.toStdString();
|
|
||||||
|
if (file)
|
||||||
video.file = file;
|
video.file = file;
|
||||||
|
else
|
||||||
|
video.url = url.toStdString();
|
||||||
|
|
||||||
auto model = models.value(roomid);
|
auto model = models.value(roomid);
|
||||||
if (!model->reply().isEmpty()) {
|
if (!model->reply().isEmpty()) {
|
||||||
|
|
|
@ -66,13 +66,7 @@ public slots:
|
||||||
|
|
||||||
void setHistoryView(const QString &room_id);
|
void setHistoryView(const QString &room_id);
|
||||||
void updateColorPalette();
|
void updateColorPalette();
|
||||||
void queueReactionMessage(const QString &roomId,
|
void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey);
|
||||||
const QString &reactedEvent,
|
|
||||||
const QString &reaction);
|
|
||||||
void reactToMessage(const QString &roomId,
|
|
||||||
const QString &reactedEvent,
|
|
||||||
const QString &reactionKey,
|
|
||||||
const QString &selfReactedEvent);
|
|
||||||
void queueTextMessage(const QString &msg);
|
void queueTextMessage(const QString &msg);
|
||||||
void queueEmoteMessage(const QString &msg);
|
void queueEmoteMessage(const QString &msg);
|
||||||
void queueImageMessage(const QString &roomid,
|
void queueImageMessage(const QString &roomid,
|
||||||
|
@ -108,6 +102,12 @@ public slots:
|
||||||
|
|
||||||
void updateEncryptedDescriptions();
|
void updateEncryptedDescriptions();
|
||||||
|
|
||||||
|
void clearCurrentRoomTimeline()
|
||||||
|
{
|
||||||
|
if (timeline_)
|
||||||
|
timeline_->clearTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
#ifdef USE_QUICK_VIEW
|
#ifdef USE_QUICK_VIEW
|
||||||
QQuickView *view;
|
QQuickView *view;
|
||||||
|
|
Loading…
Reference in a new issue