Initial support for read receipts

This commit is contained in:
Konstantinos Sideris 2018-01-03 18:05:49 +02:00
parent 44ee1b549d
commit eaf05748ff
19 changed files with 434 additions and 33 deletions

View File

@ -146,6 +146,7 @@ set(SRC_FILES
src/dialogs/JoinRoom.cc
src/dialogs/LeaveRoom.cc
src/dialogs/Logout.cc
src/dialogs/ReadReceipts.cc
# Emoji
src/emoji/Category.cc
@ -226,6 +227,7 @@ qt5_wrap_cpp(MOC_HEADERS
include/dialogs/JoinRoom.h
include/dialogs/LeaveRoom.h
include/dialogs/Logout.h
include/dialogs/ReadReceipts.h
# Emoji
include/emoji/Category.h

View File

@ -36,7 +36,7 @@ class AvatarProvider : public QObject
public:
static void init(QSharedPointer<MatrixClient> client);
static void resolve(const QString &userId, TimelineItem *item);
static void resolve(const QString &userId, std::function<void(QImage)> callback);
static void setAvatarUrl(const QString &userId, const QUrl &url);
static void clear();
@ -48,5 +48,5 @@ private:
using UserID = QString;
static QMap<UserID, AvatarData> avatars_;
static QMap<UserID, QList<TimelineItem *>> toBeResolved_;
static QMap<UserID, QList<std::function<void(QImage)>>> toBeResolved_;
};

View File

@ -18,11 +18,52 @@
#pragma once
#include <QDir>
#include <json.hpp>
#include <lmdb++.h>
#include <mtx/responses.hpp>
class RoomState;
//! Used to uniquely identify a list of read receipts.
struct ReadReceiptKey
{
std::string event_id;
std::string room_id;
};
inline void
to_json(json &j, const ReadReceiptKey &key)
{
j = json{{"event_id", key.event_id}, {"room_id", key.room_id}};
}
inline void
from_json(const json &j, ReadReceiptKey &key)
{
key.event_id = j.at("event_id").get<std::string>();
key.room_id = j.at("room_id").get<std::string>();
}
//! Decribes a read receipt stored in cache.
struct ReadReceiptValue
{
std::string user_id;
uint64_t ts;
};
inline void
to_json(json &j, const ReadReceiptValue &value)
{
j = json{{"user_id", value.user_id}, {"ts", value.ts}};
}
inline void
from_json(const json &j, ReadReceiptValue &value)
{
value.user_id = j.at("user_id").get<std::string>();
value.ts = j.at("ts").get<uint64_t>();
}
class Cache
{
public:
@ -48,6 +89,19 @@ public:
bool isFormatValid();
void setCurrentFormat();
//! Adds a user to the read list for the given event.
//!
//! There should be only one user id present in a receipt list per room.
//! The user id should be removed from any other lists.
using Receipts = std::map<std::string, std::map<std::string, uint64_t>>;
void updateReadReceipt(const std::string &room_id, const Receipts &receipts);
//! Retrieve all the read receipts for the given event id and room.
//!
//! Returns a map of user ids and the time of the read receipt in milliseconds.
using UserReceipts = std::multimap<uint64_t, std::string>;
UserReceipts readReceipts(const QString &event_id, const QString &room_id);
QByteArray image(const QString &url) const;
void saveImage(const QString &url, const QByteArray &data);
@ -60,6 +114,7 @@ private:
lmdb::dbi roomDb_;
lmdb::dbi invitesDb_;
lmdb::dbi imagesDb_;
lmdb::dbi readReceiptsDb_;
bool isMounted_;

View File

@ -42,6 +42,10 @@ class TypingDisplay;
class UserInfoWidget;
class UserSettings;
namespace dialogs {
class ReadReceipts;
}
constexpr int CONSENSUS_TIMEOUT = 1000;
constexpr int SHOW_CONTENT_TIMEOUT = 3000;
constexpr int TYPING_REFRESH_TIMEOUT = 10000;
@ -59,6 +63,9 @@ public:
// Initialize all the components of the UI.
void bootstrap(QString userid, QString homeserver, QString token);
void showQuickSwitcher();
void showReadReceipts(const QString &event_id);
static ChatPage *instance() { return instance_; }
signals:
void contentLoaded();
@ -84,6 +91,8 @@ private slots:
void removeInvite(const QString &room_id);
private:
static ChatPage *instance_;
using UserID = QString;
using RoomStates = QMap<UserID, RoomState>;
using Membership = mtx::events::StateEvent<mtx::events::state::Member>;
@ -150,6 +159,9 @@ private:
QSharedPointer<QuickSwitcher> quickSwitcher_;
QSharedPointer<OverlayModal> quickSwitcherModal_;
QSharedPointer<dialogs::ReadReceipts> receiptsDialog_;
QSharedPointer<OverlayModal> receiptsModal_;
// Matrix Client API provider.
QSharedPointer<MatrixClient> client_;

View File

@ -15,6 +15,10 @@ static constexpr int emojiSize = 14;
static constexpr int headerFontSize = 21;
static constexpr int typingNotificationFontSize = 11;
namespace receipts {
static constexpr int font = 12;
}
namespace dialogs {
static constexpr int labelSize = 15;
}

View File

@ -42,7 +42,7 @@ public:
explicit MainWindow(QWidget *parent = 0);
~MainWindow();
static MainWindow *instance();
static MainWindow *instance() { return instance_; };
void saveCurrentWindowSize();
protected:

View File

@ -0,0 +1,50 @@
#pragma once
#include <QDateTime>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QVBoxLayout>
class Avatar;
namespace dialogs {
class ReceiptItem : public QWidget
{
Q_OBJECT
public:
ReceiptItem(QWidget *parent, const QString &user_id, uint64_t timestamp);
private:
QString dateFormat(const QDateTime &then) const;
QHBoxLayout *topLayout_;
QVBoxLayout *textLayout_;
Avatar *avatar_;
QLabel *userName_;
QLabel *timestamp_;
};
class ReadReceipts : public QFrame
{
Q_OBJECT
public:
explicit ReadReceipts(QWidget *parent = nullptr);
public slots:
void addUsers(const std::multimap<uint64_t, std::string> &users);
protected:
void paintEvent(QPaintEvent *event) override;
private:
QLabel *topLabel_;
QListWidget *userList_;
};
} // dialogs

View File

@ -87,6 +87,7 @@ public:
protected:
void paintEvent(QPaintEvent *event) override;
void contextMenuEvent(QContextMenuEvent *event) override;
private:
void init();
@ -116,6 +117,9 @@ private:
DescInfo descriptionMsg_;
QMenu *receiptsMenu_;
QAction *showReadReceipts_;
QHBoxLayout *topLayout_;
QVBoxLayout *sideLayout_; // Avatar or Timestamp
QVBoxLayout *mainLayout_; // Header & Message body
@ -156,7 +160,7 @@ TimelineItem::setupLocalWidgetLayout(Widget *widget,
setupAvatarLayout(displayName);
mainLayout_->addLayout(headerLayout_);
AvatarProvider::resolve(userid, this);
AvatarProvider::resolve(userid, [=](const QImage &img) { setUserAvatar(img); });
} else {
setupSimpleLayout();
}
@ -199,7 +203,7 @@ TimelineItem::setupWidgetLayout(Widget *widget,
mainLayout_->addLayout(headerLayout_);
AvatarProvider::resolve(sender, this);
AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
} else {
setupSimpleLayout();
}

View File

@ -18,6 +18,7 @@
#pragma once
#include <QGraphicsOpacityEffect>
#include <QKeyEvent>
#include <QPaintEvent>
#include <QPropertyAnimation>
@ -37,6 +38,7 @@ public:
protected:
void paintEvent(QPaintEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private:
int duration_;

@ -1 +1 @@
Subproject commit acb732474665343174209f0518c33a7ca0eb504a
Subproject commit 3555ec1cfc3865e0ae47c0fa53c9ea00f1e7cb36

View File

@ -84,6 +84,7 @@ dialogs--Logout,
dialogs--LeaveRoom,
dialogs--CreateRoom,
dialogs--InviteUsers,
dialogs--ReadReceipts,
dialogs--JoinRoom {
background-color: #383c4a;
color: #caccd1;

View File

@ -86,6 +86,7 @@ dialogs--Logout,
dialogs--LeaveRoom,
dialogs--CreateRoom,
dialogs--InviteUsers,
dialogs--ReadReceipts,
dialogs--JoinRoom {
background-color: white;
color: #333;

View File

@ -18,12 +18,10 @@
#include "AvatarProvider.h"
#include "MatrixClient.h"
#include "timeline/TimelineItem.h"
QSharedPointer<MatrixClient> AvatarProvider::client_;
QMap<QString, AvatarData> AvatarProvider::avatars_;
QMap<QString, QList<TimelineItem *>> AvatarProvider::toBeResolved_;
QMap<QString, QList<std::function<void(QImage)>>> AvatarProvider::toBeResolved_;
void
AvatarProvider::init(QSharedPointer<MatrixClient> client)
@ -37,11 +35,11 @@ void
AvatarProvider::updateAvatar(const QString &uid, const QImage &img)
{
if (toBeResolved_.contains(uid)) {
auto items = toBeResolved_[uid];
auto callbacks = toBeResolved_[uid];
// Update all the timeline items with the resolved avatar.
for (const auto item : items)
item->setUserAvatar(img);
for (const auto callback : callbacks)
callback(img);
toBeResolved_.remove(uid);
}
@ -53,7 +51,7 @@ AvatarProvider::updateAvatar(const QString &uid, const QImage &img)
}
void
AvatarProvider::resolve(const QString &userId, TimelineItem *item)
AvatarProvider::resolve(const QString &userId, std::function<void(QImage)> callback)
{
if (!avatars_.contains(userId))
return;
@ -61,7 +59,7 @@ AvatarProvider::resolve(const QString &userId, TimelineItem *item)
auto img = avatars_[userId].img;
if (!img.isNull()) {
item->setUserAvatar(img);
callback(img);
return;
}
@ -69,12 +67,12 @@ AvatarProvider::resolve(const QString &userId, TimelineItem *item)
if (!toBeResolved_.contains(userId)) {
client_->fetchUserAvatar(userId, avatars_[userId].url);
QList<TimelineItem *> timelineItems;
timelineItems.push_back(item);
QList<std::function<void(QImage)>> items;
items.push_back(callback);
toBeResolved_.insert(userId, timelineItems);
toBeResolved_.insert(userId, items);
} else {
toBeResolved_[userId].push_back(item);
toBeResolved_[userId].push_back(callback);
}
}

View File

@ -36,6 +36,7 @@ Cache::Cache(const QString &userId)
, roomDb_{0}
, invitesDb_{0}
, imagesDb_{0}
, readReceiptsDb_{0}
, isMounted_{false}
, userId_{userId}
{}
@ -89,11 +90,12 @@ Cache::setup()
env_.open(statePath.toStdString().c_str());
}
auto txn = lmdb::txn::begin(env_);
stateDb_ = lmdb::dbi::open(txn, "state", MDB_CREATE);
roomDb_ = lmdb::dbi::open(txn, "rooms", MDB_CREATE);
invitesDb_ = lmdb::dbi::open(txn, "invites", MDB_CREATE);
imagesDb_ = lmdb::dbi::open(txn, "images", MDB_CREATE);
auto txn = lmdb::txn::begin(env_);
stateDb_ = lmdb::dbi::open(txn, "state", MDB_CREATE);
roomDb_ = lmdb::dbi::open(txn, "rooms", MDB_CREATE);
invitesDb_ = lmdb::dbi::open(txn, "invites", MDB_CREATE);
imagesDb_ = lmdb::dbi::open(txn, "images", MDB_CREATE);
readReceiptsDb_ = lmdb::dbi::open(txn, "read_receipts", MDB_CREATE);
txn.commit();
@ -449,3 +451,94 @@ Cache::setInvites(const std::map<std::string, mtx::responses::InvitedRoom> &invi
unmount();
}
}
std::multimap<uint64_t, std::string>
Cache::readReceipts(const QString &event_id, const QString &room_id)
{
std::multimap<uint64_t, std::string> receipts;
ReadReceiptKey receipt_key{event_id.toStdString(), room_id.toStdString()};
nlohmann::json json_key = receipt_key;
try {
auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
auto key = json_key.dump();
lmdb::val value;
bool res =
lmdb::dbi_get(txn, readReceiptsDb_, lmdb::val(key.data(), key.size()), value);
txn.commit();
if (res) {
auto json_response = json::parse(std::string(value.data(), value.size()));
auto values = json_response.get<std::vector<ReadReceiptValue>>();
for (auto v : values)
receipts.emplace(v.ts, v.user_id);
}
} catch (const lmdb::error &e) {
qCritical() << "readReceipts:" << e.what();
}
return receipts;
}
using Receipts = std::map<std::string, std::map<std::string, uint64_t>>;
void
Cache::updateReadReceipt(const std::string &room_id, const Receipts &receipts)
{
for (auto receipt : receipts) {
const auto event_id = receipt.first;
auto event_receipts = receipt.second;
ReadReceiptKey receipt_key{event_id, room_id};
nlohmann::json json_key = receipt_key;
try {
auto read_txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
const auto key = json_key.dump();
lmdb::val prev_value;
bool exists = lmdb::dbi_get(
read_txn, readReceiptsDb_, lmdb::val(key.data(), key.size()), prev_value);
read_txn.commit();
std::vector<ReadReceiptValue> saved_receipts;
// If an entry for the event id already exists, we would
// merge the existing receipts with the new ones.
if (exists) {
auto json_value =
json::parse(std::string(prev_value.data(), prev_value.size()));
// Retrieve the saved receipts.
saved_receipts = json_value.get<std::vector<ReadReceiptValue>>();
}
// Append the new ones.
for (auto event_receipt : event_receipts)
saved_receipts.push_back(
ReadReceiptValue{event_receipt.first, event_receipt.second});
// Save back the merged (or only the new) receipts.
nlohmann::json json_updated_value = saved_receipts;
std::string merged_receipts = json_updated_value.dump();
auto txn = lmdb::txn::begin(env_);
lmdb::dbi_put(txn,
readReceiptsDb_,
lmdb::val(key.data(), key.size()),
lmdb::val(merged_receipts.data(), merged_receipts.size()));
txn.commit();
} catch (const lmdb::error &e) {
qCritical() << "updateReadReceipts:" << e.what();
}
}
}

View File

@ -39,11 +39,14 @@
#include "UserInfoWidget.h"
#include "UserSettingsPage.h"
#include "dialogs/ReadReceipts.h"
#include "timeline/TimelineViewManager.h"
constexpr int MAX_INITIAL_SYNC_FAILURES = 5;
constexpr int SYNC_RETRY_TIMEOUT = 10000;
ChatPage *ChatPage::instance_ = nullptr;
ChatPage::ChatPage(QSharedPointer<MatrixClient> client,
QSharedPointer<UserSettings> userSettings,
QWidget *parent)
@ -302,6 +305,8 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client,
});
AvatarProvider::init(client);
instance_ = this;
}
void
@ -734,6 +739,12 @@ ChatPage::updateJoinedRooms(const std::map<std::string, mtx::responses::JoinedRo
updateTypingUsers(roomid, it->second.ephemeral.typing);
if (it->second.ephemeral.receipts.size() > 0)
QtConcurrent::run(cache_.data(),
&Cache::updateReadReceipt,
it->first,
it->second.ephemeral.receipts);
const auto newStateEvents = it->second.state;
const auto newTimelineEvents = it->second.timeline;
@ -809,4 +820,25 @@ ChatPage::generateMembershipDifference(
return stateDiff;
}
void
ChatPage::showReadReceipts(const QString &event_id)
{
if (receiptsDialog_.isNull()) {
receiptsDialog_ = QSharedPointer<dialogs::ReadReceipts>(
new dialogs::ReadReceipts(this),
[=](dialogs::ReadReceipts *dialog) { dialog->deleteLater(); });
}
if (receiptsModal_.isNull()) {
receiptsModal_ = QSharedPointer<OverlayModal>(
new OverlayModal(MainWindow::instance(), receiptsDialog_.data()),
[=](OverlayModal *modal) { modal->deleteLater(); });
receiptsModal_->setDuration(0);
receiptsModal_->setColor(QColor(30, 30, 30, 170));
}
receiptsDialog_->addUsers(cache_->readReceipts(event_id, current_room_));
receiptsModal_->fadeIn();
}
ChatPage::~ChatPage() {}

View File

@ -281,10 +281,4 @@ MainWindow::hasActiveUser()
settings.contains("auth/user_id");
}
MainWindow *
MainWindow::instance()
{
return instance_;
}
MainWindow::~MainWindow() {}

