Merge branch 'relations' into 'develop'

Relations

See merge request b0/spectral!62
This commit is contained in:
Black Hat
2019-08-04 11:13:46 +00:00
15 changed files with 314 additions and 44 deletions

View File

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

View File

@@ -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: "<style>a{color: " + color + ";} .user-pill{}</style>" + (replyDisplay || "")
text: "<style>a{color: " + color + ";} .user-pill{}</style>" + (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
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,5 +57,6 @@
<file>imports/Spectral/Dialog/OpenFolderDialog.qml</file>
<file>imports/Spectral/Component/Timeline/VideoDelegate.qml</file>
<file>imports/Spectral/Component/AutoRectangle.qml</file>
<file>imports/Spectral/Component/Timeline/ReactionDelegate.qml</file>
</qresource>
</RCC>

1
spectral_win32.rc Normal file
View File

@@ -0,0 +1 @@
IDI_ICON1 ICON DISCARDABLE "icons/icon.ico"

View File

@@ -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<LogoutJob>();
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<LogoutJob>();
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());
});
}

View File

@@ -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<Connection*> connections() { return m_connections; }

View File

@@ -4,6 +4,7 @@
#include <settings.h>
#include <user.h>
#include <events/reactionevent.h>
#include <events/redactionevent.h>
#include <events/roomavatarevent.h>
#include <events/roommemberevent.h>
@@ -30,13 +31,12 @@ QHash<int, QByteArray> 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<int>& roles) {
emit dataChanged(idx, idx, roles);
}
int MessageEventModel::refreshEventRoles(const QString& eventId,
int MessageEventModel::refreshEventRoles(const QString& id,
const QVector<int>& 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<RedactionEvent>(evt))
if (is<RedactionEvent>(evt) || is<ReactionEvent>(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<const StateEventBase&>(evt).repeatsState())
return EventStatus::Hidden;
if (auto e = eventCast<const RoomMessageEvent>(&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<QString, QList<SpectralUser*>> reactions = {};
for (const auto& a : annotations) {
if (a->isRedacted()) // Just in case?
continue;
if (auto e = eventCast<const ReactionEvent>(a))
reactions[e->relation().key].append(
static_cast<SpectralUser*>(m_currentRoom->user(e->senderId())));
}
if (reactions.isEmpty()) {
return {};
}
QVariantList res = {};
QMap<QString, QList<SpectralUser*>>::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<SpectralUser*>(m_currentRoom->localUser()));
res.append(QVariantMap{{"reaction", i.key()},
{"count", i.value().count()},
{"authors", authors},
{"hasLocalUser", hasLocalUser}});
++i;
}
return res;
}
return {};
}

View File

@@ -26,14 +26,16 @@ class MessageEventModel : public QAbstractListModel {
LongOperationRole,
AnnotationRole,
UserMarkerRole,
// For reply
ReplyEventIdRole,
ReplyAuthorRole,
ReplyDisplayRole,
ReplyRole,
ShowAuthorRole,
ShowSectionRole,
BubbleShapeRole,
ReactionRole,
// For debugging
EventResolvedTypeRole,
};

View File

@@ -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<RedactionEvent>(*evt))
if (is<RedactionEvent>(*evt) || is<ReactionEvent>(*evt))
continue;
if (evt->isRedacted())
continue;
if (evt->isStateEvent() &&
static_cast<const StateEventBase*>(evt)->repeatsState())
static_cast<const StateEventBase&>(*evt).repeatsState())
continue;
if (auto e = eventCast<const RoomMessageEvent>(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<const ReactionEvent>(&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<const ReactionEvent>(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);
}
}

View File

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