diff --git a/CMakeLists.txt b/CMakeLists.txt
index 30e5885ab..181bcdace 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -64,6 +64,8 @@ if (Qt5_POSITION_INDEPENDENT_CODE)
SET(CMAKE_POSITION_INDEPENDENT_CODE ON)
endif()
+set(QML_IMPORT_PATH ${CMAKE_SOURCE_DIR}/qml ${CMAKE_SOURCE_DIR}/imports CACHE string "" FORCE)
+
if(WIN32)
enable_language(RC)
include(CMakeDetermineRCCompiler)
@@ -174,6 +176,7 @@ QT5_ADD_RESOURCES(spectral_QRC_SRC ${spectral_QRC})
set_property(SOURCE qrc_resources.cpp PROPERTY SKIP_AUTOMOC ON)
if(WIN32)
+ set(spectral_WINRC spectral_win32.rc)
set_property(SOURCE spectral_win32.rc APPEND PROPERTY
OBJECT_DEPENDS ${PROJECT_SOURCE_DIR}/icons/icon.ico
)
diff --git a/imports/Spectral/Component/Timeline/MessageDelegate.qml b/imports/Spectral/Component/Timeline/MessageDelegate.qml
index 597e46e77..b793a3409 100644
--- a/imports/Spectral/Component/Timeline/MessageDelegate.qml
+++ b/imports/Spectral/Component/Timeline/MessageDelegate.qml
@@ -15,7 +15,7 @@ ColumnLayout {
readonly property bool avatarVisible: !sentByMe && showAuthor
readonly property bool sentByMe: author === currentRoom.localUser
readonly property bool darkBackground: !sentByMe
- readonly property bool replyVisible: replyEventId || false
+ readonly property bool replyVisible: reply || false
signal saveFileAs()
signal openExternally()
@@ -66,6 +66,7 @@ ColumnLayout {
Control {
Layout.maximumWidth: messageListView.width - (!sentByMe ? 36 + messageRow.spacing : 0) - 48
+ Layout.minimumHeight: 36
padding: 0
@@ -144,15 +145,15 @@ ColumnLayout {
Layout.preferredHeight: 28
Layout.alignment: Qt.AlignTop
- source: replyVisible ? replyAuthor.avatarMediaId : ""
- hint: replyVisible ? replyAuthor.displayName : "H"
+ source: replyVisible ? reply.author.avatarMediaId : ""
+ hint: replyVisible ? reply.author.displayName : "H"
RippleEffect {
anchors.fill: parent
circular: true
- onClicked: userDetailDialog.createObject(ApplicationWindow.overlay, {"room": currentRoom, "user": replyAuthor}).open()
+ onClicked: userDetailDialog.createObject(ApplicationWindow.overlay, {"room": currentRoom, "user": reply.author}).open()
}
}
@@ -160,7 +161,7 @@ ColumnLayout {
Layout.fillWidth: true
color: !sentByMe ? MPalette.foreground : "white"
- text: "" + (replyDisplay || "")
+ text: "" + (replyVisible ? reply.display : "")
wrapMode: Label.Wrap
textFormat: Label.RichText
@@ -168,13 +169,13 @@ ColumnLayout {
}
background: Rectangle {
- color: replyAuthor && sentByMe ? replyAuthor.color : MPalette.background
+ color: replyVisible && sentByMe ? reply.author.color : MPalette.background
radius: 18
AutoMouseArea {
anchors.fill: parent
- onClicked: goToEvent(replyEventId)
+ onClicked: goToEvent(reply.eventId)
}
}
}
@@ -220,6 +221,15 @@ ColumnLayout {
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}
+
+ ReactionDelegate {
+ Layout.fillWidth: true
+
+ Layout.topMargin: 0
+ Layout.bottomMargin: 8
+ Layout.leftMargin: 16
+ Layout.rightMargin: 16
+ }
}
}
}
diff --git a/imports/Spectral/Component/Timeline/ReactionDelegate.qml b/imports/Spectral/Component/Timeline/ReactionDelegate.qml
new file mode 100644
index 000000000..ef606ccc3
--- /dev/null
+++ b/imports/Spectral/Component/Timeline/ReactionDelegate.qml
@@ -0,0 +1,57 @@
+import QtQuick 2.12
+import QtQuick.Controls 2.12
+import QtQuick.Layouts 1.12
+import Spectral.Setting 0.1
+
+Flow {
+ visible: (reaction && reaction.length > 0) || false
+
+ spacing: 8
+
+ Repeater {
+ model: reaction
+
+ delegate: Control {
+ horizontalPadding: 6
+ verticalPadding: 0
+
+ background: Rectangle {
+ radius: height / 2
+ color: modelData.hasLocalUser ? (MSettings.darkTheme ? Qt.darker(MPalette.accent, 1.55) : Qt.lighter(MPalette.accent, 1.55)) : MPalette.banner
+
+ MouseArea {
+ anchors.fill: parent
+
+ hoverEnabled: true
+
+ ToolTip.visible: containsMouse
+ ToolTip.text: {
+ var text = "";
+
+ for (var i = 0; i < modelData.authors.length; i++) {
+ if (i === modelData.authors.length - 1 && i !== 0) {
+ text += " and "
+ } else if (i !== 0) {
+ text += ", "
+ }
+
+ text += modelData.authors[i].displayName
+ }
+
+ text += " reacted with " + modelData.reaction
+
+ return text
+ }
+
+ onClicked: currentRoom.toggleReaction(eventId, modelData.reaction)
+ }
+ }
+
+ contentItem: Label {
+ text: modelData.reaction + " " + modelData.count
+ font.pixelSize: 14
+ }
+ }
+ }
+}
+
diff --git a/imports/Spectral/Component/Timeline/qmldir b/imports/Spectral/Component/Timeline/qmldir
index 03ed87550..e1f5b7b4e 100644
--- a/imports/Spectral/Component/Timeline/qmldir
+++ b/imports/Spectral/Component/Timeline/qmldir
@@ -5,3 +5,4 @@ SectionDelegate 2.0 SectionDelegate.qml
ImageDelegate 2.0 ImageDelegate.qml
FileDelegate 2.0 FileDelegate.qml
VideoDelegate 2.0 VideoDelegate.qml
+ReactionDelegate 2.0 ReactionDelegate.qml
diff --git a/imports/Spectral/Dialog/LoginDialog.qml b/imports/Spectral/Dialog/LoginDialog.qml
index b531ebc08..cdafed19c 100644
--- a/imports/Spectral/Dialog/LoginDialog.qml
+++ b/imports/Spectral/Dialog/LoginDialog.qml
@@ -44,6 +44,16 @@ Dialog {
placeholderText: "Password"
echoMode: TextInput.Password
+ onAccepted: accessTokenField.forceActiveFocus()
+ }
+
+ AutoTextField {
+ Layout.fillWidth: true
+
+ id: accessTokenField
+
+ placeholderText: "Access Token (Optional)"
+
onAccepted: deviceNameField.forceActiveFocus()
}
@@ -52,14 +62,19 @@ Dialog {
id: deviceNameField
- placeholderText: "Device Name"
+ placeholderText: "Device Name (Optional)"
onAccepted: root.accept()
}
}
function doLogin() {
- spectralController.loginWithCredentials(serverField.text, usernameField.text, passwordField.text, deviceNameField.text)
+ if (accessTokenField.text !== "") {
+ console.log("Login using access token.")
+ spectralController.loginWithAccessToken(serverField.text, usernameField.text, accessTokenField.text, deviceNameField.text)
+ } else {
+ spectralController.loginWithCredentials(serverField.text, usernameField.text, passwordField.text, deviceNameField.text)
+ }
}
onClosed: destroy()
diff --git a/imports/Spectral/Menu/Timeline/MessageDelegateContextMenu.qml b/imports/Spectral/Menu/Timeline/MessageDelegateContextMenu.qml
index 62ca02a33..f3a53c517 100644
--- a/imports/Spectral/Menu/Timeline/MessageDelegateContextMenu.qml
+++ b/imports/Spectral/Menu/Timeline/MessageDelegateContextMenu.qml
@@ -12,6 +12,38 @@ Menu {
id: root
+ Item {
+ width: parent.width
+ height: 32
+
+ Row {
+ anchors.centerIn: parent
+
+ spacing: 0
+
+ Repeater {
+ model: ["👍", "👎️", "😄", "🎉", "🚀", "👀"]
+
+ delegate: ItemDelegate {
+ width: 32
+ height: 32
+
+ contentItem: Label {
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+
+ font.pixelSize: 16
+ text: modelData
+ }
+
+ onClicked: currentRoom.toggleReaction(eventId, modelData)
+ }
+ }
+ }
+ }
+
+ MenuSeparator {}
+
MenuItem {
text: "View Source"
diff --git a/include/libQuotient b/include/libQuotient
index 74caea266..5b236dfe8 160000
--- a/include/libQuotient
+++ b/include/libQuotient
@@ -1 +1 @@
-Subproject commit 74caea2669b8f76ca76507bc40321fdcd23dc522
+Subproject commit 5b236dfe895c7766002559570aa29c9033009228
diff --git a/res.qrc b/res.qrc
index a65bdab65..0827f905b 100644
--- a/res.qrc
+++ b/res.qrc
@@ -57,5 +57,6 @@
imports/Spectral/Dialog/OpenFolderDialog.qml
imports/Spectral/Component/Timeline/VideoDelegate.qml
imports/Spectral/Component/AutoRectangle.qml
+ imports/Spectral/Component/Timeline/ReactionDelegate.qml
diff --git a/spectral_win32.rc b/spectral_win32.rc
new file mode 100644
index 000000000..339c9cdca
--- /dev/null
+++ b/spectral_win32.rc
@@ -0,0 +1 @@
+IDI_ICON1 ICON DISCARDABLE "icons/icon.ico"
diff --git a/src/controller.cpp b/src/controller.cpp
index 79312949d..2ef5ecfb1 100644
--- a/src/controller.cpp
+++ b/src/controller.cpp
@@ -103,6 +103,39 @@ void Controller::loginWithCredentials(QString serverAddr,
}
}
+void Controller::loginWithAccessToken(QString serverAddr,
+ QString user,
+ QString token,
+ QString deviceName) {
+ if (!user.isEmpty() && !token.isEmpty()) {
+ QUrl serverUrl(serverAddr);
+
+ Connection* conn = new Connection(this);
+ if (serverUrl.isValid()) {
+ conn->setHomeserver(serverUrl);
+ }
+
+ connect(conn, &Connection::connected, [=] {
+ AccountSettings account(conn->userId());
+ account.setKeepLoggedIn(true);
+ account.clearAccessToken(); // Drop the legacy - just in case
+ account.setHomeserver(conn->homeserver());
+ account.setDeviceId(conn->deviceId());
+ account.setDeviceName(deviceName);
+ if (!saveAccessTokenToKeyChain(account, conn->accessToken()))
+ qWarning() << "Couldn't save access token";
+ account.sync();
+ addConnection(conn);
+ setConnection(conn);
+ });
+ connect(conn, &Connection::networkError,
+ [=](QString error, QString, int, int) {
+ emit errorOccured("Network Error", error);
+ });
+ conn->connectWithToken(user, token, deviceName);
+ }
+}
+
void Controller::logout(Connection* conn) {
if (!conn) {
qCritical() << "Attempt to logout null connection";
@@ -112,16 +145,24 @@ void Controller::logout(Connection* conn) {
SettingsGroup("Accounts").remove(conn->userId());
QFile(accessTokenFileName(AccountSettings(conn->userId()))).remove();
- auto job = conn->callApi();
- connect(job, &LogoutJob::finished, conn, [=] {
+ QKeychain::DeletePasswordJob job(qAppName());
+ job.setAutoDelete(true);
+ job.setKey(conn->userId());
+ QEventLoop loop;
+ QKeychain::DeletePasswordJob::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
+ job.start();
+ loop.exec();
+
+ auto logoutJob = conn->callApi();
+ connect(logoutJob, &LogoutJob::finished, conn, [=] {
conn->stopSync();
emit conn->stateChanged();
emit conn->loggedOut();
if (!m_connections.isEmpty())
setConnection(m_connections[0]);
});
- connect(job, &LogoutJob::failure, this, [=] {
- emit errorOccured("Server-side Logout Failed", job->errorString());
+ connect(logoutJob, &LogoutJob::failure, this, [=] {
+ emit errorOccured("Server-side Logout Failed", logoutJob->errorString());
});
}
diff --git a/src/controller.h b/src/controller.h
index 49c92c5d3..5c9e1836f 100644
--- a/src/controller.h
+++ b/src/controller.h
@@ -32,6 +32,7 @@ class Controller : public QObject {
~Controller();
Q_INVOKABLE void loginWithCredentials(QString, QString, QString, QString);
+ Q_INVOKABLE void loginWithAccessToken(QString, QString, QString, QString);
QVector connections() { return m_connections; }
diff --git a/src/messageeventmodel.cpp b/src/messageeventmodel.cpp
index 59577f4a9..1583dc588 100644
--- a/src/messageeventmodel.cpp
+++ b/src/messageeventmodel.cpp
@@ -4,6 +4,7 @@
#include
#include
+#include
#include
#include
#include
@@ -30,13 +31,12 @@ QHash MessageEventModel::roleNames() const {
roles[LongOperationRole] = "progressInfo";
roles[AnnotationRole] = "annotation";
roles[EventResolvedTypeRole] = "eventResolvedType";
- roles[ReplyEventIdRole] = "replyEventId";
- roles[ReplyAuthorRole] = "replyAuthor";
- roles[ReplyDisplayRole] = "replyDisplay";
+ roles[ReplyRole] = "reply";
roles[UserMarkerRole] = "userMarker";
roles[ShowAuthorRole] = "showAuthor";
roles[ShowSectionRole] = "showSection";
roles[BubbleShapeRole] = "bubbleShape";
+ roles[ReactionRole] = "reaction";
return roles;
}
@@ -136,6 +136,10 @@ void MessageEventModel::setRoom(SpectralRoom* room) {
refreshLastUserEvents(refreshEvent(newEvent->id()) -
timelineBaseIndex());
});
+ connect(m_currentRoom, &Room::updatedEvent, this,
+ [this](const QString& eventId) {
+ refreshEventRoles(eventId, {ReactionRole, Qt::DisplayRole});
+ });
connect(m_currentRoom, &Room::fileTransferProgress, this,
&MessageEventModel::refreshEvent);
connect(m_currentRoom, &Room::fileTransferCompleted, this,
@@ -178,15 +182,24 @@ void MessageEventModel::refreshEventRoles(int row, const QVector& roles) {
emit dataChanged(idx, idx, roles);
}
-int MessageEventModel::refreshEventRoles(const QString& eventId,
+int MessageEventModel::refreshEventRoles(const QString& id,
const QVector& roles) {
- const auto it = m_currentRoom->findInTimeline(eventId);
- if (it == m_currentRoom->timelineEdge()) {
- qWarning() << "Trying to refresh inexistent event:" << eventId;
- return -1;
+ // On 64-bit platforms, difference_type for std containers is long long
+ // but Qt uses int throughout its interfaces; hence casting to int below.
+ int row = -1;
+ // First try pendingEvents because it is almost always very short.
+ const auto pendingIt = m_currentRoom->findPendingEvent(id);
+ if (pendingIt != m_currentRoom->pendingEvents().end())
+ row = int(pendingIt - m_currentRoom->pendingEvents().begin());
+ else {
+ const auto timelineIt = m_currentRoom->findInTimeline(id);
+ if (timelineIt == m_currentRoom->timelineEdge()) {
+ qWarning() << "Trying to refresh inexistent event:" << id;
+ return -1;
+ }
+ row = int(timelineIt - m_currentRoom->messageEvents().rbegin()) +
+ timelineBaseIndex();
}
- const auto row =
- it - m_currentRoom->messageEvents().rbegin() + timelineBaseIndex();
refreshEventRoles(row, roles);
return row;
}
@@ -359,7 +372,7 @@ QVariant MessageEventModel::data(const QModelIndex& idx, int role) const {
return EventStatus::Hidden;
}
- if (is(evt))
+ if (is(evt) || is(evt))
return EventStatus::Hidden;
if (evt.isRedacted())
return EventStatus::Hidden;
@@ -368,6 +381,12 @@ QVariant MessageEventModel::data(const QModelIndex& idx, int role) const {
static_cast(evt).repeatsState())
return EventStatus::Hidden;
+ if (auto e = eventCast(&evt)) {
+ if (!e->replacedEvent().isEmpty()) {
+ return EventStatus::Hidden;
+ }
+ }
+
if (m_currentRoom->connection()->isIgnored(
m_currentRoom->user(evt.senderId())))
return EventStatus::Hidden;
@@ -404,8 +423,7 @@ QVariant MessageEventModel::data(const QModelIndex& idx, int role) const {
return variantList;
}
- if (role == ReplyEventIdRole || role == ReplyDisplayRole ||
- role == ReplyAuthorRole) {
+ if (role == ReplyRole) {
const QString& replyEventId = evt.contentJson()["m.relates_to"]
.toObject()["m.in_reply_to"]
.toObject()["event_id"]
@@ -416,16 +434,13 @@ QVariant MessageEventModel::data(const QModelIndex& idx, int role) const {
if (replyIt == m_currentRoom->timelineEdge())
return {};
const auto& replyEvt = **replyIt;
- switch (role) {
- case ReplyEventIdRole:
- return replyEventId;
- case ReplyDisplayRole:
- return utils::cleanHTML(utils::removeReply(
- m_currentRoom->eventToString(replyEvt, Qt::RichText)));
- case ReplyAuthorRole:
- return QVariant::fromValue(m_currentRoom->user(replyEvt.senderId()));
- }
- return {};
+
+ return QVariantMap{
+ {"eventId", replyEventId},
+ {"display", utils::cleanHTML(utils::removeReply(
+ m_currentRoom->eventToString(replyEvt, Qt::RichText)))},
+ {"author",
+ QVariant::fromValue(m_currentRoom->user(replyEvt.senderId()))}};
}
if (role == ShowAuthorRole) {
@@ -484,6 +499,44 @@ QVariant MessageEventModel::data(const QModelIndex& idx, int role) const {
return BubbleShapes::MiddleShape;
}
+ if (role == ReactionRole) {
+ const auto& annotations =
+ m_currentRoom->relatedEvents(evt, EventRelation::Annotation());
+ if (annotations.isEmpty())
+ return {};
+ QMap> reactions = {};
+ for (const auto& a : annotations) {
+ if (a->isRedacted()) // Just in case?
+ continue;
+ if (auto e = eventCast(a))
+ reactions[e->relation().key].append(
+ static_cast(m_currentRoom->user(e->senderId())));
+ }
+
+ if (reactions.isEmpty()) {
+ return {};
+ }
+
+ QVariantList res = {};
+ QMap>::const_iterator i =
+ reactions.constBegin();
+ while (i != reactions.constEnd()) {
+ QVariantList authors;
+ for (auto author : i.value()) {
+ authors.append(QVariant::fromValue(author));
+ }
+ bool hasLocalUser = i.value().contains(
+ static_cast(m_currentRoom->localUser()));
+ res.append(QVariantMap{{"reaction", i.key()},
+ {"count", i.value().count()},
+ {"authors", authors},
+ {"hasLocalUser", hasLocalUser}});
+ ++i;
+ }
+
+ return res;
+ }
+
return {};
}
diff --git a/src/messageeventmodel.h b/src/messageeventmodel.h
index 3def157f9..38dd301b0 100644
--- a/src/messageeventmodel.h
+++ b/src/messageeventmodel.h
@@ -26,14 +26,16 @@ class MessageEventModel : public QAbstractListModel {
LongOperationRole,
AnnotationRole,
UserMarkerRole,
- // For reply
- ReplyEventIdRole,
- ReplyAuthorRole,
- ReplyDisplayRole,
+
+ ReplyRole,
ShowAuthorRole,
ShowSectionRole,
+
BubbleShapeRole,
+
+ ReactionRole,
+
// For debugging
EventResolvedTypeRole,
};
diff --git a/src/spectralroom.cpp b/src/spectralroom.cpp
index 28953d1da..7936fec7d 100644
--- a/src/spectralroom.cpp
+++ b/src/spectralroom.cpp
@@ -10,6 +10,7 @@
#include "csapi/rooms.h"
#include "csapi/typing.h"
#include "events/accountdataevents.h"
+#include "events/reactionevent.h"
#include "events/roommessageevent.h"
#include "events/typingevent.h"
#include "jobs/downloadfilejob.h"
@@ -119,15 +120,21 @@ QString SpectralRoom::lastEvent() {
for (auto i = messageEvents().rbegin(); i < messageEvents().rend(); i++) {
const RoomEvent* evt = i->get();
- if (is(*evt))
+ if (is(*evt) || is(*evt))
continue;
if (evt->isRedacted())
continue;
if (evt->isStateEvent() &&
- static_cast(evt)->repeatsState())
+ static_cast(*evt).repeatsState())
continue;
+ if (auto e = eventCast(evt)) {
+ if (!e->replacedEvent().isEmpty()) {
+ continue;
+ }
+ }
+
if (connection()->isIgnored(user(evt->senderId())))
continue;
@@ -164,6 +171,13 @@ void SpectralRoom::onAddHistoricalTimelineEvents(rev_iter_t from) {
[this](const TimelineItem& ti) { checkForHighlights(ti); });
}
+void SpectralRoom::onRedaction(const RoomEvent& prevEvent,
+ const RoomEvent& /*after*/) {
+ if (const auto& e = eventCast(&prevEvent)) {
+ emit updatedEvent(e->relation().eventId);
+ }
+}
+
void SpectralRoom::countChanged() {
if (displayed() && !hasUnreadMessages()) {
resetNotificationCount();
@@ -305,7 +319,7 @@ QString SpectralRoom::markdownToHTML(const QString& markdown) {
std::string html(tmp_buf);
- free((char *)tmp_buf);
+ free((char*)tmp_buf);
auto result = QString::fromStdString(html).trimmed();
@@ -419,3 +433,40 @@ void SpectralRoom::postHtmlMessage(const QString& text,
Room::postHtmlMessage(text, html, type);
}
+
+void SpectralRoom::toggleReaction(const QString& eventId,
+ const QString& reaction) {
+ if (eventId.isEmpty() || reaction.isEmpty())
+ return;
+
+ const auto eventIt = findInTimeline(eventId);
+ if (eventIt == timelineEdge())
+ return;
+
+ const auto& evt = **eventIt;
+
+ QStringList redactEventIds; // What if there are multiple reaction events?
+
+ const auto& annotations = relatedEvents(evt, EventRelation::Annotation());
+ if (!annotations.isEmpty()) {
+ for (const auto& a : annotations) {
+ if (auto e = eventCast(a)) {
+ if (e->relation().key != reaction)
+ continue;
+
+ if (e->senderId() == localUser()->id()) {
+ redactEventIds.push_back(e->id());
+ break;
+ }
+ }
+ }
+ }
+
+ if (!redactEventIds.isEmpty()) {
+ for (auto redactEventId : redactEventIds) {
+ redactEvent(redactEventId);
+ }
+ } else {
+ postReaction(eventId, reaction);
+ }
+}
diff --git a/src/spectralroom.h b/src/spectralroom.h
index e4885b8f5..972a8f269 100644
--- a/src/spectralroom.h
+++ b/src/spectralroom.h
@@ -284,6 +284,7 @@ class SpectralRoom : public Room {
void onAddNewTimelineEvents(timeline_iter_t from) override;
void onAddHistoricalTimelineEvents(rev_iter_t from) override;
+ void onRedaction(const RoomEvent& prevEvent, const RoomEvent& after) override;
static QString markdownToHTML(const QString& plaintext);
@@ -315,6 +316,7 @@ class SpectralRoom : public Room {
void changeAvatar(QUrl localFile);
void addLocalAlias(const QString& alias);
void removeLocalAlias(const QString& alias);
+ void toggleReaction(const QString& eventId, const QString& reaction);
};
#endif // SpectralRoom_H