mirror of
https://github.com/Nheko-Reborn/nheko.git
synced 2024-10-30 17:40: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
|
||||
src/timeline/ReactionsModel.cpp
|
||||
src/timeline/EventStore.cpp
|
||||
src/timeline/Reaction.cpp
|
||||
src/timeline/TimelineViewManager.cpp
|
||||
src/timeline/TimelineModel.cpp
|
||||
src/timeline/DelegateChooser.cpp
|
||||
|
@ -340,7 +341,7 @@ if(USE_BUNDLED_MTXCLIENT)
|
|||
FetchContent_Declare(
|
||||
MatrixClient
|
||||
GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
|
||||
GIT_TAG 744018c86a8094acbda9821d6d7b5a890d4aac47
|
||||
GIT_TAG d8666a3f1a5b709b78ccea2b545d540a8cb502ca
|
||||
)
|
||||
FetchContent_MakeAvailable(MatrixClient)
|
||||
else()
|
||||
|
@ -463,7 +464,8 @@ qt5_wrap_cpp(MOC_HEADERS
|
|||
src/emoji/Provider.h
|
||||
|
||||
# Timeline
|
||||
src/timeline/ReactionsModel.h
|
||||
src/timeline/EventStore.h
|
||||
src/timeline/Reaction.h
|
||||
src/timeline/TimelineViewManager.h
|
||||
src/timeline/TimelineModel.h
|
||||
src/timeline/DelegateChooser.h
|
||||
|
|
|
@ -75,6 +75,14 @@ sudo eselect repository enable matrix
|
|||
sudo emerge -a nheko
|
||||
```
|
||||
|
||||
#### Nix(os)
|
||||
|
||||
```bash
|
||||
nix-env -iA nixpkgs.nheko
|
||||
# or
|
||||
nix-shell -p nheko --run nheko
|
||||
```
|
||||
|
||||
#### Alpine Linux (and 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`.
|
||||
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.
|
||||
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:
|
||||
|
||||
|
|
|
@ -146,7 +146,7 @@
|
|||
"name": "mtxclient",
|
||||
"sources": [
|
||||
{
|
||||
"commit": "744018c86a8094acbda9821d6d7b5a890d4aac47",
|
||||
"commit": "d8666a3f1a5b709b78ccea2b545d540a8cb502ca",
|
||||
"type": "git",
|
||||
"url": "https://github.com/Nheko-Reborn/mtxclient.git"
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ TextEdit {
|
|||
readOnly: true
|
||||
wrapMode: Text.Wrap
|
||||
selectByMouse: true
|
||||
activeFocusOnPress: false
|
||||
color: colors.text
|
||||
|
||||
onLinkActivated: {
|
||||
|
|
|
@ -30,11 +30,11 @@ Flow {
|
|||
implicitHeight: contentItem.childrenRect.height
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: model.users
|
||||
ToolTip.text: modelData.users
|
||||
|
||||
onClicked: {
|
||||
console.debug("Picked " + model.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + model.selfReactedEvent)
|
||||
timelineManager.reactToMessage(reactionFlow.roomId, reactionFlow.eventId, model.key, model.selfReactedEvent)
|
||||
console.debug("Picked " + modelData.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + modelData.selfReactedEvent)
|
||||
timelineManager.queueReactionMessage(reactionFlow.eventId, modelData.key)
|
||||
}
|
||||
|
||||
|
||||
|
@ -49,13 +49,13 @@ Flow {
|
|||
font.family: settings.emojiFont
|
||||
elide: Text.ElideRight
|
||||
elideWidth: 150
|
||||
text: model.key
|
||||
text: modelData.key
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.baseline: reactionCounter.baseline
|
||||
id: reactionText
|
||||
text: textMetrics.elidedText + (textMetrics.elidedText == model.key ? "" : "…")
|
||||
text: textMetrics.elidedText + (textMetrics.elidedText == modelData.key ? "" : "…")
|
||||
font.family: settings.emojiFont
|
||||
color: reaction.hovered ? colors.highlight : colors.text
|
||||
maximumLineCount: 1
|
||||
|
@ -65,13 +65,13 @@ Flow {
|
|||
id: divider
|
||||
height: Math.floor(reactionCounter.implicitHeight * 1.4)
|
||||
width: 1
|
||||
color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text
|
||||
color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: divider.verticalCenter
|
||||
id: reactionCounter
|
||||
text: model.counter
|
||||
text: modelData.count
|
||||
font: reaction.font
|
||||
color: reaction.hovered ? colors.highlight : colors.text
|
||||
}
|
||||
|
@ -82,8 +82,8 @@ Flow {
|
|||
|
||||
implicitWidth: reaction.implicitWidth
|
||||
implicitHeight: reaction.implicitHeight
|
||||
border.color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text
|
||||
color: model.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base
|
||||
border.color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text
|
||||
color: modelData.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base
|
||||
border.width: 1
|
||||
radius: reaction.height / 2.0
|
||||
}
|
||||
|
|
|
@ -106,6 +106,6 @@ MouseArea {
|
|||
//How long the scrollbar will remain visible
|
||||
interval: 500
|
||||
// Hide the scrollbars
|
||||
onTriggered: flickable.cancelFlick();
|
||||
onTriggered: { flickable.cancelFlick(); flickable.movementEnded(); }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,22 +8,25 @@ import im.nheko 1.0
|
|||
import "./delegates"
|
||||
import "./emoji"
|
||||
|
||||
MouseArea {
|
||||
Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: row.height
|
||||
propagateComposedEvents: true
|
||||
preventStealing: true
|
||||
hoverEnabled: true
|
||||
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row)
|
||||
}
|
||||
onPressAndHold: {
|
||||
if (mouse.source === Qt.MouseEventNotSynthesized)
|
||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row)
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
propagateComposedEvents: true
|
||||
preventStealing: true
|
||||
hoverEnabled: true
|
||||
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onClicked: {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row)
|
||||
}
|
||||
onPressAndHold: {
|
||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row, mapToItem(timelineRoot, mouse.x, mouse.y))
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent"
|
||||
|
@ -45,7 +48,7 @@ MouseArea {
|
|||
// fancy reply, if this is a reply
|
||||
Reply {
|
||||
visible: model.replyTo
|
||||
modelData: chat.model.getDump(model.replyTo)
|
||||
modelData: chat.model.getDump(model.replyTo, model.id)
|
||||
userColor: timelineManager.userColor(modelData.userId, colors.window)
|
||||
}
|
||||
|
||||
|
@ -90,7 +93,6 @@ MouseArea {
|
|||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("React")
|
||||
emojiPicker: emojiPopup
|
||||
room_id: model.roomId
|
||||
event_id: model.id
|
||||
}
|
||||
ImageButton {
|
||||
|
@ -128,6 +130,7 @@ MouseArea {
|
|||
Label {
|
||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop
|
||||
text: model.timestamp.toLocaleTimeString("HH:mm")
|
||||
width: Math.max(implicitWidth, text.length*fontMetrics.maximumCharacterWidth)
|
||||
color: inactiveColors.text
|
||||
|
||||
MouseArea{
|
||||
|
|
|
@ -11,6 +11,8 @@ import "./delegates"
|
|||
import "./emoji"
|
||||
|
||||
Page {
|
||||
id: timelineRoot
|
||||
|
||||
property var colors: currentActivePalette
|
||||
property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled }
|
||||
property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive
|
||||
|
@ -25,34 +27,39 @@ Page {
|
|||
id: fontMetrics
|
||||
}
|
||||
|
||||
EmojiPicker {
|
||||
id: emojiPopup
|
||||
width: 7 * 52 + 20
|
||||
height: 6 * 52
|
||||
colors: palette
|
||||
model: EmojiProxyModel {
|
||||
category: EmojiCategory.People
|
||||
sourceModel: EmojiModel {}
|
||||
}
|
||||
}
|
||||
EmojiPicker {
|
||||
id: emojiPopup
|
||||
width: 7 * 52 + 20
|
||||
height: 6 * 52
|
||||
colors: palette
|
||||
model: EmojiProxyModel {
|
||||
category: EmojiCategory.People
|
||||
sourceModel: EmojiModel {}
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
id: messageContextMenu
|
||||
modal: true
|
||||
|
||||
function show(eventId_, eventType_, isEncrypted_, showAt) {
|
||||
function show(eventId_, eventType_, isEncrypted_, showAt_, position) {
|
||||
eventId = eventId_
|
||||
eventType = eventType_
|
||||
isEncrypted = isEncrypted_
|
||||
popup(showAt)
|
||||
|
||||
if (position)
|
||||
popup(position, showAt_)
|
||||
else
|
||||
popup(showAt_)
|
||||
}
|
||||
|
||||
property string eventId
|
||||
property int eventType
|
||||
property bool isEncrypted
|
||||
|
||||
MenuItem {
|
||||
text: qsTr("React")
|
||||
onClicked: chat.model.reactAction(messageContextMenu.eventId)
|
||||
onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId)
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Reply")
|
||||
|
@ -87,8 +94,6 @@ Page {
|
|||
}
|
||||
}
|
||||
|
||||
id: timelineRoot
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: colors.window
|
||||
|
@ -113,7 +118,7 @@ Page {
|
|||
ListView {
|
||||
id: chat
|
||||
|
||||
visible: timelineManager.timeline != null
|
||||
visible: !!timelineManager.timeline
|
||||
|
||||
cacheBuffer: 400
|
||||
|
||||
|
@ -181,7 +186,7 @@ Page {
|
|||
|
||||
id: wrapper
|
||||
property Item section
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
|
||||
width: chat.delegateMaxWidth
|
||||
height: section ? section.height + timelinerow.height : timelinerow.height
|
||||
color: "transparent"
|
||||
|
@ -205,14 +210,13 @@ Page {
|
|||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: chat.model
|
||||
property: "currentIndex"
|
||||
when: y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height
|
||||
value: index
|
||||
delayed: true
|
||||
Connections {
|
||||
target: chat
|
||||
function onMovementEnded() {
|
||||
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
|
||||
chat.model.currentIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
section {
|
||||
|
@ -296,13 +300,13 @@ Page {
|
|||
}
|
||||
}
|
||||
|
||||
footer: BusyIndicator {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
running: chat.model && chat.model.paginationInProgress
|
||||
height: 50
|
||||
width: 50
|
||||
z: 3
|
||||
}
|
||||
footer: BusyIndicator {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
running: chat.model && chat.model.paginationInProgress
|
||||
height: 50
|
||||
width: 50
|
||||
z: 3
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
|
@ -354,7 +358,7 @@ Page {
|
|||
anchors.rightMargin: 20
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -9,8 +9,8 @@ Item {
|
|||
property double divisor: model.isReply ? 4 : 2
|
||||
property bool tooHigh: tempHeight > timelineRoot.height / divisor
|
||||
|
||||
height: tooHigh ? timelineRoot.height / divisor : tempHeight
|
||||
width: tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth
|
||||
height: Math.round(tooHigh ? timelineRoot.height / divisor : tempHeight)
|
||||
width: Math.round(tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth)
|
||||
|
||||
Image {
|
||||
id: blurhash
|
||||
|
|
|
@ -66,6 +66,12 @@ Item {
|
|||
text: qsTr("redacted")
|
||||
}
|
||||
}
|
||||
DelegateChoice {
|
||||
roleValue: MtxEvent.Redaction
|
||||
Pill {
|
||||
text: qsTr("redacted")
|
||||
}
|
||||
}
|
||||
DelegateChoice {
|
||||
roleValue: MtxEvent.Encryption
|
||||
Pill {
|
||||
|
@ -108,6 +114,12 @@ Item {
|
|||
text: qsTr("%1 ended the call.").arg(model.data.userName)
|
||||
}
|
||||
}
|
||||
DelegateChoice {
|
||||
roleValue: MtxEvent.CallCandidates
|
||||
NoticeMessage {
|
||||
text: qsTr("Negotiating call...")
|
||||
}
|
||||
}
|
||||
DelegateChoice {
|
||||
// TODO: make a more complex formatter for the power levels.
|
||||
roleValue: MtxEvent.PowerLevels
|
||||
|
|
|
@ -9,7 +9,7 @@ Rectangle {
|
|||
id: bg
|
||||
radius: 10
|
||||
color: colors.dark
|
||||
height: content.height + 24
|
||||
height: Math.round(content.height + 24)
|
||||
width: parent ? parent.width : undefined
|
||||
|
||||
Column {
|
||||
|
|
|
@ -4,7 +4,7 @@ MatrixText {
|
|||
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'>")
|
||||
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
|
||||
font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize
|
||||
}
|
||||
|
|
|
@ -8,11 +8,10 @@ import "../"
|
|||
ImageButton {
|
||||
property var colors: currentActivePalette
|
||||
property var emojiPicker
|
||||
property string room_id
|
||||
property string event_id
|
||||
|
||||
image: ":/icons/icons/ui/smile.png"
|
||||
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 {
|
||||
|
||||
function show(showAt, room_id, event_id) {
|
||||
console.debug("Showing emojiPicker for " + event_id + "in room " + room_id)
|
||||
parent = showAt
|
||||
x = Math.round((showAt.width - width) / 2)
|
||||
y = showAt.height
|
||||
emojiPopup.room_id = room_id
|
||||
emojiPopup.event_id = event_id
|
||||
open()
|
||||
}
|
||||
function show(showAt, event_id) {
|
||||
console.debug("Showing emojiPicker for " + event_id)
|
||||
if (showAt){
|
||||
parent = showAt
|
||||
x = Math.round((showAt.width - width) / 2)
|
||||
y = showAt.height
|
||||
}
|
||||
emojiPopup.event_id = event_id
|
||||
open()
|
||||
}
|
||||
|
||||
property string room_id
|
||||
property string event_id
|
||||
property var colors
|
||||
property alias model: gridView.model
|
||||
|
@ -102,9 +102,9 @@ Popup {
|
|||
}
|
||||
// TODO: maybe add favorites at some point?
|
||||
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()
|
||||
timelineManager.queueReactionMessage(emojiPopup.room_id, emojiPopup.event_id, model.unicode)
|
||||
timelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
936
src/Cache.cpp
936
src/Cache.cpp
File diff suppressed because it is too large
Load diff
|
@ -18,6 +18,7 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
|
||||
#include <QDateTime>
|
||||
|
@ -38,9 +39,6 @@
|
|||
#include "CacheCryptoStructs.h"
|
||||
#include "CacheStructs.h"
|
||||
|
||||
int
|
||||
numeric_key_comparison(const MDB_val *a, const MDB_val *b);
|
||||
|
||||
class Cache : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
@ -172,6 +170,47 @@ public:
|
|||
//! Add all notifications containing a user mention to the db.
|
||||
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.
|
||||
void deleteOldMessages();
|
||||
void deleteOldData() noexcept;
|
||||
|
@ -250,8 +289,6 @@ private:
|
|||
const std::string &room_id,
|
||||
const mtx::responses::Timeline &res);
|
||||
|
||||
mtx::responses::Timeline getTimelineMessages(lmdb::txn &txn, const std::string &room_id);
|
||||
|
||||
//! Remove a room from the cache.
|
||||
// void removeLeftRoom(lmdb::txn &txn, const std::string &room_id);
|
||||
template<class T>
|
||||
|
@ -402,13 +439,46 @@ private:
|
|||
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 =
|
||||
lmdb::dbi::open(txn, std::string(room_id + "/messages").c_str(), MDB_CREATE);
|
||||
lmdb::dbi_set_compare(txn, db, numeric_key_comparison);
|
||||
return lmdb::dbi::open(txn, std::string(room_id + "/events").c_str(), MDB_CREATE);
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
189
src/ChatPage.cpp
189
src/ChatPage.cpp
|
@ -165,6 +165,11 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
trySync();
|
||||
});
|
||||
|
||||
connect(text_input_,
|
||||
&TextInputWidget::clearRoomTimeline,
|
||||
view_manager_,
|
||||
&TimelineViewManager::clearCurrentRoomTimeline);
|
||||
|
||||
connect(
|
||||
new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
|
||||
if (isVisible())
|
||||
|
@ -254,7 +259,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
|
||||
|
||||
connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) {
|
||||
view_manager_->addRoom(room_id);
|
||||
joinRoom(room_id);
|
||||
room_list_->removeRoom(room_id, currentRoom() == room_id);
|
||||
});
|
||||
|
@ -323,17 +327,15 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
.toStdString();
|
||||
member.membership = mtx::events::state::Membership::Join;
|
||||
|
||||
http::client()
|
||||
->send_state_event<mtx::events::state::Member,
|
||||
mtx::events::EventType::RoomMember>(
|
||||
currentRoom().toStdString(),
|
||||
http::client()->user_id().to_string(),
|
||||
member,
|
||||
[](mtx::responses::EventId, mtx::http::RequestErr err) {
|
||||
if (err)
|
||||
nhlog::net()->error("Failed to set room displayname: {}",
|
||||
err->matrix_error.error);
|
||||
});
|
||||
http::client()->send_state_event(
|
||||
currentRoom().toStdString(),
|
||||
http::client()->user_id().to_string(),
|
||||
member,
|
||||
[](mtx::responses::EventId, mtx::http::RequestErr err) {
|
||||
if (err)
|
||||
nhlog::net()->error("Failed to set room displayname: {}",
|
||||
err->matrix_error.error);
|
||||
});
|
||||
});
|
||||
|
||||
connect(
|
||||
|
@ -584,12 +586,8 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
emit notificationsRetrieved(std::move(res));
|
||||
});
|
||||
});
|
||||
connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync, Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&ChatPage::syncTags,
|
||||
communitiesList_,
|
||||
&CommunitiesList::syncTags,
|
||||
Qt::QueuedConnection);
|
||||
connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync);
|
||||
connect(this, &ChatPage::syncTags, communitiesList_, &CommunitiesList::syncTags);
|
||||
connect(
|
||||
this, &ChatPage::syncTopBar, this, [this](const std::map<QString, RoomInfo> &updates) {
|
||||
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); },
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this,
|
||||
&ChatPage::newSyncResponse,
|
||||
this,
|
||||
&ChatPage::handleSyncResponse,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
|
||||
|
||||
connectCallMessage<mtx::events::msg::CallInvite>();
|
||||
|
@ -841,43 +845,39 @@ ChatPage::loadStateFromCache()
|
|||
|
||||
nhlog::db()->info("restoring state from cache");
|
||||
|
||||
try {
|
||||
cache::restoreSessions();
|
||||
olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY);
|
||||
|
||||
cache::populateMembers();
|
||||
|
||||
emit initializeEmptyViews(cache::roomMessages());
|
||||
emit initializeRoomList(cache::roomInfo());
|
||||
emit initializeMentions(cache::getTimelineMentions());
|
||||
emit syncTags(cache::roomInfo().toStdMap());
|
||||
|
||||
cache::calculateRoomReadStatus();
|
||||
|
||||
} catch (const mtx::crypto::olm_exception &e) {
|
||||
nhlog::crypto()->critical("failed to restore olm account: {}", e.what());
|
||||
emit dropToLoginPageCb(tr("Failed to restore OLM account. Please login again."));
|
||||
return;
|
||||
} catch (const lmdb::error &e) {
|
||||
nhlog::db()->critical("failed to restore cache: {}", e.what());
|
||||
emit dropToLoginPageCb(tr("Failed to restore save data. Please login again."));
|
||||
return;
|
||||
} catch (const json::exception &e) {
|
||||
nhlog::db()->critical("failed to parse cache data: {}", e.what());
|
||||
return;
|
||||
}
|
||||
|
||||
nhlog::crypto()->info("ed25519 : {}", olm::client()->identity_keys().ed25519);
|
||||
nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519);
|
||||
|
||||
getProfileInfo();
|
||||
|
||||
QtConcurrent::run([this]() {
|
||||
try {
|
||||
cache::restoreSessions();
|
||||
olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY);
|
||||
|
||||
cache::populateMembers();
|
||||
|
||||
emit initializeEmptyViews(cache::roomMessages());
|
||||
emit initializeRoomList(cache::roomInfo());
|
||||
emit initializeMentions(cache::getTimelineMentions());
|
||||
emit syncTags(cache::roomInfo().toStdMap());
|
||||
|
||||
cache::calculateRoomReadStatus();
|
||||
|
||||
} catch (const mtx::crypto::olm_exception &e) {
|
||||
nhlog::crypto()->critical("failed to restore olm account: {}", e.what());
|
||||
emit dropToLoginPageCb(
|
||||
tr("Failed to restore OLM account. Please login again."));
|
||||
return;
|
||||
} catch (const lmdb::error &e) {
|
||||
nhlog::db()->critical("failed to restore cache: {}", e.what());
|
||||
emit dropToLoginPageCb(
|
||||
tr("Failed to restore save data. Please login again."));
|
||||
return;
|
||||
} catch (const json::exception &e) {
|
||||
nhlog::db()->critical("failed to parse cache data: {}", e.what());
|
||||
return;
|
||||
}
|
||||
|
||||
nhlog::crypto()->info("ed25519 : {}", olm::client()->identity_keys().ed25519);
|
||||
nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519);
|
||||
|
||||
// Start receiving events.
|
||||
emit trySyncCb();
|
||||
});
|
||||
// Start receiving events.
|
||||
emit trySyncCb();
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -1055,6 +1055,45 @@ ChatPage::startInitialSync()
|
|||
&ChatPage::initialSyncHandler, this, std::placeholders::_1, std::placeholders::_2));
|
||||
}
|
||||
|
||||
void
|
||||
ChatPage::handleSyncResponse(mtx::responses::Sync res)
|
||||
{
|
||||
nhlog::net()->debug("sync completed: {}", res.next_batch);
|
||||
|
||||
// Ensure that we have enough one-time keys available.
|
||||
ensureOneTimeKeyCount(res.device_one_time_keys_count);
|
||||
|
||||
// TODO: fine grained error handling
|
||||
try {
|
||||
cache::saveState(res);
|
||||
olm::handle_to_device_messages(res.to_device.events);
|
||||
|
||||
auto updates = cache::roomUpdates(res);
|
||||
|
||||
emit syncTopBar(updates);
|
||||
emit syncRoomlist(updates);
|
||||
|
||||
emit syncUI(res.rooms);
|
||||
|
||||
emit syncTags(cache::roomTagUpdates(res));
|
||||
|
||||
// if we process a lot of syncs (1 every 200ms), this means we clean the
|
||||
// db every 100s
|
||||
static int syncCounter = 0;
|
||||
if (syncCounter++ >= 500) {
|
||||
cache::deleteOldData();
|
||||
syncCounter = 0;
|
||||
}
|
||||
} catch (const lmdb::map_full_error &e) {
|
||||
nhlog::db()->error("lmdb is full: {}", e.what());
|
||||
cache::deleteOldData();
|
||||
} catch (const lmdb::error &e) {
|
||||
nhlog::db()->error("saving sync response: {}", e.what());
|
||||
}
|
||||
|
||||
emit trySyncCb();
|
||||
}
|
||||
|
||||
void
|
||||
ChatPage::trySync()
|
||||
{
|
||||
|
@ -1072,7 +1111,14 @@ ChatPage::trySync()
|
|||
}
|
||||
|
||||
http::client()->sync(
|
||||
opts, [this](const mtx::responses::Sync &res, mtx::http::RequestErr err) {
|
||||
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);
|
||||
|
@ -1094,40 +1140,7 @@ ChatPage::trySync()
|
|||
return;
|
||||
}
|
||||
|
||||
nhlog::net()->debug("sync completed: {}", res.next_batch);
|
||||
|
||||
// Ensure that we have enough one-time keys available.
|
||||
ensureOneTimeKeyCount(res.device_one_time_keys_count);
|
||||
|
||||
// TODO: fine grained error handling
|
||||
try {
|
||||
cache::saveState(res);
|
||||
olm::handle_to_device_messages(res.to_device.events);
|
||||
|
||||
auto updates = cache::roomUpdates(res);
|
||||
|
||||
emit syncTopBar(updates);
|
||||
emit syncRoomlist(updates);
|
||||
|
||||
emit syncUI(res.rooms);
|
||||
|
||||
emit syncTags(cache::roomTagUpdates(res));
|
||||
|
||||
// if we process a lot of syncs (1 every 200ms), this means we clean the
|
||||
// db every 100s
|
||||
static int syncCounter = 0;
|
||||
if (syncCounter++ >= 500) {
|
||||
cache::deleteOldData();
|
||||
syncCounter = 0;
|
||||
}
|
||||
} catch (const lmdb::map_full_error &e) {
|
||||
nhlog::db()->error("lmdb is full: {}", e.what());
|
||||
cache::deleteOldData();
|
||||
} catch (const lmdb::error &e) {
|
||||
nhlog::db()->error("saving sync response: {}", e.what());
|
||||
}
|
||||
|
||||
emit trySyncCb();
|
||||
emit newSyncResponse(res);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -140,6 +140,7 @@ signals:
|
|||
void trySyncCb();
|
||||
void tryDelayedSyncCb();
|
||||
void tryInitialSyncCb();
|
||||
void newSyncResponse(mtx::responses::Sync res);
|
||||
void leftRoom(const QString &room_id);
|
||||
|
||||
void initializeRoomList(QMap<QString, RoomInfo>);
|
||||
|
@ -174,6 +175,7 @@ private slots:
|
|||
|
||||
void joinRoom(const QString &room);
|
||||
void sendTypingNotifications();
|
||||
void handleSyncResponse(mtx::responses::Sync res);
|
||||
|
||||
private:
|
||||
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
|
||||
{
|
||||
template<class T>
|
||||
|
@ -409,6 +423,11 @@ mtx::accessors::in_reply_to_event(const mtx::events::collections::TimelineEvents
|
|||
{
|
||||
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
|
||||
mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event)
|
||||
|
|
|
@ -56,6 +56,8 @@ mimetype(const mtx::events::collections::TimelineEvents &event);
|
|||
std::string
|
||||
in_reply_to_event(const mtx::events::collections::TimelineEvents &event);
|
||||
std::string
|
||||
relates_to_event_id(const mtx::events::collections::TimelineEvents &event);
|
||||
std::string
|
||||
transaction_id(const mtx::events::collections::TimelineEvents &event);
|
||||
|
||||
int64_t
|
||||
|
|
95
src/Olm.cpp
95
src/Olm.cpp
|
@ -3,6 +3,7 @@
|
|||
#include "Olm.h"
|
||||
|
||||
#include "Cache.h"
|
||||
#include "Cache_p.h"
|
||||
#include "Logging.h"
|
||||
#include "MatrixClient.h"
|
||||
#include "Utils.h"
|
||||
|
@ -316,32 +317,36 @@ send_key_request_for(const std::string &room_id,
|
|||
using namespace mtx::events;
|
||||
|
||||
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;
|
||||
body["messages"][e.sender] = json::object();
|
||||
body["messages"][e.sender][e.content.device_id] = payload;
|
||||
mtx::events::msg::KeyRequest request;
|
||||
request.action = mtx::events::msg::RequestAction::Request;
|
||||
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) {
|
||||
if (err) {
|
||||
nhlog::net()->warn("failed to send "
|
||||
"send_to_device "
|
||||
"message: {}",
|
||||
err->matrix_error.error);
|
||||
}
|
||||
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;
|
||||
|
||||
nhlog::net()->info(
|
||||
"m.room_key_request sent to {}:{}", e.sender, e.content.device_id);
|
||||
});
|
||||
http::client()->send_to_device(
|
||||
http::client()->generate_txn_id(), body, [e](mtx::http::RequestErr err) {
|
||||
if (err) {
|
||||
nhlog::net()->warn("failed to send "
|
||||
"send_to_device "
|
||||
"message: {}",
|
||||
err->matrix_error.error);
|
||||
}
|
||||
|
||||
nhlog::net()->info("m.room_key_request sent to {}:{} and your own devices",
|
||||
e.sender,
|
||||
e.content.device_id);
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -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
|
||||
|
|
24
src/Olm.h
24
src/Olm.h
|
@ -7,10 +7,30 @@
|
|||
#include <mtx/events/encrypted.hpp>
|
||||
#include <mtxclient/crypto/client.hpp>
|
||||
|
||||
#include <CacheCryptoStructs.h>
|
||||
|
||||
constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2";
|
||||
|
||||
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
|
||||
{
|
||||
std::string sender_key;
|
||||
|
@ -65,6 +85,10 @@ encrypt_group_message(const std::string &room_id,
|
|||
const std::string &device_id,
|
||||
nlohmann::json body);
|
||||
|
||||
DecryptionResult
|
||||
decryptEvent(const MegolmSessionIndex &index,
|
||||
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &event);
|
||||
|
||||
void
|
||||
mark_keys_as_published();
|
||||
|
||||
|
|
|
@ -15,9 +15,11 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QAbstractTextDocumentLayout>
|
||||
#include <QBuffer>
|
||||
#include <QClipboard>
|
||||
#include <QCompleter>
|
||||
#include <QFileDialog>
|
||||
#include <QMimeData>
|
||||
#include <QMimeDatabase>
|
||||
|
@ -28,9 +30,12 @@
|
|||
|
||||
#include "Cache.h"
|
||||
#include "ChatPage.h"
|
||||
#include "CompletionModel.h"
|
||||
#include "Logging.h"
|
||||
#include "TextInputWidget.h"
|
||||
#include "Utils.h"
|
||||
#include "emoji/EmojiSearchModel.h"
|
||||
#include "emoji/Provider.h"
|
||||
#include "ui/FlatButton.h"
|
||||
#include "ui/LoadingIndicator.h"
|
||||
|
||||
|
@ -61,6 +66,23 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
|
|||
connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged);
|
||||
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_->setInterval(1000);
|
||||
typingTimer_->setSingleShot(true);
|
||||
|
@ -101,6 +123,18 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
|
|||
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
|
||||
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()) {
|
||||
case Qt::Key_At:
|
||||
atTriggerPosition_ = textCursor().position();
|
||||
|
@ -195,8 +244,26 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
|
|||
|
||||
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_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)) {
|
||||
stopTyping();
|
||||
submit();
|
||||
|
@ -243,6 +310,21 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
|
|||
if (isModifier)
|
||||
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) {
|
||||
resetAnchor();
|
||||
closeSuggestions();
|
||||
|
@ -352,6 +434,29 @@ FilteredTextEdit::stopTyping()
|
|||
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
|
||||
FilteredTextEdit::sizeHint() const
|
||||
{
|
||||
|
@ -581,27 +686,29 @@ void
|
|||
TextInputWidget::command(QString command, QString args)
|
||||
{
|
||||
if (command == "me") {
|
||||
sendEmoteMessage(args);
|
||||
emit sendEmoteMessage(args);
|
||||
} else if (command == "join") {
|
||||
sendJoinRoomRequest(args);
|
||||
emit sendJoinRoomRequest(args);
|
||||
} 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") {
|
||||
sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} 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") {
|
||||
sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "roomnick") {
|
||||
changeRoomNick(args);
|
||||
emit changeRoomNick(args);
|
||||
} else if (command == "shrug") {
|
||||
sendTextMessage("¯\\_(ツ)_/¯");
|
||||
emit sendTextMessage("¯\\_(ツ)_/¯");
|
||||
} else if (command == "fliptable") {
|
||||
sendTextMessage("(╯°□°)╯︵ ┻━┻");
|
||||
emit sendTextMessage("(╯°□°)╯︵ ┻━┻");
|
||||
} else if (command == "unfliptable") {
|
||||
sendTextMessage(" ┯━┯╭( º _ º╭)");
|
||||
emit sendTextMessage(" ┯━┯╭( º _ º╭)");
|
||||
} 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_);
|
||||
sendFileBtn_->hide();
|
||||
|
||||
topLayout_->insertWidget(0, spinner_);
|
||||
topLayout_->insertWidget(1, spinner_);
|
||||
spinner_->start();
|
||||
}
|
||||
|
||||
|
@ -641,7 +748,7 @@ void
|
|||
TextInputWidget::hideUploadSpinner()
|
||||
{
|
||||
topLayout_->removeWidget(spinner_);
|
||||
topLayout_->insertWidget(0, sendFileBtn_);
|
||||
topLayout_->insertWidget(1, sendFileBtn_);
|
||||
sendFileBtn_->show();
|
||||
spinner_->stop();
|
||||
}
|
||||
|
|
|
@ -33,8 +33,10 @@
|
|||
|
||||
struct SearchResult;
|
||||
|
||||
class CompletionModel;
|
||||
class FlatButton;
|
||||
class LoadingIndicator;
|
||||
class QCompleter;
|
||||
|
||||
class FilteredTextEdit : public QTextEdit
|
||||
{
|
||||
|
@ -80,8 +82,12 @@ protected:
|
|||
}
|
||||
|
||||
private:
|
||||
bool emoji_popup_open_ = false;
|
||||
CompletionModel *emoji_completion_model_;
|
||||
std::deque<QString> true_history_, working_history_;
|
||||
int trigger_pos_; // Where emoji completer was triggered
|
||||
size_t history_index_;
|
||||
QCompleter *completer_;
|
||||
QTimer *typingTimer_;
|
||||
|
||||
SuggestionsPopup suggestionsPopup_;
|
||||
|
@ -103,19 +109,27 @@ private:
|
|||
{
|
||||
return pos == atTriggerPosition_ + anchorWidth(anchor);
|
||||
}
|
||||
|
||||
QRect completerRect();
|
||||
QString query()
|
||||
{
|
||||
auto cursor = textCursor();
|
||||
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
|
||||
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_;
|
||||
|
||||
//! Latest position of the '@' character that triggers the username completer.
|
||||
int atTriggerPosition_ = -1;
|
||||
|
||||
void insertCompletion(QString completion);
|
||||
void textChanged();
|
||||
void uploadData(const QByteArray data, const QString &media, const QString &filename);
|
||||
void afterCompletion(int);
|
||||
|
@ -158,6 +172,7 @@ private slots:
|
|||
signals:
|
||||
void sendTextMessage(const QString &msg);
|
||||
void sendEmoteMessage(QString msg);
|
||||
void clearRoomTimeline();
|
||||
void heightChanged(int height);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#if GST_CHECK_VERSION(1, 17, 0)
|
||||
#if GST_CHECK_VERSION(1, 18, 0)
|
||||
void
|
||||
iceGatheringStateChanged(GstElement *webrtc,
|
||||
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);
|
||||
|
||||
#if GST_CHECK_VERSION(1, 18, 0)
|
||||
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
|
||||
return;
|
||||
#else
|
||||
if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) {
|
||||
emit WebRTCSession::instance().newICECandidate(
|
||||
{"audio", (uint16_t)mlineIndex, candidate});
|
||||
|
@ -232,9 +236,8 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
|
|||
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
|
||||
|
||||
// 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
|
||||
#if !GST_CHECK_VERSION(1, 17, 0)
|
||||
static guint timerid = 0;
|
||||
if (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 *sink = gst_element_factory_make("autoaudiosink", 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(convert);
|
||||
gst_element_sync_state_with_parent(resample);
|
||||
gst_element_sync_state_with_parent(sink);
|
||||
gst_element_link_many(queue, convert, resample, sink, nullptr);
|
||||
queuepad = gst_element_get_static_pad(queue, "sink");
|
||||
}
|
||||
|
||||
|
@ -423,8 +426,12 @@ WebRTCSession::acceptICECandidates(
|
|||
for (const auto &c : candidates) {
|
||||
nhlog::ui()->debug(
|
||||
"WebRTC: remote candidate: (m-line:{}):{}", c.sdpMLineIndex, c.candidate);
|
||||
g_signal_emit_by_name(
|
||||
webrtc_, "add-ice-candidate", c.sdpMLineIndex, c.candidate.c_str());
|
||||
if (!c.candidate.empty()) {
|
||||
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);
|
||||
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
|
||||
g_signal_connect(
|
||||
webrtc_, "notify::ice-gathering-state", G_CALLBACK(iceGatheringStateChanged), nullptr);
|
||||
|
|
|
@ -151,7 +151,7 @@ EditModal::applyClicked()
|
|||
state::Name body;
|
||||
body.name = newName.toStdString();
|
||||
|
||||
http::client()->send_state_event<state::Name, EventType::RoomName>(
|
||||
http::client()->send_state_event(
|
||||
roomId_.toStdString(),
|
||||
body,
|
||||
[proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
||||
|
@ -169,7 +169,7 @@ EditModal::applyClicked()
|
|||
state::Topic body;
|
||||
body.topic = newTopic.toStdString();
|
||||
|
||||
http::client()->send_state_event<state::Topic, EventType::RoomTopic>(
|
||||
http::client()->send_state_event(
|
||||
roomId_.toStdString(),
|
||||
body,
|
||||
[proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
||||
|
@ -694,7 +694,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
|
|||
startLoadingSpinner();
|
||||
resetErrorLabel();
|
||||
|
||||
http::client()->send_state_event<state::JoinRules, EventType::RoomJoinRules>(
|
||||
http::client()->send_state_event(
|
||||
room_id,
|
||||
join_rule,
|
||||
[this, room_id, guest_access](const mtx::responses::EventId &,
|
||||
|
@ -708,7 +708,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
|
|||
return;
|
||||
}
|
||||
|
||||
http::client()->send_state_event<state::GuestAccess, EventType::RoomGuestAccess>(
|
||||
http::client()->send_state_event(
|
||||
room_id,
|
||||
guest_access,
|
||||
[this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
|
||||
|
@ -843,7 +843,7 @@ RoomSettings::updateAvatar()
|
|||
avatar_event.image_info.size = size;
|
||||
avatar_event.url = res.content_uri;
|
||||
|
||||
http::client()->send_state_event<state::Avatar, EventType::RoomAvatar>(
|
||||
http::client()->send_state_event(
|
||||
room_id,
|
||||
avatar_event,
|
||||
[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();
|
||||
|
||||
QTranslator qtTranslator;
|
||||
qtTranslator.load("qt_" + lang, QLibraryInfo::location(QLibraryInfo::TranslationsPath));
|
||||
qtTranslator.load(
|
||||
QLocale(), "qt", "_", QLibraryInfo::location(QLibraryInfo::TranslationsPath));
|
||||
app.installTranslator(&qtTranslator);
|
||||
|
||||
QTranslator appTranslator;
|
||||
appTranslator.load("nheko_" + lang, ":/translations");
|
||||
appTranslator.load(QLocale(), "nheko", "_", ":/translations");
|
||||
app.installTranslator(&appTranslator);
|
||||
|
||||
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 "CacheCryptoStructs.h"
|
||||
#include "ReactionsModel.h"
|
||||
#include "EventStore.h"
|
||||
|
||||
namespace mtx::http {
|
||||
using RequestErr = const std::optional<mtx::http::ClientError> &;
|
||||
|
@ -42,6 +42,8 @@ enum EventType
|
|||
CallAnswer,
|
||||
/// m.call.hangup
|
||||
CallHangUp,
|
||||
/// m.call.candidates
|
||||
CallCandidates,
|
||||
/// m.room.canonical_alias
|
||||
CanonicalAlias,
|
||||
/// m.room.create
|
||||
|
@ -177,7 +179,7 @@ public:
|
|||
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;
|
||||
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;
|
||||
void fetchMore(const QModelIndex &) override;
|
||||
|
@ -204,6 +206,15 @@ public:
|
|||
Q_INVOKABLE void cacheMedia(QString eventId);
|
||||
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 addEvents(const mtx::responses::Timeline &events);
|
||||
template<class T>
|
||||
|
@ -214,7 +225,7 @@ public slots:
|
|||
void setCurrentIndex(int index);
|
||||
int currentIndex() const { return idToIndex(currentId); }
|
||||
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)
|
||||
{
|
||||
if (this->typingUsers_ != users) {
|
||||
|
@ -240,36 +251,26 @@ public slots:
|
|||
}
|
||||
}
|
||||
void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
|
||||
void clearTimeline() { events.clearTimeline(); }
|
||||
|
||||
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);
|
||||
|
||||
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 redactionFailed(QString id);
|
||||
void eventRedacted(QString id);
|
||||
void nextPendingMessage();
|
||||
void newMessageToSend(mtx::events::collections::TimelineEvents event);
|
||||
void mediaCached(QString mxcUrl, QString cacheUrl);
|
||||
void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
|
||||
void eventFetched(QString requestingEvent, mtx::events::collections::TimelineEvents event);
|
||||
void typingUsersChanged(std::vector<QString> users);
|
||||
void replyChanged(QString reply);
|
||||
void paginationInProgressChanged(const bool);
|
||||
void newCallEvent(const mtx::events::collections::TimelineEvents &event);
|
||||
|
||||
void newMessageToSend(mtx::events::collections::TimelineEvents event);
|
||||
void addPendingMessageToStore(mtx::events::collections::TimelineEvents event);
|
||||
|
||||
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,
|
||||
nlohmann::json content,
|
||||
mtx::events::EventType);
|
||||
|
@ -283,16 +284,12 @@ private:
|
|||
|
||||
void setPaginationInProgress(const bool paginationInProgress);
|
||||
|
||||
QHash<QString, mtx::events::collections::TimelineEvents> events;
|
||||
QSet<QString> read;
|
||||
QList<QString> pending;
|
||||
std::vector<QString> eventOrder;
|
||||
std::map<QString, ReactionsModel> reactions;
|
||||
|
||||
mutable EventStore events;
|
||||
|
||||
QString room_id_;
|
||||
QString prev_batch_token_;
|
||||
|
||||
bool isInitialSync = true;
|
||||
bool decryptDescription = true;
|
||||
bool m_paginationInProgress = false;
|
||||
|
||||
|
|
|
@ -340,35 +340,38 @@ TimelineViewManager::queueEmoteMessage(const QString &msg)
|
|||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::reactToMessage(const QString &roomId,
|
||||
const QString &reactedEvent,
|
||||
const QString &reactionKey,
|
||||
const QString &selfReactedEvent)
|
||||
TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey)
|
||||
{
|
||||
if (!timeline_)
|
||||
return;
|
||||
|
||||
auto reactions = timeline_->reactions(reactedEvent.toStdString());
|
||||
|
||||
QString selfReactedEvent;
|
||||
for (const auto &reaction : reactions) {
|
||||
if (reactionKey == reaction.key_) {
|
||||
selfReactedEvent = reaction.selfReactedEvent_;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selfReactedEvent.startsWith("m"))
|
||||
return;
|
||||
|
||||
// If selfReactedEvent is empty, that means we haven't previously reacted
|
||||
if (selfReactedEvent.isEmpty()) {
|
||||
queueReactionMessage(roomId, reactedEvent, reactionKey);
|
||||
mtx::events::msg::Reaction reaction;
|
||||
reaction.relates_to.rel_type = mtx::common::RelationType::Annotation;
|
||||
reaction.relates_to.event_id = reactedEvent.toStdString();
|
||||
reaction.relates_to.key = reactionKey.toStdString();
|
||||
|
||||
timeline_->sendMessageEvent(reaction, mtx::events::EventType::Reaction);
|
||||
// Otherwise, we have previously reacted and the reaction should be redacted
|
||||
} else {
|
||||
auto model = models.value(roomId);
|
||||
model->redactEvent(selfReactedEvent);
|
||||
timeline_->redactEvent(selfReactedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueReactionMessage(const QString &roomId,
|
||||
const QString &reactedEvent,
|
||||
const QString &reactionKey)
|
||||
{
|
||||
mtx::events::msg::Reaction reaction;
|
||||
reaction.relates_to.rel_type = mtx::common::RelationType::Annotation;
|
||||
reaction.relates_to.event_id = reactedEvent.toStdString();
|
||||
reaction.relates_to.key = reactionKey.toStdString();
|
||||
|
||||
auto model = models.value(roomId);
|
||||
model->sendMessageEvent(reaction, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueImageMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
|
@ -384,10 +387,13 @@ TimelineViewManager::queueImageMessage(const QString &roomid,
|
|||
image.info.size = dsize;
|
||||
image.info.blurhash = blurhash.toStdString();
|
||||
image.body = filename.toStdString();
|
||||
image.url = url.toStdString();
|
||||
image.info.h = dimensions.height();
|
||||
image.info.w = dimensions.width();
|
||||
image.file = file;
|
||||
|
||||
if (file)
|
||||
image.file = file;
|
||||
else
|
||||
image.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
|
@ -411,8 +417,11 @@ TimelineViewManager::queueFileMessage(
|
|||
file.info.mimetype = mime.toStdString();
|
||||
file.info.size = dsize;
|
||||
file.body = filename.toStdString();
|
||||
file.url = url.toStdString();
|
||||
file.file = encryptedFile;
|
||||
|
||||
if (encryptedFile)
|
||||
file.file = encryptedFile;
|
||||
else
|
||||
file.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
|
@ -436,7 +445,11 @@ TimelineViewManager::queueAudioMessage(const QString &roomid,
|
|||
audio.info.size = dsize;
|
||||
audio.body = filename.toStdString();
|
||||
audio.url = url.toStdString();
|
||||
audio.file = file;
|
||||
|
||||
if (file)
|
||||
audio.file = file;
|
||||
else
|
||||
audio.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
|
@ -459,8 +472,11 @@ TimelineViewManager::queueVideoMessage(const QString &roomid,
|
|||
video.info.mimetype = mime.toStdString();
|
||||
video.info.size = dsize;
|
||||
video.body = filename.toStdString();
|
||||
video.url = url.toStdString();
|
||||
video.file = file;
|
||||
|
||||
if (file)
|
||||
video.file = file;
|
||||
else
|
||||
video.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
|
|
|
@ -66,13 +66,7 @@ public slots:
|
|||
|
||||
void setHistoryView(const QString &room_id);
|
||||
void updateColorPalette();
|
||||
void queueReactionMessage(const QString &roomId,
|
||||
const QString &reactedEvent,
|
||||
const QString &reaction);
|
||||
void reactToMessage(const QString &roomId,
|
||||
const QString &reactedEvent,
|
||||
const QString &reactionKey,
|
||||
const QString &selfReactedEvent);
|
||||
void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey);
|
||||
void queueTextMessage(const QString &msg);
|
||||
void queueEmoteMessage(const QString &msg);
|
||||
void queueImageMessage(const QString &roomid,
|
||||
|
@ -108,6 +102,12 @@ public slots:
|
|||
|
||||
void updateEncryptedDescriptions();
|
||||
|
||||
void clearCurrentRoomTimeline()
|
||||
{
|
||||
if (timeline_)
|
||||
timeline_->clearTimeline();
|
||||
}
|
||||
|
||||
private:
|
||||
#ifdef USE_QUICK_VIEW
|
||||
QQuickView *view;
|
||||
|
|
Loading…
Reference in a new issue