This commit is contained in:
Jussi Kuokkanen 2020-08-28 23:35:40 +03:00
commit 5e344d2685
39 changed files with 4094 additions and 1224 deletions

View file

@ -253,7 +253,8 @@ set(SRC_FILES
# Timeline # Timeline
src/timeline/ReactionsModel.cpp src/timeline/EventStore.cpp
src/timeline/Reaction.cpp
src/timeline/TimelineViewManager.cpp src/timeline/TimelineViewManager.cpp
src/timeline/TimelineModel.cpp src/timeline/TimelineModel.cpp
src/timeline/DelegateChooser.cpp src/timeline/DelegateChooser.cpp
@ -341,7 +342,7 @@ if(USE_BUNDLED_MTXCLIENT)
FetchContent_Declare( FetchContent_Declare(
MatrixClient MatrixClient
GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
GIT_TAG 744018c86a8094acbda9821d6d7b5a890d4aac47 GIT_TAG d8666a3f1a5b709b78ccea2b545d540a8cb502ca
) )
FetchContent_MakeAvailable(MatrixClient) FetchContent_MakeAvailable(MatrixClient)
else() else()
@ -465,7 +466,8 @@ qt5_wrap_cpp(MOC_HEADERS
src/emoji/Provider.h src/emoji/Provider.h
# Timeline # Timeline
src/timeline/ReactionsModel.h src/timeline/EventStore.h
src/timeline/Reaction.h
src/timeline/TimelineViewManager.h src/timeline/TimelineViewManager.h
src/timeline/TimelineModel.h src/timeline/TimelineModel.h
src/timeline/DelegateChooser.h src/timeline/DelegateChooser.h

View file

@ -75,6 +75,14 @@ sudo eselect repository enable matrix
sudo emerge -a nheko sudo emerge -a nheko
``` ```
#### Nix(os)
```bash
nix-env -iA nixpkgs.nheko
# or
nix-shell -p nheko --run nheko
```
#### Alpine Linux (and postmarketOS) #### Alpine Linux (and postmarketOS)
Make sure you have the testing repositories from `edge` enabled. Note that this is not needed on postmarketOS. Make sure you have the testing repositories from `edge` enabled. Note that this is not needed on postmarketOS.

View file

@ -146,7 +146,7 @@
"name": "mtxclient", "name": "mtxclient",
"sources": [ "sources": [
{ {
"commit": "744018c86a8094acbda9821d6d7b5a890d4aac47", "commit": "d8666a3f1a5b709b78ccea2b545d540a8cb502ca",
"type": "git", "type": "git",
"url": "https://github.com/Nheko-Reborn/mtxclient.git" "url": "https://github.com/Nheko-Reborn/mtxclient.git"
} }

View file

@ -198,7 +198,7 @@
<location filename="../qml/emoji/EmojiPicker.qml" line="+117"/> <location filename="../qml/emoji/EmojiPicker.qml" line="+117"/>
<location line="+139"/> <location line="+139"/>
<source>Search</source> <source>Search</source>
<translation type="unfinished"></translation> <translation>Search</translation>
</message> </message>
<message> <message>
<location line="-42"/> <location line="-42"/>

1815
resources/langs/nheko_ro.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ TextEdit {
readOnly: true readOnly: true
wrapMode: Text.Wrap wrapMode: Text.Wrap
selectByMouse: true selectByMouse: true
activeFocusOnPress: false
color: colors.text color: colors.text
onLinkActivated: { onLinkActivated: {

View file

@ -30,11 +30,11 @@ Flow {
implicitHeight: contentItem.childrenRect.height implicitHeight: contentItem.childrenRect.height
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.text: model.users ToolTip.text: modelData.users
onClicked: { onClicked: {
console.debug("Picked " + model.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + model.selfReactedEvent) console.debug("Picked " + modelData.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + modelData.selfReactedEvent)
timelineManager.reactToMessage(reactionFlow.roomId, reactionFlow.eventId, model.key, model.selfReactedEvent) timelineManager.queueReactionMessage(reactionFlow.eventId, modelData.key)
} }
@ -49,13 +49,13 @@ Flow {
font.family: settings.emojiFont font.family: settings.emojiFont
elide: Text.ElideRight elide: Text.ElideRight
elideWidth: 150 elideWidth: 150
text: model.key text: modelData.key
} }
Text { Text {
anchors.baseline: reactionCounter.baseline anchors.baseline: reactionCounter.baseline
id: reactionText id: reactionText
text: textMetrics.elidedText + (textMetrics.elidedText == model.key ? "" : "…") text: textMetrics.elidedText + (textMetrics.elidedText == modelData.key ? "" : "…")
font.family: settings.emojiFont font.family: settings.emojiFont
color: reaction.hovered ? colors.highlight : colors.text color: reaction.hovered ? colors.highlight : colors.text
maximumLineCount: 1 maximumLineCount: 1
@ -65,13 +65,13 @@ Flow {
id: divider id: divider
height: Math.floor(reactionCounter.implicitHeight * 1.4) height: Math.floor(reactionCounter.implicitHeight * 1.4)
width: 1 width: 1
color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text
} }
Text { Text {
anchors.verticalCenter: divider.verticalCenter anchors.verticalCenter: divider.verticalCenter
id: reactionCounter id: reactionCounter
text: model.counter text: modelData.count
font: reaction.font font: reaction.font
color: reaction.hovered ? colors.highlight : colors.text color: reaction.hovered ? colors.highlight : colors.text
} }
@ -82,8 +82,8 @@ Flow {
implicitWidth: reaction.implicitWidth implicitWidth: reaction.implicitWidth
implicitHeight: reaction.implicitHeight implicitHeight: reaction.implicitHeight
border.color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text border.color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text
color: model.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base color: modelData.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base
border.width: 1 border.width: 1
radius: reaction.height / 2.0 radius: reaction.height / 2.0
} }

View file

@ -106,6 +106,6 @@ MouseArea {
//How long the scrollbar will remain visible //How long the scrollbar will remain visible
interval: 500 interval: 500
// Hide the scrollbars // Hide the scrollbars
onTriggered: flickable.cancelFlick(); onTriggered: { flickable.cancelFlick(); flickable.movementEnded(); }
} }
} }

View file

@ -8,22 +8,25 @@ import im.nheko 1.0
import "./delegates" import "./delegates"
import "./emoji" import "./emoji"
MouseArea { Item {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
height: row.height height: row.height
propagateComposedEvents: true
preventStealing: true
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton MouseArea {
onClicked: { anchors.fill: parent
if (mouse.button === Qt.RightButton) propagateComposedEvents: true
messageContextMenu.show(model.id, model.type, model.isEncrypted, row) preventStealing: true
} hoverEnabled: true
onPressAndHold: {
if (mouse.source === Qt.MouseEventNotSynthesized) acceptedButtons: Qt.AllButtons
messageContextMenu.show(model.id, model.type, model.isEncrypted, row) 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 { Rectangle {
color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent" color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent"
@ -45,7 +48,7 @@ MouseArea {
// fancy reply, if this is a reply // fancy reply, if this is a reply
Reply { Reply {
visible: model.replyTo visible: model.replyTo
modelData: chat.model.getDump(model.replyTo) modelData: chat.model.getDump(model.replyTo, model.id)
userColor: timelineManager.userColor(modelData.userId, colors.window) userColor: timelineManager.userColor(modelData.userId, colors.window)
} }
@ -90,7 +93,6 @@ MouseArea {
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.text: qsTr("React") ToolTip.text: qsTr("React")
emojiPicker: emojiPopup emojiPicker: emojiPopup
room_id: model.roomId
event_id: model.id event_id: model.id
} }
ImageButton { ImageButton {
@ -128,6 +130,7 @@ MouseArea {
Label { Label {
Layout.alignment: Qt.AlignRight | Qt.AlignTop Layout.alignment: Qt.AlignRight | Qt.AlignTop
text: model.timestamp.toLocaleTimeString("HH:mm") text: model.timestamp.toLocaleTimeString("HH:mm")
width: Math.max(implicitWidth, text.length*fontMetrics.maximumCharacterWidth)
color: inactiveColors.text color: inactiveColors.text
MouseArea{ MouseArea{

View file

@ -11,6 +11,8 @@ import "./delegates"
import "./emoji" import "./emoji"
Page { Page {
id: timelineRoot
property var colors: currentActivePalette property var colors: currentActivePalette
property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled } property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled }
property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive
@ -25,34 +27,39 @@ Page {
id: fontMetrics id: fontMetrics
} }
EmojiPicker { EmojiPicker {
id: emojiPopup id: emojiPopup
width: 7 * 52 + 20 width: 7 * 52 + 20
height: 6 * 52 height: 6 * 52
colors: palette colors: palette
model: EmojiProxyModel { model: EmojiProxyModel {
category: EmojiCategory.People category: EmojiCategory.People
sourceModel: EmojiModel {} sourceModel: EmojiModel {}
} }
} }
Menu { Menu {
id: messageContextMenu id: messageContextMenu
modal: true modal: true
function show(eventId_, eventType_, isEncrypted_, showAt) { function show(eventId_, eventType_, isEncrypted_, showAt_, position) {
eventId = eventId_ eventId = eventId_
eventType = eventType_ eventType = eventType_
isEncrypted = isEncrypted_ isEncrypted = isEncrypted_
popup(showAt)
if (position)
popup(position, showAt_)
else
popup(showAt_)
} }
property string eventId property string eventId
property int eventType property int eventType
property bool isEncrypted property bool isEncrypted
MenuItem { MenuItem {
text: qsTr("React") text: qsTr("React")
onClicked: chat.model.reactAction(messageContextMenu.eventId) onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId)
} }
MenuItem { MenuItem {
text: qsTr("Reply") text: qsTr("Reply")
@ -87,8 +94,6 @@ Page {
} }
} }
id: timelineRoot
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: colors.window color: colors.window
@ -113,7 +118,7 @@ Page {
ListView { ListView {
id: chat id: chat
visible: timelineManager.timeline != null visible: !!timelineManager.timeline
cacheBuffer: 400 cacheBuffer: 400
@ -181,7 +186,7 @@ Page {
id: wrapper id: wrapper
property Item section property Item section
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
width: chat.delegateMaxWidth width: chat.delegateMaxWidth
height: section ? section.height + timelinerow.height : timelinerow.height height: section ? section.height + timelinerow.height : timelinerow.height
color: "transparent" color: "transparent"
@ -205,14 +210,13 @@ Page {
} }
} }
Binding { Connections {
target: chat.model target: chat
property: "currentIndex" function onMovementEnded() {
when: y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
value: index chat.model.currentIndex = index;
delayed: true }
} }
} }
section { section {
@ -296,13 +300,13 @@ Page {
} }
} }
footer: BusyIndicator { footer: BusyIndicator {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
running: chat.model && chat.model.paginationInProgress running: chat.model && chat.model.paginationInProgress
height: 50 height: 50
width: 50 width: 50
z: 3 z: 3
} }
} }
Rectangle { Rectangle {
@ -354,7 +358,7 @@ Page {
anchors.rightMargin: 20 anchors.rightMargin: 20
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
modelData: chat.model ? chat.model.getDump(chat.model.reply) : {} modelData: chat.model ? chat.model.getDump(chat.model.reply, chat.model.id) : {}
userColor: timelineManager.userColor(modelData.userId, colors.window) userColor: timelineManager.userColor(modelData.userId, colors.window)
} }

View file

@ -9,8 +9,8 @@ Item {
property double divisor: model.isReply ? 4 : 2 property double divisor: model.isReply ? 4 : 2
property bool tooHigh: tempHeight > timelineRoot.height / divisor property bool tooHigh: tempHeight > timelineRoot.height / divisor
height: tooHigh ? timelineRoot.height / divisor : tempHeight height: Math.round(tooHigh ? timelineRoot.height / divisor : tempHeight)
width: tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth width: Math.round(tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth)
Image { Image {
id: blurhash id: blurhash

View file

@ -66,6 +66,12 @@ Item {
text: qsTr("redacted") text: qsTr("redacted")
} }
} }
DelegateChoice {
roleValue: MtxEvent.Redaction
Pill {
text: qsTr("redacted")
}
}
DelegateChoice { DelegateChoice {
roleValue: MtxEvent.Encryption roleValue: MtxEvent.Encryption
Pill { Pill {
@ -108,6 +114,12 @@ Item {
text: qsTr("%1 ended the call.").arg(model.data.userName) text: qsTr("%1 ended the call.").arg(model.data.userName)
} }
} }
DelegateChoice {
roleValue: MtxEvent.CallCandidates
NoticeMessage {
text: qsTr("Negotiating call...")
}
}
DelegateChoice { DelegateChoice {
// TODO: make a more complex formatter for the power levels. // TODO: make a more complex formatter for the power levels.
roleValue: MtxEvent.PowerLevels roleValue: MtxEvent.PowerLevels

View file

@ -9,7 +9,7 @@ Rectangle {
id: bg id: bg
radius: 10 radius: 10
color: colors.dark color: colors.dark
height: content.height + 24 height: Math.round(content.height + 24)
width: parent ? parent.width : undefined width: parent ? parent.width : undefined
Column { Column {

View file

@ -4,7 +4,7 @@ MatrixText {
property string formatted: model.data.formattedBody property string formatted: model.data.formattedBody
text: "<style type=\"text/css\">a { color:"+colors.link+";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>") text: "<style type=\"text/css\">a { color:"+colors.link+";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>")
width: parent ? parent.width : undefined width: parent ? parent.width : undefined
height: isReply ? Math.min(chat.height / 8, implicitHeight) : undefined height: isReply ? Math.round(Math.min(timelineRoot.height / 8, implicitHeight)) : undefined
clip: true clip: true
font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize
} }

View file

@ -8,11 +8,10 @@ import "../"
ImageButton { ImageButton {
property var colors: currentActivePalette property var colors: currentActivePalette
property var emojiPicker property var emojiPicker
property string room_id
property string event_id property string event_id
image: ":/icons/icons/ui/smile.png" image: ":/icons/icons/ui/smile.png"
id: emojiButton id: emojiButton
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, room_id, event_id) onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id)
} }

View file

@ -10,17 +10,17 @@ import "../"
Popup { Popup {
function show(showAt, room_id, event_id) { function show(showAt, event_id) {
console.debug("Showing emojiPicker for " + event_id + "in room " + room_id) console.debug("Showing emojiPicker for " + event_id)
parent = showAt if (showAt){
x = Math.round((showAt.width - width) / 2) parent = showAt
y = showAt.height x = Math.round((showAt.width - width) / 2)
emojiPopup.room_id = room_id y = showAt.height
emojiPopup.event_id = event_id }
open() emojiPopup.event_id = event_id
} open()
}
property string room_id
property string event_id property string event_id
property var colors property var colors
property alias model: gridView.model property alias model: gridView.model
@ -102,9 +102,9 @@ Popup {
} }
// TODO: maybe add favorites at some point? // TODO: maybe add favorites at some point?
onClicked: { onClicked: {
console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id + " in room " + emojiPopup.room_id) console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id)
emojiPopup.close() emojiPopup.close()
timelineManager.queueReactionMessage(emojiPopup.room_id, emojiPopup.event_id, model.unicode) timelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode)
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@
#pragma once #pragma once
#include <limits>
#include <optional> #include <optional>
#include <QDateTime> #include <QDateTime>
@ -38,9 +39,6 @@
#include "CacheCryptoStructs.h" #include "CacheCryptoStructs.h"
#include "CacheStructs.h" #include "CacheStructs.h"
int
numeric_key_comparison(const MDB_val *a, const MDB_val *b);
class Cache : public QObject class Cache : public QObject
{ {
Q_OBJECT Q_OBJECT
@ -172,6 +170,47 @@ public:
//! Add all notifications containing a user mention to the db. //! Add all notifications containing a user mention to the db.
void saveTimelineMentions(const mtx::responses::Notifications &res); void saveTimelineMentions(const mtx::responses::Notifications &res);
//! retrieve events in timeline and related functions
struct Messages
{
mtx::responses::Timeline timeline;
uint64_t next_index;
bool end_of_cache = false;
};
Messages getTimelineMessages(lmdb::txn &txn,
const std::string &room_id,
uint64_t index = std::numeric_limits<uint64_t>::max(),
bool forward = false);
std::optional<mtx::events::collections::TimelineEvent> getEvent(
const std::string &room_id,
const std::string &event_id);
void storeEvent(const std::string &room_id,
const std::string &event_id,
const mtx::events::collections::TimelineEvent &event);
std::vector<std::string> relatedEvents(const std::string &room_id,
const std::string &event_id);
struct TimelineRange
{
uint64_t first, last;
};
std::optional<TimelineRange> getTimelineRange(const std::string &room_id);
std::optional<uint64_t> getTimelineIndex(const std::string &room_id,
std::string_view event_id);
std::optional<std::string> getTimelineEventId(const std::string &room_id, uint64_t index);
std::string previousBatchToken(const std::string &room_id);
uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res);
void savePendingMessage(const std::string &room_id,
const mtx::events::collections::TimelineEvent &message);
std::optional<mtx::events::collections::TimelineEvent> firstPendingMessage(
const std::string &room_id);
void removePendingStatus(const std::string &room_id, const std::string &txn_id);
//! clear timeline keeping only the latest batch
void clearTimeline(const std::string &room_id);
//! Remove old unused data. //! Remove old unused data.
void deleteOldMessages(); void deleteOldMessages();
void deleteOldData() noexcept; void deleteOldData() noexcept;
@ -250,8 +289,6 @@ private:
const std::string &room_id, const std::string &room_id,
const mtx::responses::Timeline &res); const mtx::responses::Timeline &res);
mtx::responses::Timeline getTimelineMessages(lmdb::txn &txn, const std::string &room_id);
//! Remove a room from the cache. //! Remove a room from the cache.
// void removeLeftRoom(lmdb::txn &txn, const std::string &room_id); // void removeLeftRoom(lmdb::txn &txn, const std::string &room_id);
template<class T> template<class T>
@ -402,13 +439,46 @@ private:
return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE); return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE);
} }
lmdb::dbi getMessagesDb(lmdb::txn &txn, const std::string &room_id) lmdb::dbi getEventsDb(lmdb::txn &txn, const std::string &room_id)
{ {
auto db = return lmdb::dbi::open(txn, std::string(room_id + "/events").c_str(), MDB_CREATE);
lmdb::dbi::open(txn, std::string(room_id + "/messages").c_str(), MDB_CREATE); }
lmdb::dbi_set_compare(txn, db, numeric_key_comparison);
return db; lmdb::dbi getEventOrderDb(lmdb::txn &txn, const std::string &room_id)
{
return lmdb::dbi::open(
txn, std::string(room_id + "/event_order").c_str(), MDB_CREATE | MDB_INTEGERKEY);
}
// inverse of EventOrderDb
lmdb::dbi getEventToOrderDb(lmdb::txn &txn, const std::string &room_id)
{
return lmdb::dbi::open(
txn, std::string(room_id + "/event2order").c_str(), MDB_CREATE);
}
lmdb::dbi getMessageToOrderDb(lmdb::txn &txn, const std::string &room_id)
{
return lmdb::dbi::open(
txn, std::string(room_id + "/msg2order").c_str(), MDB_CREATE);
}
lmdb::dbi getOrderToMessageDb(lmdb::txn &txn, const std::string &room_id)
{
return lmdb::dbi::open(
txn, std::string(room_id + "/order2msg").c_str(), MDB_CREATE | MDB_INTEGERKEY);
}
lmdb::dbi getPendingMessagesDb(lmdb::txn &txn, const std::string &room_id)
{
return lmdb::dbi::open(
txn, std::string(room_id + "/pending").c_str(), MDB_CREATE | MDB_INTEGERKEY);
}
lmdb::dbi getRelationsDb(lmdb::txn &txn, const std::string &room_id)
{
return lmdb::dbi::open(
txn, std::string(room_id + "/related").c_str(), MDB_CREATE | MDB_DUPSORT);
} }
lmdb::dbi getInviteStatesDb(lmdb::txn &txn, const std::string &room_id) lmdb::dbi getInviteStatesDb(lmdb::txn &txn, const std::string &room_id)

View file

@ -165,6 +165,11 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
trySync(); trySync();
}); });
connect(text_input_,
&TextInputWidget::clearRoomTimeline,
view_manager_,
&TimelineViewManager::clearCurrentRoomTimeline);
connect( connect(
new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() { new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
if (isVisible()) if (isVisible())
@ -254,7 +259,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView); room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) { connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) {
view_manager_->addRoom(room_id);
joinRoom(room_id); joinRoom(room_id);
room_list_->removeRoom(room_id, currentRoom() == room_id); room_list_->removeRoom(room_id, currentRoom() == room_id);
}); });
@ -323,17 +327,15 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
.toStdString(); .toStdString();
member.membership = mtx::events::state::Membership::Join; member.membership = mtx::events::state::Membership::Join;
http::client() http::client()->send_state_event(
->send_state_event<mtx::events::state::Member, currentRoom().toStdString(),
mtx::events::EventType::RoomMember>( http::client()->user_id().to_string(),
currentRoom().toStdString(), member,
http::client()->user_id().to_string(), [](mtx::responses::EventId, mtx::http::RequestErr err) {
member, if (err)
[](mtx::responses::EventId, mtx::http::RequestErr err) { nhlog::net()->error("Failed to set room displayname: {}",
if (err) err->matrix_error.error);
nhlog::net()->error("Failed to set room displayname: {}", });
err->matrix_error.error);
});
}); });
connect( connect(
@ -584,12 +586,8 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
emit notificationsRetrieved(std::move(res)); emit notificationsRetrieved(std::move(res));
}); });
}); });
connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync, Qt::QueuedConnection); connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync);
connect(this, connect(this, &ChatPage::syncTags, communitiesList_, &CommunitiesList::syncTags);
&ChatPage::syncTags,
communitiesList_,
&CommunitiesList::syncTags,
Qt::QueuedConnection);
connect( connect(
this, &ChatPage::syncTopBar, this, [this](const std::map<QString, RoomInfo> &updates) { this, &ChatPage::syncTopBar, this, [this](const std::map<QString, RoomInfo> &updates) {
if (updates.find(currentRoom()) != updates.end()) if (updates.find(currentRoom()) != updates.end())
@ -614,6 +612,12 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
[this]() { QTimer::singleShot(RETRY_TIMEOUT, this, &ChatPage::trySync); }, [this]() { QTimer::singleShot(RETRY_TIMEOUT, this, &ChatPage::trySync); },
Qt::QueuedConnection); Qt::QueuedConnection);
connect(this,
&ChatPage::newSyncResponse,
this,
&ChatPage::handleSyncResponse,
Qt::QueuedConnection);
connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage); connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
connectCallMessage<mtx::events::msg::CallInvite>(); connectCallMessage<mtx::events::msg::CallInvite>();
@ -841,43 +845,39 @@ ChatPage::loadStateFromCache()
nhlog::db()->info("restoring state from cache"); 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(); getProfileInfo();
QtConcurrent::run([this]() { // Start receiving events.
try { emit trySyncCb();
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();
});
} }
void void
@ -1055,6 +1055,45 @@ ChatPage::startInitialSync()
&ChatPage::initialSyncHandler, this, std::placeholders::_1, std::placeholders::_2)); &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 void
ChatPage::trySync() ChatPage::trySync()
{ {
@ -1072,7 +1111,14 @@ ChatPage::trySync()
} }
http::client()->sync( 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) { if (err) {
const auto error = QString::fromStdString(err->matrix_error.error); const auto error = QString::fromStdString(err->matrix_error.error);
const auto msg = tr("Please try to login again: %1").arg(error); const auto msg = tr("Please try to login again: %1").arg(error);
@ -1094,40 +1140,7 @@ ChatPage::trySync()
return; return;
} }
nhlog::net()->debug("sync completed: {}", res.next_batch); emit newSyncResponse(res);
// 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();
}); });
} }