124
src/dialogs/ReadReceipts.cc Normal file
View File

@ -0,0 +1,124 @@
#include <QDebug>
#include <QIcon>
#include <QListWidgetItem>
#include <QPainter>
#include <QStyleOption>
#include <QTimer>
#include <QVBoxLayout>
#include "Config.h"
#include "Avatar.h"
#include "AvatarProvider.h"
#include "dialogs/ReadReceipts.h"
#include "timeline/TimelineViewManager.h"
using namespace dialogs;
ReceiptItem::ReceiptItem(QWidget *parent, const QString &user_id, uint64_t timestamp)
: QWidget(parent)
{
topLayout_ = new QHBoxLayout(this);
topLayout_->setMargin(0);
textLayout_ = new QVBoxLayout;
textLayout_->setMargin(0);
textLayout_->setSpacing(5);
QFont font;
font.setPixelSize(conf::receipts::font);
auto displayName = TimelineViewManager::displayName(user_id);
avatar_ = new Avatar(this);
avatar_->setSize(40);
avatar_->setLetter(QChar(displayName[0]));
// If it's a matrix id we use the second letter.
if (displayName.size() > 1 && displayName.at(0) == '@')
avatar_->setLetter(QChar(displayName.at(1)));
userName_ = new QLabel(displayName, this);
userName_->setFont(font);
timestamp_ = new QLabel(dateFormat(QDateTime::fromMSecsSinceEpoch(timestamp)), this);
timestamp_->setFont(font);
textLayout_->addWidget(userName_);
textLayout_->addWidget(timestamp_);
topLayout_->addWidget(avatar_);
topLayout_->addLayout(textLayout_, 1);
AvatarProvider::resolve(user_id, [=](const QImage &img) { avatar_->setImage(img); });
}
QString
ReceiptItem::dateFormat(const QDateTime &then) const
{
auto now = QDateTime::currentDateTime();
auto days = then.daysTo(now);
if (days == 0)
return QString("Today %1").arg(then.toString("HH:mm"));
else if (days < 2)
return QString("Yesterday %1").arg(then.toString("HH::mm"));
else if (days < 365)
return then.toString("dd/MM HH:mm");
return then.toString("dd/MM/yy");
}
ReadReceipts::ReadReceipts(QWidget *parent)
: QFrame(parent)
{
setMaximumSize(400, 350);
auto layout = new QVBoxLayout(this);
layout->setSpacing(30);
layout->setMargin(20);
userList_ = new QListWidget;
userList_->setFrameStyle(QFrame::NoFrame);
userList_->setSelectionMode(QAbstractItemView::NoSelection);
userList_->setAttribute(Qt::WA_MacShowFocusRect, 0);
userList_->setSpacing(5);
QFont font;
font.setPixelSize(conf::headerFontSize);
topLabel_ = new QLabel(tr("Read receipts"), this);
topLabel_->setAlignment(Qt::AlignCenter);
topLabel_->setFont(font);
layout->addWidget(topLabel_);
layout->addWidget(userList_);
}
void
ReadReceipts::addUsers(const std::multimap<uint64_t, std::string> &receipts)
{
// We want to remove any previous items that have been set.
userList_->clear();
for (auto receipt : receipts) {
auto user =
new ReceiptItem(this, QString::fromStdString(receipt.second), receipt.first);
auto item = new QListWidgetItem(userList_);
item->setSizeHint(user->minimumSizeHint());
item->setFlags(Qt::NoItemFlags);
item->setTextAlignment(Qt::AlignCenter);
userList_->setItemWidget(item, user);
}
}
void
ReadReceipts::paintEvent(QPaintEvent *)
{
QStyleOption opt;
opt.init(this);
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}

