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