View file

@ -140,6 +140,7 @@ signals:
void trySyncCb(); void trySyncCb();
void tryDelayedSyncCb(); void tryDelayedSyncCb();
void tryInitialSyncCb(); void tryInitialSyncCb();
void newSyncResponse(mtx::responses::Sync res);
void leftRoom(const QString &room_id); void leftRoom(const QString &room_id);
void initializeRoomList(QMap<QString, RoomInfo>); void initializeRoomList(QMap<QString, RoomInfo>);
@ -174,6 +175,7 @@ private slots:
void joinRoom(const QString &room); void joinRoom(const QString &room);
void sendTypingNotifications(); void sendTypingNotifications();
void handleSyncResponse(mtx::responses::Sync res);
private: private:
static ChatPage *instance_; static ChatPage *instance_;

View file

@ -248,6 +248,20 @@ struct EventInReplyTo
} }
}; };
struct EventRelatesTo
{
template<class Content>
using related_ev_id_t = decltype(Content::relates_to.event_id);
template<class T>
std::string operator()(const mtx::events::Event<T> &e)
{
if constexpr (is_detected<related_ev_id_t, T>::value) {
return e.content.relates_to.event_id;
}
return "";
}
};
struct EventTransactionId struct EventTransactionId
{ {
template<class T> template<class T>
@ -409,6 +423,11 @@ mtx::accessors::in_reply_to_event(const mtx::events::collections::TimelineEvents
{ {
return std::visit(EventInReplyTo{}, event); return std::visit(EventInReplyTo{}, event);
} }
std::string
mtx::accessors::relates_to_event_id(const mtx::events::collections::TimelineEvents &event)
{
return std::visit(EventRelatesTo{}, event);
}
std::string std::string
mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event) mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event)

View file

@ -56,6 +56,8 @@ mimetype(const mtx::events::collections::TimelineEvents &event);
std::string std::string
in_reply_to_event(const mtx::events::collections::TimelineEvents &event); in_reply_to_event(const mtx::events::collections::TimelineEvents &event);
std::string std::string
relates_to_event_id(const mtx::events::collections::TimelineEvents &event);
std::string
transaction_id(const mtx::events::collections::TimelineEvents &event); transaction_id(const mtx::events::collections::TimelineEvents &event);
int64_t int64_t

View file

@ -3,6 +3,7 @@
#include "Olm.h" #include "Olm.h"
#include "Cache.h" #include "Cache.h"
#include "Cache_p.h"
#include "Logging.h" #include "Logging.h"
#include "MatrixClient.h" #include "MatrixClient.h"
#include "Utils.h" #include "Utils.h"
@ -316,32 +317,36 @@ send_key_request_for(const std::string &room_id,
using namespace mtx::events; using namespace mtx::events;
nhlog::crypto()->debug("sending key request: {}", json(e).dump(2)); nhlog::crypto()->debug("sending key request: {}", json(e).dump(2));
auto payload = json{{"action", "request"},
{"request_id", http::client()->generate_txn_id()},
{"requesting_device_id", http::client()->device_id()},
{"body",
{{"algorithm", MEGOLM_ALGO},
{"room_id", room_id},
{"sender_key", e.content.sender_key},
{"session_id", e.content.session_id}}}};
json body; mtx::events::msg::KeyRequest request;
body["messages"][e.sender] = json::object(); request.action = mtx::events::msg::RequestAction::Request;
body["messages"][e.sender][e.content.device_id] = payload; request.algorithm = MEGOLM_ALGO;
request.room_id = room_id;
request.sender_key = e.content.sender_key;
request.session_id = e.content.session_id;
request.request_id = "key_request." + http::client()->generate_txn_id();
request.requesting_device_id = http::client()->device_id();
nhlog::crypto()->debug("m.room_key_request: {}", body.dump(2)); nhlog::crypto()->debug("m.room_key_request: {}", json(request).dump(2));
http::client()->send_to_device("m.room_key_request", body, [e](mtx::http::RequestErr err) { std::map<mtx::identifiers::User, std::map<std::string, decltype(request)>> body;
if (err) { body[mtx::identifiers::parse<mtx::identifiers::User>(e.sender)][e.content.device_id] =
nhlog::net()->warn("failed to send " request;
"send_to_device " body[http::client()->user_id()]["*"] = request;
"message: {}",
err->matrix_error.error);
}
nhlog::net()->info( http::client()->send_to_device(
"m.room_key_request sent to {}:{}", e.sender, e.content.device_id); 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 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 } // namespace olm

View file

@ -7,10 +7,30 @@
#include <mtx/events/encrypted.hpp> #include <mtx/events/encrypted.hpp>
#include <mtxclient/crypto/client.hpp> #include <mtxclient/crypto/client.hpp>
#include <CacheCryptoStructs.h>
constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2"; constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2";
namespace olm { namespace olm {
enum class DecryptionErrorCode
{
MissingSession, // Session was not found, retrieve from backup or request from other devices
// and try again
DbError, // DB read failed
DecryptionFailed, // libolm error
ParsingFailed, // Failed to parse the actual event
ReplayAttack, // Megolm index reused
UnknownFingerprint, // Unknown device Fingerprint
};
struct DecryptionResult
{
std::optional<DecryptionErrorCode> error;
std::optional<std::string> error_message;
std::optional<mtx::events::collections::TimelineEvents> event;
};
struct OlmMessage struct OlmMessage
{ {
std::string sender_key; std::string sender_key;
@ -65,6 +85,10 @@ encrypt_group_message(const std::string &room_id,
const std::string &device_id, const std::string &device_id,
nlohmann::json body); nlohmann::json body);
DecryptionResult
decryptEvent(const MegolmSessionIndex &index,
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &event);
void void
mark_keys_as_published(); mark_keys_as_published();

View file

@ -683,27 +683,29 @@ void
TextInputWidget::command(QString command, QString args) TextInputWidget::command(QString command, QString args)
{ {
if (command == "me") { if (command == "me") {
sendEmoteMessage(args); emit sendEmoteMessage(args);
} else if (command == "join") { } else if (command == "join") {
sendJoinRoomRequest(args); emit sendJoinRoomRequest(args);
} else if (command == "invite") { } else if (command == "invite") {
sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); emit sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "kick") { } else if (command == "kick") {
sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "ban") { } else if (command == "ban") {
sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); emit sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "unban") { } else if (command == "unban") {
sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "roomnick") { } else if (command == "roomnick") {
changeRoomNick(args); emit changeRoomNick(args);
} else if (command == "shrug") { } else if (command == "shrug") {
sendTextMessage("¯\\_(ツ)_/¯"); emit sendTextMessage("¯\\_(ツ)_/¯");
} else if (command == "fliptable") { } else if (command == "fliptable") {
sendTextMessage("(╯°□°)╯︵ ┻━┻"); emit sendTextMessage("(╯°□°) ");
} else if (command == "unfliptable") { } else if (command == "unfliptable") {
sendTextMessage(" ┯━┯╭( º _ º╭)"); emit sendTextMessage(" ┯━┯╭( º _ º╭)");
} else if (command == "sovietflip") { } else if (command == "sovietflip") {
sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\"); emit sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
} else if (command == "clear-timeline") {
emit clearRoomTimeline();
} }
} }
@ -735,7 +737,7 @@ TextInputWidget::showUploadSpinner()
topLayout_->removeWidget(sendFileBtn_); topLayout_->removeWidget(sendFileBtn_);
sendFileBtn_->hide(); sendFileBtn_->hide();
topLayout_->insertWidget(0, spinner_); topLayout_->insertWidget(1, spinner_);
spinner_->start(); spinner_->start();
} }
@ -743,7 +745,7 @@ void
TextInputWidget::hideUploadSpinner() TextInputWidget::hideUploadSpinner()
{ {
topLayout_->removeWidget(spinner_); topLayout_->removeWidget(spinner_);
topLayout_->insertWidget(0, sendFileBtn_); topLayout_->insertWidget(1, sendFileBtn_);
sendFileBtn_->show(); sendFileBtn_->show();
spinner_->stop(); spinner_->stop();
} }

View file

@ -180,6 +180,7 @@ private slots:
signals: signals:
void sendTextMessage(const QString &msg); void sendTextMessage(const QString &msg);
void sendEmoteMessage(QString msg); void sendEmoteMessage(QString msg);
void clearRoomTimeline();
void heightChanged(int height); void heightChanged(int height);
void uploadMedia(const QSharedPointer<QIODevice> data, void uploadMedia(const QSharedPointer<QIODevice> data,

View file

@ -223,18 +223,19 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
{ {
nhlog::ui()->debug("WebRTC: local candidate: (m-line:{}):{}", mlineIndex, candidate); nhlog::ui()->debug("WebRTC: local candidate: (m-line:{}):{}", mlineIndex, candidate);
#if GST_CHECK_VERSION(1, 17, 0)
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
return;
#else
if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) { if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) {
emit WebRTCSession::instance().newICECandidate( emit WebRTCSession::instance().newICECandidate(
{"audio", (uint16_t)mlineIndex, candidate}); {"audio", (uint16_t)mlineIndex, candidate});
return; return;
} }
localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate});
// GStreamer v1.16: webrtcbin's notify::ice-gathering-state triggers // GStreamer v1.16: webrtcbin's notify::ice-gathering-state triggers
// GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.17. // GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.17.
// Use a 100ms timeout in the meantime // Use a 100ms timeout in the meantime
#if !GST_CHECK_VERSION(1, 17, 0)
static guint timerid = 0; static guint timerid = 0;
if (timerid) if (timerid)
g_source_remove(timerid); g_source_remove(timerid);
@ -282,11 +283,11 @@ linkNewPad(GstElement *decodebin G_GNUC_UNUSED, GstPad *newpad, GstElement *pipe
GstElement *resample = gst_element_factory_make("audioresample", nullptr); GstElement *resample = gst_element_factory_make("audioresample", nullptr);
GstElement *sink = gst_element_factory_make("autoaudiosink", nullptr); GstElement *sink = gst_element_factory_make("autoaudiosink", nullptr);
gst_bin_add_many(GST_BIN(pipe), queue, convert, resample, sink, nullptr); gst_bin_add_many(GST_BIN(pipe), queue, convert, resample, sink, nullptr);
gst_element_link_many(queue, convert, resample, sink, nullptr);
gst_element_sync_state_with_parent(queue); gst_element_sync_state_with_parent(queue);
gst_element_sync_state_with_parent(convert); gst_element_sync_state_with_parent(convert);
gst_element_sync_state_with_parent(resample); gst_element_sync_state_with_parent(resample);
gst_element_sync_state_with_parent(sink); gst_element_sync_state_with_parent(sink);
gst_element_link_many(queue, convert, resample, sink, nullptr);
queuepad = gst_element_get_static_pad(queue, "sink"); queuepad = gst_element_get_static_pad(queue, "sink");
} }

View file

@ -151,7 +151,7 @@ EditModal::applyClicked()
state::Name body; state::Name body;
body.name = newName.toStdString(); body.name = newName.toStdString();
http::client()->send_state_event<state::Name, EventType::RoomName>( http::client()->send_state_event(
roomId_.toStdString(), roomId_.toStdString(),
body, body,
[proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) { [proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
@ -169,7 +169,7 @@ EditModal::applyClicked()
state::Topic body; state::Topic body;
body.topic = newTopic.toStdString(); body.topic = newTopic.toStdString();
http::client()->send_state_event<state::Topic, EventType::RoomTopic>( http::client()->send_state_event(
roomId_.toStdString(), roomId_.toStdString(),
body, body,
[proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) { [proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) {
@ -694,7 +694,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
startLoadingSpinner(); startLoadingSpinner();
resetErrorLabel(); resetErrorLabel();
http::client()->send_state_event<state::JoinRules, EventType::RoomJoinRules>( http::client()->send_state_event(
room_id, room_id,
join_rule, join_rule,
[this, room_id, guest_access](const mtx::responses::EventId &, [this, room_id, guest_access](const mtx::responses::EventId &,
@ -708,7 +708,7 @@ RoomSettings::updateAccessRules(const std::string &room_id,
return; return;
} }
http::client()->send_state_event<state::GuestAccess, EventType::RoomGuestAccess>( http::client()->send_state_event(
room_id, room_id,
guest_access, guest_access,
[this](const mtx::responses::EventId &, mtx::http::RequestErr err) { [this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
@ -843,7 +843,7 @@ RoomSettings::updateAvatar()
avatar_event.image_info.size = size; avatar_event.image_info.size = size;
avatar_event.url = res.content_uri; avatar_event.url = res.content_uri;
http::client()->send_state_event<state::Avatar, EventType::RoomAvatar>( http::client()->send_state_event(
room_id, room_id,
avatar_event, avatar_event,
[content = std::move(content), proxy = std::move(proxy)]( [content = std::move(content), proxy = std::move(proxy)](

View file

@ -173,11 +173,12 @@ main(int argc, char *argv[])
QString lang = QLocale::system().name(); QString lang = QLocale::system().name();
QTranslator qtTranslator; QTranslator qtTranslator;
qtTranslator.load("qt_" + lang, QLibraryInfo::location(QLibraryInfo::TranslationsPath)); qtTranslator.load(
QLocale(), "qt", "_", QLibraryInfo::location(QLibraryInfo::TranslationsPath));
app.installTranslator(&qtTranslator); app.installTranslator(&qtTranslator);
QTranslator appTranslator; QTranslator appTranslator;
appTranslator.load("nheko_" + lang, ":/translations"); appTranslator.load(QLocale(), "nheko", "_", ":/translations");
app.installTranslator(&appTranslator); app.installTranslator(&appTranslator);
MainWindow w; MainWindow w;

570
src/timeline/EventStore.cpp Normal file
View 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)
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
View 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;
};

View file

@ -0,0 +1 @@
#include "Reaction.h"

24
src/timeline/Reaction.h Normal file
View 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_;
};

View file

@ -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++;
}
}

View file

@ -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

View file

@ -9,7 +9,7 @@
#include <mtxclient/http/errors.hpp> #include <mtxclient/http/errors.hpp>
#include "CacheCryptoStructs.h" #include "CacheCryptoStructs.h"
#include "ReactionsModel.h" #include "EventStore.h"
namespace mtx::http { namespace mtx::http {
using RequestErr = const std::optional<mtx::http::ClientError> &; using RequestErr = const std::optional<mtx::http::ClientError> &;
@ -42,6 +42,8 @@ enum EventType
CallAnswer, CallAnswer,
/// m.call.hangup /// m.call.hangup
CallHangUp, CallHangUp,
/// m.call.candidates
CallCandidates,
/// m.room.canonical_alias /// m.room.canonical_alias
CanonicalAlias, CanonicalAlias,
/// m.room.create /// m.room.create
@ -177,7 +179,7 @@ public:
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant data(const QString &id, int role) const; QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const;
bool canFetchMore(const QModelIndex &) const override; bool canFetchMore(const QModelIndex &) const override;
void fetchMore(const QModelIndex &) override; void fetchMore(const QModelIndex &) override;
@ -204,6 +206,15 @@ public:
Q_INVOKABLE void cacheMedia(QString eventId); Q_INVOKABLE void cacheMedia(QString eventId);
Q_INVOKABLE bool saveMedia(QString eventId) const; Q_INVOKABLE bool saveMedia(QString eventId) const;
std::vector<::Reaction> reactions(const std::string &event_id)
{
auto list = events.reactions(event_id);
std::vector<::Reaction> vec;
for (const auto &r : list)
vec.push_back(r.value<Reaction>());
return vec;
}
void updateLastMessage(); void updateLastMessage();
void addEvents(const mtx::responses::Timeline &events); void addEvents(const mtx::responses::Timeline &events);
template<class T> template<class T>
@ -214,7 +225,7 @@ public slots:
void setCurrentIndex(int index); void setCurrentIndex(int index);
int currentIndex() const { return idToIndex(currentId); } int currentIndex() const { return idToIndex(currentId); }
void markEventsAsRead(const std::vector<QString> &event_ids); void markEventsAsRead(const std::vector<QString> &event_ids);
QVariantMap getDump(QString eventId) const; QVariantMap getDump(QString eventId, QString relatedTo) const;
void updateTypingUsers(const std::vector<QString> &users) void updateTypingUsers(const std::vector<QString> &users)
{ {
if (this->typingUsers_ != users) { if (this->typingUsers_ != users) {
@ -240,36 +251,26 @@ public slots:
} }
} }
void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; } void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
void clearTimeline() { events.clearTimeline(); }
private slots: private slots:
// Add old events at the top of the timeline.
void addBackwardsEvents(const mtx::responses::Messages &msgs);
void processOnePendingMessage();
void addPendingMessage(mtx::events::collections::TimelineEvents event); void addPendingMessage(mtx::events::collections::TimelineEvents event);
signals: signals:
void oldMessagesRetrieved(const mtx::responses::Messages &res);
void messageFailed(QString txn_id);
void messageSent(QString txn_id, QString event_id);
void currentIndexChanged(int index); void currentIndexChanged(int index);
void redactionFailed(QString id); void redactionFailed(QString id);
void eventRedacted(QString id); void eventRedacted(QString id);
void nextPendingMessage();
void newMessageToSend(mtx::events::collections::TimelineEvents event);
void mediaCached(QString mxcUrl, QString cacheUrl); void mediaCached(QString mxcUrl, QString cacheUrl);
void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo); void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
void eventFetched(QString requestingEvent, mtx::events::collections::TimelineEvents event);
void typingUsersChanged(std::vector<QString> users); void typingUsersChanged(std::vector<QString> users);
void replyChanged(QString reply); void replyChanged(QString reply);
void paginationInProgressChanged(const bool); void paginationInProgressChanged(const bool);
void newCallEvent(const mtx::events::collections::TimelineEvents &event); void newCallEvent(const mtx::events::collections::TimelineEvents &event);
void newMessageToSend(mtx::events::collections::TimelineEvents event);
void addPendingMessageToStore(mtx::events::collections::TimelineEvents event);
private: private:
DecryptionResult decryptEvent(
const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e) const;
std::vector<QString> internalAddEvents(
const std::vector<mtx::events::collections::TimelineEvents> &timeline,
bool emitCallEvents);
void sendEncryptedMessageEvent(const std::string &txn_id, void sendEncryptedMessageEvent(const std::string &txn_id,
nlohmann::json content, nlohmann::json content,
mtx::events::EventType); mtx::events::EventType);
@ -283,16 +284,12 @@ private:
void setPaginationInProgress(const bool paginationInProgress); void setPaginationInProgress(const bool paginationInProgress);
QHash<QString, mtx::events::collections::TimelineEvents> events;
QSet<QString> read; QSet<QString> read;
QList<QString> pending;
std::vector<QString> eventOrder; mutable EventStore events;
std::map<QString, ReactionsModel> reactions;
QString room_id_; QString room_id_;
QString prev_batch_token_;
bool isInitialSync = true;
bool decryptDescription = true; bool decryptDescription = true;
bool m_paginationInProgress = false; bool m_paginationInProgress = false;

View file

@ -340,35 +340,38 @@ TimelineViewManager::queueEmoteMessage(const QString &msg)
} }
void void
TimelineViewManager::reactToMessage(const QString &roomId, TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey)
const QString &reactedEvent,
const QString &reactionKey,
const QString &selfReactedEvent)
{ {
if (!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 is empty, that means we haven't previously reacted
if (selfReactedEvent.isEmpty()) { 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 // Otherwise, we have previously reacted and the reaction should be redacted
} else { } else {
auto model = models.value(roomId); timeline_->redactEvent(selfReactedEvent);
model->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 void
TimelineViewManager::queueImageMessage(const QString &roomid, TimelineViewManager::queueImageMessage(const QString &roomid,
const QString &filename, const QString &filename,
@ -384,10 +387,13 @@ TimelineViewManager::queueImageMessage(const QString &roomid,
image.info.size = dsize; image.info.size = dsize;
image.info.blurhash = blurhash.toStdString(); image.info.blurhash = blurhash.toStdString();
image.body = filename.toStdString(); image.body = filename.toStdString();
image.url = url.toStdString();
image.info.h = dimensions.height(); image.info.h = dimensions.height();
image.info.w = dimensions.width(); image.info.w = dimensions.width();
image.file = file;
if (file)
image.file = file;
else
image.url = url.toStdString();
auto model = models.value(roomid); auto model = models.value(roomid);
if (!model->reply().isEmpty()) { if (!model->reply().isEmpty()) {
@ -411,8 +417,11 @@ TimelineViewManager::queueFileMessage(
file.info.mimetype = mime.toStdString(); file.info.mimetype = mime.toStdString();
file.info.size = dsize; file.info.size = dsize;
file.body = filename.toStdString(); file.body = filename.toStdString();
file.url = url.toStdString();
file.file = encryptedFile; if (encryptedFile)
file.file = encryptedFile;
else
file.url = url.toStdString();
auto model = models.value(roomid); auto model = models.value(roomid);
if (!model->reply().isEmpty()) { if (!model->reply().isEmpty()) {
@ -436,7 +445,11 @@ TimelineViewManager::queueAudioMessage(const QString &roomid,
audio.info.size = dsize; audio.info.size = dsize;
audio.body = filename.toStdString(); audio.body = filename.toStdString();
audio.url = url.toStdString(); audio.url = url.toStdString();
audio.file = file;
if (file)
audio.file = file;
else
audio.url = url.toStdString();
auto model = models.value(roomid); auto model = models.value(roomid);
if (!model->reply().isEmpty()) { if (!model->reply().isEmpty()) {
@ -459,8 +472,11 @@ TimelineViewManager::queueVideoMessage(const QString &roomid,
video.info.mimetype = mime.toStdString(); video.info.mimetype = mime.toStdString();
video.info.size = dsize; video.info.size = dsize;
video.body = filename.toStdString(); video.body = filename.toStdString();
video.url = url.toStdString();
video.file = file; if (file)
video.file = file;
else
video.url = url.toStdString();
auto model = models.value(roomid); auto model = models.value(roomid);
if (!model->reply().isEmpty()) { if (!model->reply().isEmpty()) {

View file

@ -66,13 +66,7 @@ public slots:
void setHistoryView(const QString &room_id); void setHistoryView(const QString &room_id);
void updateColorPalette(); void updateColorPalette();
void queueReactionMessage(const QString &roomId, void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey);
const QString &reactedEvent,
const QString &reaction);
void reactToMessage(const QString &roomId,
const QString &reactedEvent,
const QString &reactionKey,
const QString &selfReactedEvent);
void queueTextMessage(const QString &msg); void queueTextMessage(const QString &msg);
void queueEmoteMessage(const QString &msg); void queueEmoteMessage(const QString &msg);
void queueImageMessage(const QString &roomid, void queueImageMessage(const QString &roomid,
@ -108,6 +102,12 @@ public slots:
void updateEncryptedDescriptions(); void updateEncryptedDescriptions();
void clearCurrentRoomTimeline()
{
if (timeline_)
timeline_->clearTimeline();
}
private: private:
#ifdef USE_QUICK_VIEW #ifdef USE_QUICK_VIEW
QQuickView *view; QQuickView *view;