View File

@ -15,10 +15,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QContextMenuEvent>
#include <QFontDatabase>
#include <QMenu>
#include <QTextEdit>
#include "Avatar.h"
#include "ChatPage.h"
#include "Config.h"
#include "timeline/TimelineItem.h"
@ -39,6 +42,14 @@ TimelineItem::init()
QFontMetrics fm(font_);
receiptsMenu_ = new QMenu(this);
showReadReceipts_ = new QAction("Read receipts", this);
receiptsMenu_->addAction(showReadReceipts_);
connect(showReadReceipts_, &QAction::triggered, this, [=]() {
if (!event_id_.isEmpty())
ChatPage::instance()->showReadReceipts(event_id_);
});
topLayout_ = new QHBoxLayout(this);
sideLayout_ = new QVBoxLayout;
mainLayout_ = new QVBoxLayout;
@ -88,7 +99,7 @@ TimelineItem::TimelineItem(mtx::events::MessageType ty,
setupAvatarLayout(displayName);
mainLayout_->addLayout(headerLayout_);
AvatarProvider::resolve(userid, this);
AvatarProvider::resolve(userid, [=](const QImage &img) { setUserAvatar(img); });
} else {
generateBody(body);
setupSimpleLayout();
@ -213,7 +224,7 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Notice
mainLayout_->addLayout(headerLayout_);
AvatarProvider::resolve(sender, this);
AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
} else {
generateBody(body);
setupSimpleLayout();
@ -252,7 +263,7 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Emote>
setupAvatarLayout(displayName);
mainLayout_->addLayout(headerLayout_);
AvatarProvider::resolve(sender, this);
AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
} else {
generateBody(emoteMsg);
setupSimpleLayout();
@ -297,7 +308,7 @@ TimelineItem::TimelineItem(const mtx::events::RoomEvent<mtx::events::msg::Text>
mainLayout_->addLayout(headerLayout_);
AvatarProvider::resolve(sender, this);
AvatarProvider::resolve(sender, [=](const QImage &img) { setUserAvatar(img); });
} else {
generateBody(body);
setupSimpleLayout();
@ -471,6 +482,13 @@ TimelineItem::descriptiveTime(const QDateTime &then)
TimelineItem::~TimelineItem() {}
void
TimelineItem::contextMenuEvent(QContextMenuEvent *event)
{
if (receiptsMenu_)
receiptsMenu_->exec(event->globalPos());
}
void
TimelineItem::paintEvent(QPaintEvent *)
{

View File

@ -47,6 +47,8 @@ OverlayModal::OverlayModal(QWidget *parent, QWidget *content)
if (animation_->direction() == QAbstractAnimation::Forward)
this->close();
});
content->setFocus();
}
void
@ -72,3 +74,12 @@ OverlayModal::fadeOut()
animation_->setDirection(QAbstractAnimation::Forward);
animation_->start();
}
void
OverlayModal::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Escape) {
event->accept();
fadeOut();
}
}