Files
neochat/src/libneochat/neochatroom.cpp
Joshua Goins edd64d9b8f Improve UserListModel performance and other preparations
In a future patch I want to add support for viewing banned/invited
users, and it's also been mentioned that UserListModel is quite slow
too.

The biggest cost is sorting the member list (power level and
alphabetically) and this happened in a few different ways:
* When the member list updated
* The user switches rooms
* Misc events such as the palette changing

But this was pretty inefficient, because internally Quotient::Room keeps
a list of members, and we kept re-sorting that same list. Our
connections were also too broad and despite having signals for members
joining and leaving we just reloaded the entire list anyway.

So my new solution is to keep the list persistently sorted in
NeoChatRoom, and reload that in UserListModel. This model also keeps
track of *all* members - including ones that left - which will be used
for the aforementioned feature. So UserFilterModel now filters out only
the joined members, and that will be configurable in the future.

I also added two new roles to UserListModel for membership and color
respectively (which makes some dead code useful again) and fixed us
overwriting the built-in Qt roles accidentally.
2026-02-01 17:43:53 -05:00

1967 lines
66 KiB
C++

// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
#include "neochatroom.h"
#include "chatbartype.h"
#include <QFileInfo>
#include <QMediaMetaData>
#include <QMediaPlayer>
#include <QMimeDatabase>
#include <QTemporaryFile>
#include <QVideoFrame>
#include <QVideoSink>
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/eventrelation.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/jobs/basejob.h>
#include <Quotient/quotient_common.h>
#include <qcoro/qcorosignal.h>
#include <Quotient/avatar.h>
#include <Quotient/connection.h>
#include <Quotient/csapi/account-data.h>
#include <Quotient/csapi/directory.h>
#include <Quotient/csapi/pushrules.h>
#include <Quotient/csapi/redaction.h>
#include <Quotient/csapi/report_content.h>
#include <Quotient/csapi/room_state.h>
#include <Quotient/csapi/rooms.h>
#include <Quotient/csapi/typing.h>
#include <Quotient/events/encryptionevent.h>
#include <Quotient/events/reactionevent.h>
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/roomavatarevent.h>
#include <Quotient/events/roomcanonicalaliasevent.h>
#include <Quotient/events/roommemberevent.h>
#include <Quotient/events/roompowerlevelsevent.h>
#include <Quotient/events/simplestateevents.h>
#include <Quotient/jobs/downloadfilejob.h>
#include <Quotient/qt_connection_util.h>
#include <Quotient/thread.h>
#include "chatbarcache.h"
#include "clipboard.h"
#include "eventhandler.h"
#include "filetransferpseudojob.h"
#include "neochatconnection.h"
#include "roomlastmessageprovider.h"
#include "spacehierarchycache.h"
#include "urlhelper.h"
#include "jobs/neochatreportroomjob.h"
#ifndef Q_OS_ANDROID
#include <KIO/Job>
#include <KIO/JobTracker>
#endif
#include <KJobTrackerInterface>
#include <KLocalizedString>
using namespace Quotient;
std::function<bool(const Quotient::RoomEvent *)> NeoChatRoom::m_hiddenFilter = [](const Quotient::RoomEvent *) -> bool {
return false;
};
NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinState)
: Room(connection, std::move(roomId), joinState)
{
m_mainCache = new ChatBarCache(this);
m_editCache = new ChatBarCache(this);
m_threadCache = new ChatBarCache(this);
connect(connection, &Connection::accountDataChanged, this, &NeoChatRoom::updatePushNotificationState);
connect(this, &Room::fileTransferCompleted, this, [this] {
setFileUploadingProgress(0);
setHasFileUploading(false);
});
connect(this, &Room::fileTransferCompleted, this, [this](QString eventId) {
const auto evtIt = findInTimeline(eventId);
if (evtIt != messageEvents().rend()) {
const auto m_event = evtIt->viewAs<RoomEvent>();
QString mxcUrl;
if (auto event = eventCast<const Quotient::RoomMessageEvent>(m_event)) {
if (event->has<EventContent::FileContentBase>()) {
mxcUrl = event->get<EventContent::FileContentBase>()->url().toString();
}
} else if (auto event = eventCast<const Quotient::StickerEvent>(m_event)) {
mxcUrl = event->image().url().toString();
}
if (mxcUrl.isEmpty()) {
return;
}
auto localPath = this->fileTransferInfo(eventId).localPath.toLocalFile();
auto config = KSharedConfig::openStateConfig(u"neochatdownloads"_s)->group(u"downloads"_s);
config.writePathEntry(mxcUrl.mid(6), localPath);
}
});
connect(this, &Room::addedMessages, this, &NeoChatRoom::readMarkerLoadedChanged);
connect(this, &Room::aboutToAddHistoricalMessages, this, &NeoChatRoom::cleanupExtraEventRange);
connect(this, &Room::aboutToAddNewMessages, this, &NeoChatRoom::cleanupExtraEventRange);
const auto &roomLastMessageProvider = RoomLastMessageProvider::self();
if (roomLastMessageProvider.hasKey(id())) {
auto eventJson = QJsonDocument::fromJson(roomLastMessageProvider.read(id())).object();
if (!eventJson.isEmpty()) {
auto event = loadEvent<RoomEvent>(eventJson);
if (event != nullptr) {
m_cachedEvent = std::move(event);
}
}
}
connect(this, &Room::addedMessages, this, &NeoChatRoom::cacheLastEvent);
connect(this, &Quotient::Room::eventsHistoryJobChanged, this, &NeoChatRoom::lastActiveTimeChanged);
connect(this, &Room::joinStateChanged, this, [this](JoinState oldState, JoinState newState) {
if (oldState == JoinState::Invite && newState != JoinState::Invite) {
Q_EMIT isInviteChanged();
}
});
connect(this, &Room::displaynameChanged, this, &NeoChatRoom::displayNameChanged);
connect(
this,
&Room::baseStateLoaded,
this,
[this]() {
updatePushNotificationState(u"m.push_rules"_s);
loadPinnedMessage();
Q_EMIT canEncryptRoomChanged();
Q_EMIT inviteTimestampChanged();
},
Qt::SingleShotConnection);
connect(this, &Room::pinnedEventsChanged, this, &NeoChatRoom::loadPinnedMessage);
connect(this, &Room::changed, this, [this] {
Q_EMIT canEncryptRoomChanged();
Q_EMIT parentIdsChanged();
Q_EMIT canonicalParentChanged();
Q_EMIT readOnlyChanged();
});
connect(connection, &Connection::capabilitiesLoaded, this, &NeoChatRoom::maxRoomVersionChanged);
connect(this, &Room::changed, this, [this]() {
Q_EMIT defaultUrlPreviewStateChanged();
});
connect(this, &Room::accountDataChanged, this, [this](QString type) {
if (type == "org.matrix.room.preview_urls"_L1) {
Q_EMIT urlPreviewEnabledChanged();
}
});
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceHierarchyChanged, this, [this]() {
if (isSpace()) {
Q_EMIT childrenNotificationCountChanged();
Q_EMIT childrenHaveHighlightNotificationsChanged();
Q_EMIT spaceHasUnreadMessagesChanged();
}
});
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceNotificationCountChanged, this, [this](const QStringList &spaces) {
if (spaces.contains(id())) {
Q_EMIT childrenNotificationCountChanged();
Q_EMIT childrenHaveHighlightNotificationsChanged();
Q_EMIT spaceHasUnreadMessagesChanged();
}
});
const auto neochatconnection = static_cast<NeoChatConnection *>(connection);
Q_ASSERT(neochatconnection);
connect(neochatconnection, &NeoChatConnection::globalUrlPreviewEnabledChanged, this, &NeoChatRoom::urlPreviewEnabledChanged);
connect(this, &Room::fullyReadMarkerMoved, this, &NeoChatRoom::invalidateLastUnreadHighlightId);
// Wait until the initial member list is available before sorting
connect(this, &Room::memberListChanged, this, &NeoChatRoom::refreshAllMembers, Qt::SingleShotConnection);
connect(this, &Room::memberJoined, this, &NeoChatRoom::insertMemberSorted);
}
bool NeoChatRoom::visible() const
{
return m_visible;
}
void NeoChatRoom::setVisible(bool visible)
{
m_visible = visible;
if (!visible) {
m_memberObjects.clear();
}
}
int NeoChatRoom::contextAwareNotificationCount() const
{
// Don't include spaces, rooms that the user hasn't joined and rooms where the user has joined the successor.
if (isSpace() || joinState() != JoinState::Join || successor(JoinState::Join) != nullptr) {
return 0;
}
if (m_currentPushNotificationState == PushNotificationState::Mute) {
return 0;
}
// There is (currently) no association between our highlight count and the associated push rule,
// so we need this check here otherwise everything appears out-of-sync.
if (m_currentPushNotificationState == PushNotificationState::MentionKeyword || isLowPriority()) {
return int(highlightCount());
}
return int(notificationCount());
}
bool NeoChatRoom::hasFileUploading() const
{
return m_hasFileUploading;
}
void NeoChatRoom::setHasFileUploading(bool value)
{
if (value == m_hasFileUploading) {
return;
}
m_hasFileUploading = value;
Q_EMIT hasFileUploadingChanged();
}
int NeoChatRoom::fileUploadingProgress() const
{
return m_fileUploadingProgress;
}
void NeoChatRoom::setFileUploadingProgress(int value)
{
if (m_fileUploadingProgress == value) {
return;
}
m_fileUploadingProgress = value;
Q_EMIT fileUploadingProgressChanged();
}
void NeoChatRoom::uploadFile(const QUrl &url, const QString &body, std::optional<EventRelation> relatesTo)
{
doUploadFile(url, body, relatesTo);
}
QCoro::Task<void> NeoChatRoom::doUploadFile(QUrl url, QString body, std::optional<EventRelation> relatesTo)
{
if (url.isEmpty()) {
co_return;
}
auto mime = QMimeDatabase().mimeTypeForUrl(url);
url.setScheme("file"_L1);
QFileInfo fileInfo(url.isLocalFile() ? url.toLocalFile() : url.toString());
EventContent::FileContentBase *content;
if (mime.name().startsWith("image/"_L1)) {
QImage image(url.toLocalFile());
content = new EventContent::ImageContent(url, fileInfo.size(), mime, image.size(), fileInfo.fileName());
} else if (mime.name().startsWith("audio/"_L1)) {
content = new EventContent::AudioContent(url, fileInfo.size(), mime, fileInfo.fileName());
} else if (mime.name().startsWith("video/"_L1)) {
QVideoSink sink;
QMediaPlayer player;
player.setSource(url);
player.setVideoSink(&sink);
co_await qCoro(&player, &QMediaPlayer::mediaStatusChanged);
// Get the first video frame to use as a thumbnail.
player.play();
co_await qCoro(&player, &QMediaPlayer::positionChanged);
QTemporaryFile file;
file.setFileTemplate(QStringLiteral("XXXXXX.jpg"));
auto ok = file.open();
if (!ok) {
qWarning() << "Failed to open" << file.fileName() << file.errorString();
}
const auto thumbnailImage = sink.videoFrame().toImage();
Q_UNUSED(thumbnailImage.save(file.fileName()))
player.stop(); // We have to delay the stop() because it will invalidate our image
const auto thumbnailFileInfo = QFileInfo(file.fileName());
// Upload the thumbnail
const auto job = connection()->uploadFile(thumbnailFileInfo.absoluteFilePath());
co_await qCoro(job.get(), &BaseJob::finished);
const auto resolution = player.metaData().value(QMediaMetaData::Resolution).toSize();
content = new EventContent::VideoContent(url, fileInfo.size(), mime, resolution, fileInfo.fileName());
content->thumbnail = EventContent::Thumbnail(job->contentUri(),
thumbnailFileInfo.size(),
QMimeDatabase().mimeTypeForName(QStringLiteral("image/jpeg")),
thumbnailImage.size());
} else {
content = new EventContent::FileContent(url, fileInfo.size(), mime, fileInfo.fileName());
}
QString txnId = postFile(body.isEmpty() ? url.fileName() : body, std::unique_ptr<EventContent::FileContentBase>(content), relatesTo);
setHasFileUploading(true);
connect(this, &Room::fileTransferCompleted, [this, txnId](const QString &id, FileSourceInfo) {
if (id == txnId) {
setFileUploadingProgress(0);
setHasFileUploading(false);
}
});
connect(this, &Room::fileTransferFailed, [this, txnId](const QString &id, const QString & /*error*/) {
if (id == txnId) {
setFileUploadingProgress(0);
setHasFileUploading(false);
}
});
connect(this, &Room::fileTransferProgress, [this, txnId](const QString &id, qint64 progress, qint64 total) {
if (id == txnId) {
setFileUploadingProgress(int(float(progress) / float(total) * 100));
}
});
#ifndef Q_OS_ANDROID
auto job = new FileTransferPseudoJob(FileTransferPseudoJob::Upload, url.toLocalFile(), txnId);
connect(this, &Room::fileTransferProgress, job, &FileTransferPseudoJob::fileTransferProgress);
connect(this, &Room::fileTransferCompleted, job, &FileTransferPseudoJob::fileTransferCompleted);
connect(this, &Room::fileTransferFailed, job, [this, job, txnId] {
auto info = fileTransferInfo(txnId);
if (info.status == FileTransferInfo::Cancelled) {
job->fileTransferCanceled(txnId);
} else {
job->fileTransferFailed(txnId);
}
});
connect(job, &FileTransferPseudoJob::cancelRequested, this, &Room::cancelFileTransfer);
KIO::getJobTracker()->registerJob(job);
job->start();
#endif
}
void NeoChatRoom::acceptInvitation()
{
connection()->joinRoom(id());
}
void NeoChatRoom::forget()
{
QStringList roomIds{id()};
NeoChatRoom *predecessor = this;
while (predecessor = dynamic_cast<NeoChatRoom *>(predecessor->predecessor(JoinState::Join)), predecessor && !roomIds.contains(predecessor->id())) {
roomIds += predecessor->id();
}
const auto neochatConnection = dynamic_cast<NeoChatConnection *>(connection());
for (const auto &id : roomIds) {
neochatConnection->forgetRoom(id);
}
}
void NeoChatRoom::sendTypingNotification(bool isTyping)
{
// During the chatbar setup sequence, this may get called while we're still initializing
if (localMember().isEmpty()) {
return;
}
connection()->callApi<SetTypingJob>(BackgroundRequest, localMember().id(), id(), isTyping, 10000);
}
const RoomEvent *NeoChatRoom::lastEvent(std::function<bool(const RoomEvent *)> filter) const
{
for (auto timelineItem = messageEvents().rbegin(); timelineItem < messageEvents().rend(); timelineItem++) {
const RoomEvent *event = timelineItem->get();
if (filter) {
if (filter(event)) {
continue;
}
}
if (is<RedactionEvent>(*event) || is<ReactionEvent>(*event)) {
continue;
}
if (event->isRedacted()) {
continue;
}
if (event->isStateEvent() && static_cast<const StateEvent &>(*event).repeatsState()) {
continue;
}
if (auto roomEvent = eventCast<const RoomMessageEvent>(event)) {
if (!roomEvent->replacedEvent().isEmpty() && roomEvent->replacedEvent() != roomEvent->id()) {
continue;
}
}
if (connection()->isIgnored(event->senderId())) {
continue;
}
if (auto lastEvent = eventCast<const StateEvent>(event)) {
return lastEvent;
}
if (auto lastEvent = eventCast<const RoomMessageEvent>(event)) {
return lastEvent;
}
if (auto lastEvent = eventCast<const PollStartEvent>(event)) {
return lastEvent;
}
if (auto lastEvent = eventCast<const EncryptedEvent>(event)) {
return lastEvent;
}
}
if (m_cachedEvent != nullptr) {
return std::to_address(m_cachedEvent);
}
return nullptr;
}
void NeoChatRoom::cacheLastEvent()
{
auto event = lastEvent(m_hiddenFilter);
if (event != nullptr) {
auto &roomLastMessageProvider = RoomLastMessageProvider::self();
auto eventJson = QJsonDocument(event->fullJson()).toJson(QJsonDocument::Compact);
roomLastMessageProvider.write(id(), eventJson);
auto uniqueEvent = loadEvent<RoomEvent>(event->fullJson());
if (event != nullptr) {
m_cachedEvent = std::move(uniqueEvent);
}
}
}
bool NeoChatRoom::isEventSpoiler(const RoomEvent *e) const
{
if (const auto message = eventCast<const RoomMessageEvent>(e)) {
if (message->has<EventContent::TextContent>() && message->content() && message->mimeType().name() == "text/html"_L1) {
const auto htmlBody = message->get<EventContent::TextContent>()->body;
return htmlBody.contains("data-mx-spoiler"_L1);
}
}
return false;
}
bool NeoChatRoom::isEventHighlighted(const RoomEvent *e) const
{
return highlights.contains(e);
}
void NeoChatRoom::checkForHighlights(const Quotient::TimelineItem &ti)
{
auto localMember = this->localMember();
if (ti->senderId() == localMember.id()) {
return;
}
if (auto *e = ti.viewAs<RoomMessageEvent>()) {
const auto &text = e->plainBody();
if (text.contains(localMember.id()) || text.contains(localMember.disambiguatedName())) {
highlights.insert(e);
}
}
}
void NeoChatRoom::onAddNewTimelineEvents(timeline_iter_t from)
{
std::for_each(from, messageEvents().cend(), [this](const TimelineItem &ti) {
checkForHighlights(ti);
});
}
void NeoChatRoom::onAddHistoricalTimelineEvents(rev_iter_t from)
{
std::for_each(from, messageEvents().crend(), [this](const TimelineItem &ti) {
checkForHighlights(ti);
});
}
void NeoChatRoom::onRedaction(const RoomEvent &prevEvent, const RoomEvent & /*after*/)
{
if (const auto &e = eventCast<const ReactionEvent>(&prevEvent)) {
if (auto relatedEventId = e->eventId(); !relatedEventId.isEmpty()) {
Q_EMIT updatedEvent(relatedEventId);
}
}
}
QDateTime NeoChatRoom::lastActiveTime() const
{
// Find the last relevant event:
if (const auto event = lastEvent(m_hiddenFilter)) {
return event->originTimestamp();
}
// If nothing is loaded yet, and there is no cached event:
if (timelineSize() == 0) {
return {};
}
// No message found, take last event:
return messageEvents().rbegin()->get()->originTimestamp();
}
QUrl NeoChatRoom::avatarMediaUrl() const
{
if (const auto avatar = Room::avatarUrl(); !avatar.isEmpty()) {
return avatar;
}
// Use the first (excluding self) user's avatar for direct chats
const auto directChatMembers = this->directChatMembers();
for (const auto member : directChatMembers) {
if (member != localMember()) {
return member.avatarUrl();
}
}
return {};
}
void NeoChatRoom::changeAvatar(const QUrl &localFile)
{
connection()->uploadFile(localFile.toLocalFile()).onResult([this](const auto &job) {
connection()->callApi<SetRoomStateWithKeyJob>(id(), "m.room.avatar"_L1, QString(), QJsonObject{{"url"_L1, job->contentUri().toString()}});
});
}
void NeoChatRoom::toggleReaction(const QString &eventId, const QString &reaction)
{
if (eventId.isEmpty() || reaction.isEmpty()) {
return;
}
const auto eventIt = findInTimeline(eventId);
if (eventIt == historyEdge()) {
return;
}
const auto &evt = **eventIt;
QStringList redactEventIds; // What if there are multiple reaction events?
const auto &annotations = relatedEvents(evt, EventRelation::AnnotationType);
if (!annotations.isEmpty()) {
for (const auto &a : annotations) {
if (auto e = eventCast<const ReactionEvent>(a)) {
if (e->key() != reaction) {
continue;
}
if (e->senderId() == localMember().id()) {
redactEventIds.push_back(e->id());
break;
}
}
}
}
if (!redactEventIds.isEmpty()) {
for (const auto &redactEventId : redactEventIds) {
redactEvent(redactEventId);
}
} else {
postReaction(eventId, reaction);
}
}
bool NeoChatRoom::containsUser(const QString &userID) const
{
return memberState(userID) != Membership::Leave;
}
bool NeoChatRoom::canSendEvent(const QString &eventType) const
{
if (roomCreatorHasUltimatePowerLevel() && isCreator(localMember().id())) {
return true;
}
auto plEvent = currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return false;
}
auto pl = plEvent->powerLevelForEvent(eventType);
auto currentPl = plEvent->powerLevelForUser(localMember().id());
return currentPl >= pl;
}
bool NeoChatRoom::canSendState(const QString &eventType) const
{
if (roomCreatorHasUltimatePowerLevel() && isCreator(localMember().id())) {
return true;
}
auto plEvent = currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return false;
}
auto pl = plEvent->powerLevelForState(eventType);
auto currentPl = plEvent->powerLevelForUser(localMember().id());
return currentPl >= pl;
}
bool NeoChatRoom::readMarkerLoaded() const
{
const auto it = findInTimeline(lastFullyReadEventId());
return it != historyEdge();
}
bool NeoChatRoom::isInvite() const
{
return joinState() == JoinState::Invite;
}
bool NeoChatRoom::readOnly() const
{
return !canSendEvent("m.room.message"_L1);
}
bool NeoChatRoom::isUserBanned(const QString &user) const
{
auto roomMemberEvent = currentState().get<RoomMemberEvent>(user);
if (!roomMemberEvent) {
return false;
}
return roomMemberEvent->membership() == Membership::Ban;
}
void NeoChatRoom::deleteMessagesByUser(const QString &user, const QString &reason)
{
doDeleteMessagesByUser(user, reason);
}
QString NeoChatRoom::historyVisibility() const
{
if (const auto stateEvent = currentState().get("m.room.history_visibility"_L1)) {
return stateEvent->contentPart<QString>("history_visibility"_L1);
}
return {};
}
void NeoChatRoom::setHistoryVisibility(const QString &historyVisibilityRule)
{
if (!canSendState("m.room.history_visibility"_L1)) {
qWarning() << "Power level too low to set history visibility";
return;
}
setState("m.room.history_visibility"_L1, {}, QJsonObject{{"history_visibility"_L1, historyVisibilityRule}});
// Not emitting historyVisibilityChanged() here, since that would override the change in the UI with the *current* value, which is not the *new* value.
}
bool NeoChatRoom::defaultUrlPreviewState() const
{
auto urlPreviewsDisabled = currentState().get("org.matrix.room.preview_urls"_L1);
// Some rooms will not have this state event set so check for a nullptr return.
if (urlPreviewsDisabled != nullptr) {
return !urlPreviewsDisabled->contentJson()["disable"_L1].toBool();
} else {
return false;
}
}
void NeoChatRoom::setDefaultUrlPreviewState(const bool &defaultUrlPreviewState)
{
if (!canSendState("org.matrix.room.preview_urls"_L1)) {
qWarning() << "Power level too low to set the default URL preview state for the room";
return;
}
/**
* Note the org.matrix.room.preview_urls room state event is completely undocumented
* so here it is because I'm nice.
*
* Also note this is a different event to org.matrix.room.preview_urls for room
* account data, because even though it has the same name and content it's totally different.
*
* {
* "content": {
* "disable": false
* },
* "origin_server_ts": 1673115224071,
* "sender": "@bob:kde.org",
* "state_key": "",
* "type": "org.matrix.room.preview_urls",
* "unsigned": {
* "replaces_state": "replaced_event_id",
* "prev_content": {
* "disable": true
* },
* "prev_sender": "@jeff:kde.org",
* "age": 99
* },
* "event_id": "$event_id",
* "room_id": "!room_id:kde.org"
* }
*
* You just have to set disable to true to disable URL previews by default.
*/
setState("org.matrix.room.preview_urls"_L1, {}, QJsonObject{{"disable"_L1, !defaultUrlPreviewState}});
}
bool NeoChatRoom::urlPreviewEnabled() const
{
if (!static_cast<NeoChatConnection *>(connection())->globalUrlPreviewEnabled()) {
return false;
}
if (hasAccountData("org.matrix.room.preview_urls"_L1)) {
return !accountData("org.matrix.room.preview_urls"_L1)->contentJson()["disable"_L1].toBool();
} else {
return defaultUrlPreviewState();
}
}
void NeoChatRoom::setUrlPreviewEnabled(const bool &urlPreviewEnabled)
{
/**
* Once again this is undocumented and even though the name and content are the
* same this is a different event to the org.matrix.room.preview_urls room state event.
*
* {
* "content": {
* "disable": true
* }
* "type": "org.matrix.room.preview_urls",
* }
*/
connection()->callApi<SetAccountDataPerRoomJob>(localMember().id(),
id(),
"org.matrix.room.preview_urls"_L1,
QJsonObject{{"disable"_L1, !urlPreviewEnabled}});
}
void NeoChatRoom::setUserPowerLevel(const QString &userID, const int &powerLevel)
{
if (joinedCount() <= 1) {
qWarning() << "Cannot modify the power level of the only user";
return;
}
if (!canSendState("m.room.power_levels"_L1)) {
qWarning() << "Power level too low to set user power levels";
return;
}
if (!isMember(userID)) {
qWarning() << "User is not a member of this room so power level cannot be set";
return;
}
int clampPowerLevel = std::clamp(powerLevel, -1, 100);
auto powerLevelContent = currentState().get("m.room.power_levels"_L1)->contentJson();
auto powerLevelUserOverrides = powerLevelContent["users"_L1].toObject();
if (powerLevelUserOverrides[userID] != clampPowerLevel) {
powerLevelUserOverrides[userID] = clampPowerLevel;
powerLevelContent["users"_L1] = powerLevelUserOverrides;
setState("m.room.power_levels"_L1, {}, powerLevelContent);
}
}
QCoro::Task<void> NeoChatRoom::doDeleteMessagesByUser(const QString &user, QString reason)
{
QStringList events;
for (const auto &event : messageEvents()) {
if (event->senderId() == user && !event->isRedacted() && !event.viewAs<RedactionEvent>() && !event->isStateEvent()) {
events += event->id();
}
}
for (const auto &e : events) {
auto job = connection()->callApi<RedactEventJob>(id(), QString::fromLatin1(QUrl::toPercentEncoding(e)), connection()->generateTxnId(), reason);
co_await qCoro(job.get(), &BaseJob::finished);
if (job->error() != BaseJob::Success) {
qWarning() << "Error: \"" << job->error() << "\" while deleting messages. Aborting";
break;
}
}
}
bool NeoChatRoom::hasParent() const
{
return currentState().eventsOfType("m.space.parent"_L1).size() > 0;
}
QList<QString> NeoChatRoom::parentIds() const
{
auto parentEvents = currentState().eventsOfType("m.space.parent"_L1);
QList<QString> parentIds;
for (const auto &parentEvent : parentEvents) {
if (parentEvent->contentJson().contains("via"_L1) && !parentEvent->contentPart<QJsonArray>("via"_L1).isEmpty()) {
parentIds += parentEvent->stateKey();
}
}
return parentIds;
}
QList<NeoChatRoom *> NeoChatRoom::parentObjects(bool multiLevel) const
{
QList<NeoChatRoom *> parentObjects;
QList<QString> parentIds = this->parentIds();
for (const auto &parentId : parentIds) {
if (auto parentObject = static_cast<NeoChatRoom *>(connection()->room(parentId))) {
parentObjects += parentObject;
if (multiLevel) {
parentObjects += parentObject->parentObjects(true);
}
}
}
return parentObjects;
}
QString NeoChatRoom::canonicalParent() const
{
auto parentEvents = currentState().eventsOfType("m.space.parent"_L1);
for (const auto &parentEvent : parentEvents) {
if (parentEvent->contentJson().contains("via"_L1) && !parentEvent->contentPart<QJsonArray>("via"_L1).isEmpty()) {
if (parentEvent->contentPart<bool>("canonical"_L1)) {
return parentEvent->stateKey();
}
}
}
return {};
}
void NeoChatRoom::setCanonicalParent(const QString &parentId)
{
if (!canModifyParent(parentId)) {
return;
}
if (const auto &parent = currentState().get("m.space.parent"_L1, parentId)) {
auto content = parent->contentJson();
content.insert("canonical"_L1, true);
setState("m.space.parent"_L1, parentId, content);
} else {
return;
}
// Only one canonical parent can exist so make sure others are set false.
auto parentEvents = currentState().eventsOfType("m.space.parent"_L1);
for (const auto &parentEvent : parentEvents) {
if (parentEvent->contentPart<bool>("canonical"_L1) && parentEvent->stateKey() != parentId) {
auto content = parentEvent->contentJson();
content.insert("canonical"_L1, false);
setState("m.space.parent"_L1, parentEvent->stateKey(), content);
}
}
}
bool NeoChatRoom::canModifyParent(const QString &parentId) const
{
if (!canSendState("m.space.parent"_L1)) {
return false;
}
// If we can't peek the parent we assume that we neither have permission nor is
// there an existing space child event for this room.
if (auto parent = static_cast<NeoChatRoom *>(connection()->room(parentId))) {
if (!parent->isSpace()) {
return false;
}
// If the user is allowed to set space child events in the parent they are
// allowed to set the space as a parent (even if a space child event doesn't
// exist).
if (parent->canSendState("m.space.child"_L1)) {
return true;
}
// If the parent has a space child event the user can set as a parent (even
// if they don't have permission to set space child events in that parent).
if (parent->currentState().contains("m.space.child"_L1, id())) {
return true;
}
}
return false;
}
void NeoChatRoom::addParent(const QString &parentId, bool canonical, bool setParentChild)
{
if (!canModifyParent(parentId)) {
return;
}
if (canonical) {
// Only one canonical parent can exist so make sure others are set false.
auto parentEvents = currentState().eventsOfType("m.space.parent"_L1);
for (const auto &parentEvent : parentEvents) {
if (parentEvent->contentPart<bool>("canonical"_L1)) {
auto content = parentEvent->contentJson();
content.insert("canonical"_L1, false);
setState("m.space.parent"_L1, parentEvent->stateKey(), content);
}
}
}
setState("m.space.parent"_L1, parentId, QJsonObject{{"canonical"_L1, canonical}, {"via"_L1, QJsonArray{connection()->domain()}}});
if (!setParentChild) {
return;
}
if (auto parent = static_cast<NeoChatRoom *>(connection()->room(parentId))) {
parent->setState("m.space.child"_L1, id(), QJsonObject{{"via"_L1, QJsonArray{connection()->domain()}}});
}
}
void NeoChatRoom::removeParent(const QString &parentId)
{
if (!canModifyParent(parentId)) {
return;
}
if (!currentState().contains("m.space.parent"_L1, parentId)) {
return;
}
setState("m.space.parent"_L1, parentId, {});
}
bool NeoChatRoom::isSpace() const
{
const auto creationEvent = this->creation();
if (!creationEvent) {
return false;
}
return creationEvent->roomType() == RoomType::Space;
}
qsizetype NeoChatRoom::childrenNotificationCount()
{
if (!isSpace()) {
return 0;
}
return SpaceHierarchyCache::instance().notificationCountForSpace(id());
}
bool NeoChatRoom::childrenHaveHighlightNotifications() const
{
if (!isSpace()) {
return false;
}
return SpaceHierarchyCache::instance().spaceHasHighlightNotifications(id());
}
void NeoChatRoom::addChild(const QString &childId, bool setChildParent, bool canonical, bool suggested, const QString &order)
{
if (!isSpace()) {
return;
}
if (!canSendEvent("m.space.child"_L1)) {
return;
}
setState("m.space.child"_L1, childId, QJsonObject{{"via"_L1, QJsonArray{connection()->domain()}}, {"suggested"_L1, suggested}, {"order"_L1, order}});
if (!setChildParent) {
return;
}
if (auto child = static_cast<NeoChatRoom *>(connection()->room(childId))) {
if (!child->canSendState("m.space.parent"_L1)) {
return;
}
child->setState("m.space.parent"_L1, id(), QJsonObject{{"canonical"_L1, canonical}, {"via"_L1, QJsonArray{connection()->domain()}}});
if (!canonical) {
return;
}
// Only one canonical parent can exist so make sure others are set to false.
auto parentEvents = child->currentState().eventsOfType("m.space.parent"_L1);
for (const auto &parentEvent : parentEvents) {
if (!parentEvent->contentPart<bool>("canonical"_L1)) {
continue;
}
auto content = parentEvent->contentJson();
content.insert("canonical"_L1, false);
setState("m.space.parent"_L1, parentEvent->stateKey(), content);
}
}
}
void NeoChatRoom::removeChild(const QString &childId, bool unsetChildParent)
{
if (!isSpace()) {
return;
}
if (!canSendEvent("m.space.child"_L1)) {
return;
}
setState("m.space.child"_L1, childId, {});
if (!unsetChildParent) {
return;
}
if (auto child = static_cast<NeoChatRoom *>(connection()->room(childId))) {
if (!child->canSendState("m.space.parent"_L1) || !child->currentState().contains("m.space.parent"_L1, id())) {
return;
}
child->setState("m.space.parent"_L1, id(), {});
}
}
bool NeoChatRoom::isSuggested(const QString &childId)
{
if (!currentState().contains("m.space.child"_L1, childId)) {
return false;
}
const auto childEvent = currentState().get("m.space.child"_L1, childId);
return childEvent->contentPart<bool>("suggested"_L1);
}
void NeoChatRoom::toggleChildSuggested(const QString &childId)
{
if (!isSpace()) {
return;
}
if (!canSendEvent("m.space.child"_L1)) {
return;
}
if (const auto childEvent = currentState().get("m.space.child"_L1, childId)) {
auto content = childEvent->contentJson();
content.insert("suggested"_L1, !childEvent->contentPart<bool>("suggested"_L1));
setState("m.space.child"_L1, childId, content);
}
}
void NeoChatRoom::setChildOrder(const QString &childId, const QString &order)
{
if (!isSpace()) {
return;
}
if (!canSendEvent("m.space.child"_L1)) {
return;
}
if (const auto childEvent = currentState().get("m.space.child"_L1, childId)) {
auto content = childEvent->contentJson();
if (!content.contains("via"_L1)) {
return;
}
if (content.value("order"_L1).toString() == order) {
return;
}
content.insert("order"_L1, order);
setState("m.space.child"_L1, childId, content);
}
}
PushNotificationState::State NeoChatRoom::pushNotificationState() const
{
return m_currentPushNotificationState;
}
void NeoChatRoom::setPushNotificationState(PushNotificationState::State state)
{
// The caller should never try to set the state to unknown.
// It exists only as a default state to diable the settings options until the actual state is retrieved from the server.
if (state == PushNotificationState::Unknown) {
Q_ASSERT(false);
return;
}
/**
* This stops updatePushNotificationState from temporarily changing
* m_pushNotificationStateUpdating to default after the exisitng rules are deleted but
* before a new rule is added.
* The value is set to false after the rule enable job is successful.
*/
m_pushNotificationStateUpdating = true;
/**
* First remove any existing room rules of the wrong type.
* Note to prevent race conditions any rule that is going ot be overridden later is not removed.
* If the default push notification state is chosen any existing rule needs to be removed.
*/
QJsonObject accountData = connection()->accountDataJson("m.push_rules"_L1);
// For default and mute check for a room rule and remove if found.
if (state == PushNotificationState::Default || state == PushNotificationState::Mute) {
QJsonArray roomRuleArray = accountData["global"_L1].toObject()["room"_L1].toArray();
for (const auto &i : roomRuleArray) {
QJsonObject roomRule = i.toObject();
if (roomRule["rule_id"_L1] == id()) {
connection()->callApi<DeletePushRuleJob>("room"_L1, id());
}
}
}
// For default, all and @mentions and keywords check for an override rule and remove if found.
if (state == PushNotificationState::Default || state == PushNotificationState::All || state == PushNotificationState::MentionKeyword) {
QJsonArray overrideRuleArray = accountData["global"_L1].toObject()["override"_L1].toArray();
for (const auto &i : overrideRuleArray) {
QJsonObject overrideRule = i.toObject();
if (overrideRule["rule_id"_L1] == id()) {
connection()->callApi<DeletePushRuleJob>("override"_L1, id());
}
}
}
if (state == PushNotificationState::Mute) {
/**
* To mute a room an override rule with "don't notify is set".
*
* Setup the rule action to "don't notify" to stop all room notifications
* see https://spec.matrix.org/v1.3/client-server-api/#actions
*
* "actions": [
* "don't_notify"
* ]
*/
const QList<QVariant> actions = {"dont_notify"_L1};
/**
* Setup the push condition to get all events for the current room
* see https://spec.matrix.org/v1.3/client-server-api/#conditions-1
*
* "conditions": [
* {
* "key": "type",
* "kind": "event_match",
* "pattern": "room_id"
* }
* ]
*/
PushCondition pushCondition;
pushCondition.kind = "event_match"_L1;
pushCondition.key = "room_id"_L1;
pushCondition.pattern = id();
const QList<PushCondition> conditions = {pushCondition};
// Add new override rule and make sure it's enabled
connection()->callApi<SetPushRuleJob>("override"_L1, id(), actions, QString(), QString(), conditions, QString()).onResult([this]() {
connection()->callApi<SetPushRuleEnabledJob>("override"_L1, id(), true).onResult([this]() {
m_pushNotificationStateUpdating = false;
});
});
} else if (state == PushNotificationState::MentionKeyword) {
/**
* To only get notifications for @ mentions and keywords a room rule with "don't_notify" is set.
*
* Note - This works becuase a default override rule which catches all user mentions will
* take precedent and notify. See https://spec.matrix.org/v1.3/client-server-api/#default-override-rules. Any keywords will also have a similar override
* rule.
*
* Setup the rule action to "don't notify" to stop all room event notifications
* see https://spec.matrix.org/v1.3/client-server-api/#actions
*
* "actions": [
* "don't_notify"
* ]
*/
const QList<QVariant> actions = {"dont_notify"_L1};
// No conditions for a room rule
const QList<PushCondition> conditions;
connection()->callApi<SetPushRuleJob>("room"_L1, id(), actions, QString(), QString(), conditions, QString()).onResult([this]() {
connection()->callApi<SetPushRuleEnabledJob>("room"_L1, id(), true).onResult([this]() {
m_pushNotificationStateUpdating = false;
});
});
} else if (state == PushNotificationState::All) {
/**
* To send a notification for all room messages a room rule with "notify" is set.
*
* Setup the rule action to "notify" so all room events give notifications.
* Tweeks is also set to follow default sound settings
* see https://spec.matrix.org/v1.3/client-server-api/#actions
*
* "actions": [
* "notify",
* {
* "set_tweek": "sound",
* "value": "default",
* }
* ]
*/
QJsonObject tweaks;
tweaks.insert("set_tweak"_L1, "sound"_L1);
tweaks.insert("value"_L1, "default"_L1);
const QList<QVariant> actions = {"notify"_L1, tweaks};
// No conditions for a room rule
const QList<PushCondition> conditions;
// Add new room rule and make sure enabled
connection()->callApi<SetPushRuleJob>("room"_L1, id(), actions, QString(), QString(), conditions, QString()).onResult([this]() {
connection()->callApi<SetPushRuleEnabledJob>("room"_L1, id(), true).onResult([this]() {
m_pushNotificationStateUpdating = false;
});
});
}
m_currentPushNotificationState = state;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
}
void NeoChatRoom::loadPinnedMessage()
{
const auto events = pinnedEventIds();
if (!events.isEmpty()) {
const QString &mostRecentEventId = events.last();
connection()->callApi<GetOneRoomEventJob>(id(), mostRecentEventId).then([this](const auto &job) {
auto event = fromJson<event_ptr_tt<RoomEvent>>(job->jsonData());
if (auto encEv = eventCast<EncryptedEvent>(event.get())) {
auto decryptedMessage = decryptMessage(*encEv);
if (decryptedMessage) {
event = std::move(decryptedMessage);
}
}
m_pinnedMessage = EventHandler::richBody(this, event.get());
Q_EMIT pinnedMessageChanged();
});
}
}
void NeoChatRoom::updatePushNotificationState(QString type)
{
if (type != "m.push_rules"_L1 || m_pushNotificationStateUpdating) {
return;
}
QJsonObject accountData = connection()->accountDataJson("m.push_rules"_L1);
// First look for a room rule with the room id
QJsonArray roomRuleArray = accountData["global"_L1].toObject()["room"_L1].toArray();
for (const auto &i : roomRuleArray) {
QJsonObject roomRule = i.toObject();
if (roomRule["rule_id"_L1] == id()) {
if (roomRule["actions"_L1].toArray().size() == 0) {
m_currentPushNotificationState = PushNotificationState::MentionKeyword;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
return;
}
QString notifyAction = roomRule["actions"_L1].toArray()[0].toString();
if (notifyAction == "notify"_L1) {
m_currentPushNotificationState = PushNotificationState::All;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
return;
} else if (notifyAction == "dont_notify"_L1) {
m_currentPushNotificationState = PushNotificationState::MentionKeyword;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
return;
}
}
}
// Check for an override rule with the room id
QJsonArray overrideRuleArray = accountData["global"_L1].toObject()["override"_L1].toArray();
for (const auto &i : overrideRuleArray) {
QJsonObject overrideRule = i.toObject();
if (overrideRule["rule_id"_L1] == id()) {
if (overrideRule["actions"_L1].toArray().isEmpty()) {
m_currentPushNotificationState = PushNotificationState::Mute;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
return;
}
QString notifyAction = overrideRule["actions"_L1].toArray()[0].toString();
if (notifyAction == "dont_notify"_L1) {
m_currentPushNotificationState = PushNotificationState::Mute;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
return;
}
}
}
// If neither a room or override rule exist for the room then the setting must be default
m_currentPushNotificationState = PushNotificationState::Default;
Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState);
}
void NeoChatRoom::reportEvent(const QString &eventId, const QString &reason)
{
auto job = connection()->callApi<ReportContentJob>(id(), eventId, -50, reason).onResult([this]() {
Q_EMIT showMessage(MessageType::Positive, i18n("Report sent successfully."));
});
}
QByteArray NeoChatRoom::getEventJsonSource(const QString &eventId)
{
if (const auto evtIt = findInTimeline(eventId); evtIt != messageEvents().rend() && is<RoomEvent>(**evtIt)) {
return QJsonDocument(evtIt->viewAs<RoomEvent>()->fullJson()).toJson();
}
return {};
}
void NeoChatRoom::openEventMediaExternally(const QString &eventId)
{
const auto evtIt = findInTimeline(eventId);
if (evtIt == messageEvents().rend()) {
return;
}
// TODO: Also allow stickers here, once that's fixed in libQuotient
if (!is<RoomMessageEvent>(**evtIt) || !evtIt->viewAs<RoomMessageEvent>()->has<EventContent::FileContentBase>()) {
return;
}
const auto transferInfo = cachedFileTransferInfo(evtIt->viewAs<RoomEvent>());
if (transferInfo.completed()) {
UrlHelper helper;
helper.openUrl(transferInfo.localPath);
return;
}
downloadFile(eventId,
QUrl(u"file:"_s + QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u'/'
+ evtIt->event()->id().replace(u':', u'_').replace(u'/', u'_').replace(u'+', u'_') + fileNameToDownload(eventId)));
connect(
this,
&Room::fileTransferCompleted,
this,
[this, eventId](QString id, QUrl localFile, FileSourceInfo fileMetadata) {
Q_UNUSED(localFile);
Q_UNUSED(fileMetadata);
if (id == eventId) {
auto transferInfo = fileTransferInfo(eventId);
UrlHelper helper;
helper.openUrl(transferInfo.localPath);
}
},
static_cast<Qt::ConnectionType>(Qt::SingleShotConnection));
}
void NeoChatRoom::copyEventMedia(const QString &eventId)
{
const auto evtIt = findInTimeline(eventId);
if (evtIt == messageEvents().rend() || !is<RoomMessageEvent>(**evtIt)) {
return;
}
const auto event = evtIt->viewAs<RoomMessageEvent>();
if (!event->has<EventContent::FileContentBase>()) {
return;
}
const auto transferInfo = fileTransferInfo(eventId);
if (transferInfo.completed()) {
Clipboard clipboard;
clipboard.setImage(transferInfo.localPath);
} else {
downloadFile(eventId,
QUrl(u"file:"_s + QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u'/'
+ event->id().replace(u':', u'_').replace(u'/', u'_').replace(u'+', u'_') + fileNameToDownload(eventId)));
connect(
this,
&Room::fileTransferCompleted,
this,
[this, eventId](QString id, QUrl localFile, FileSourceInfo fileMetadata) {
Q_UNUSED(localFile);
Q_UNUSED(fileMetadata);
if (id == eventId) {
auto transferInfo = fileTransferInfo(eventId);
Clipboard clipboard;
clipboard.setImage(transferInfo.localPath);
}
},
Qt::SingleShotConnection);
}
}
FileTransferInfo NeoChatRoom::cachedFileTransferInfo(const QString &eventId) const
{
if (eventId.isEmpty()) {
return {};
}
const auto eventResult = getEvent(eventId);
if (!eventResult.first) {
return {};
}
return cachedFileTransferInfo(eventResult.first);
}
FileTransferInfo NeoChatRoom::cachedFileTransferInfo(const Quotient::RoomEvent *event) const
{
QString mxcUrl;
int total = 0;
if (auto evt = eventCast<const Quotient::RoomMessageEvent>(event)) {
if (evt->has<EventContent::FileContent>()) {
const auto fileContent = evt->get<EventContent::FileContent>();
mxcUrl = fileContent->url().toString();
total = fileContent->payloadSize;
}
} else if (auto evt = eventCast<const Quotient::StickerEvent>(event)) {
mxcUrl = evt->image().url().toString();
total = evt->image().payloadSize;
}
FileTransferInfo transferInfo = fileTransferInfo(event->id());
if (transferInfo.active()) {
return transferInfo;
}
auto config = KSharedConfig::openStateConfig(u"neochatdownloads"_s)->group(u"downloads"_s);
if (!config.hasKey(mxcUrl.mid(6))) {
return transferInfo;
}
const auto path = config.readPathEntry(mxcUrl.mid(6), QString());
QFileInfo info(path);
if (!info.isFile()) {
config.deleteEntry(mxcUrl);
return transferInfo;
}
// TODO: we could check the hash here
return FileTransferInfo{
.status = FileTransferInfo::Completed,
.isUpload = false,
.progress = total,
.total = total,
.localDir = QUrl(info.dir().path()),
.localPath = QUrl::fromLocalFile(path),
};
}
ChatBarCache *NeoChatRoom::mainCache() const
{
return m_mainCache;
}
ChatBarCache *NeoChatRoom::editCache() const
{
return m_editCache;
}
ChatBarCache *NeoChatRoom::threadCache() const
{
return m_threadCache;
}
ChatBarCache *NeoChatRoom::cacheForType(ChatBarType::Type type) const
{
switch (type) {
case ChatBarType::Room:
return m_mainCache;
case ChatBarType::Edit:
return m_editCache;
case ChatBarType::Thread:
return m_threadCache;
default:
return nullptr;
}
}
void NeoChatRoom::replyLastMessage()
{
const auto &timelineBottom = messageEvents().rbegin();
// set a cap limit of startRow + 35 messages, to prevent loading a lot of messages
// in rooms where the user has not sent many messages
const auto limit = timelineBottom + std::min(35, timelineSize());
for (auto it = timelineBottom; it != limit; ++it) {
auto evt = it->event();
auto e = eventCast<const RoomMessageEvent>(evt);
if (!e) {
continue;
}
auto content = (*it)->contentJson();
if (e->msgtype() != MessageEventType::Unknown) {
QString eventId;
if (content.contains("m.new_content"_L1)) {
// The message has been edited so we have to return the id of the original message instead of the replacement
eventId = content["m.relates_to"_L1].toObject()["event_id"_L1].toString();
} else {
// For any message that isn't an edit return the id of the current message
eventId = (*it)->id();
}
mainCache()->setReplyId(eventId);
return;
}
}
}
void NeoChatRoom::editLastMessage()
{
const auto &timelineBottom = messageEvents().rbegin();
// set a cap limit of 35 messages, to prevent loading a lot of messages
// in rooms where the user has not sent many messages
const auto limit = timelineBottom + std::min(35, timelineSize());
for (auto it = timelineBottom; it != limit; ++it) {
auto evt = it->event();
auto e = eventCast<const RoomMessageEvent>(evt);
if (!e) {
continue;
}
// check if the current message's sender's id is same as the user's id
if ((*it)->senderId() == localMember().id()) {
auto content = (*it)->contentJson();
if (e->msgtype() != MessageEventType::Unknown) {
QString eventId;
if (content.contains("m.new_content"_L1)) {
// The message has been edited so we have to return the id of the original message instead of the replacement
eventId = content["m.relates_to"_L1].toObject()["event_id"_L1].toString();
} else {
// For any message that isn't an edit return the id of the current message
eventId = (*it)->id();
}
editCache()->setEditId(eventId);
return;
}
}
}
}
bool NeoChatRoom::canEncryptRoom() const
{
return !usesEncryption() && canSendState("m.room.encryption"_L1);
}
void NeoChatRoom::postPoll(PollKind::Kind kind, const QString &question, const QList<QString> &answers)
{
QList<EventContent::Answer> answerStructs;
for (const auto &answer : answers) {
answerStructs += EventContent::Answer{
QUuid::createUuid().toString().remove(QRegularExpression(u"{|}|-"_s)),
answer,
};
}
const auto content = EventContent::PollStartContent{
.kind = kind,
.maxSelection = 1,
.question = question,
.answers = answerStructs,
};
post<PollStartEvent>(content);
}
bool NeoChatRoom::downloadTempFile(const QString &eventId)
{
QTemporaryFile file;
file.setAutoRemove(false);
if (!file.open()) {
return false;
}
download(eventId, QUrl::fromLocalFile(file.fileName()));
return true;
}
void NeoChatRoom::download(const QString &eventId, const QUrl &localFilename)
{
downloadFile(eventId, localFilename);
#ifndef Q_OS_ANDROID
auto job = new FileTransferPseudoJob(FileTransferPseudoJob::Download, localFilename.toLocalFile(), eventId);
connect(this, &Room::fileTransferProgress, job, &FileTransferPseudoJob::fileTransferProgress);
connect(this, &Room::fileTransferCompleted, job, &FileTransferPseudoJob::fileTransferCompleted);
connect(this, &Room::fileTransferFailed, job, [this, job, eventId] {
auto info = fileTransferInfo(eventId);
if (info.status == FileTransferInfo::Cancelled) {
job->fileTransferCanceled(eventId);
} else {
job->fileTransferFailed(eventId);
}
});
connect(job, &FileTransferPseudoJob::cancelRequested, this, &Room::cancelFileTransfer);
KIO::getJobTracker()->registerJob(job);
job->start();
#endif
}
void NeoChatRoom::mapAlias(const QString &alias)
{
connection()->callApi<GetLocalAliasesJob>(id()).onResult([this, alias](const auto &job) {
if (job->aliases().contains(alias)) {
return;
} else {
connection()->callApi<SetRoomAliasJob>(alias, id()).onResult([this, alias] {
auto newAltAliases = altAliases();
newAltAliases.append(alias);
setLocalAliases(newAltAliases);
});
}
});
}
void NeoChatRoom::unmapAlias(const QString &alias)
{
connection()->callApi<DeleteRoomAliasJob>(alias);
}
void NeoChatRoom::setCanonicalAlias(const QString &newAlias)
{
QString oldCanonicalAlias = canonicalAlias();
Room::setCanonicalAlias(newAlias);
connect(this, &Room::namesChanged, this, [this, newAlias, oldCanonicalAlias] {
if (canonicalAlias() == newAlias) {
// If the new canonical alias is already a published alt alias remove it otherwise it will be in both lists.
// The server doesn't prevent this so we need to handle it.
auto newAltAliases = altAliases();
if (!oldCanonicalAlias.isEmpty()) {
newAltAliases.append(oldCanonicalAlias);
}
if (newAltAliases.contains(newAlias)) {
newAltAliases.removeAll(newAlias);
Room::setLocalAliases(newAltAliases);
}
}
});
}
int NeoChatRoom::maxRoomVersion() const
{
int maxVersion = 0;
for (auto roomVersion : connection()->availableRoomVersions()) {
if (roomVersion.id.toInt() > maxVersion) {
maxVersion = roomVersion.id.toInt();
}
}
return maxVersion;
}
NeochatRoomMember *NeoChatRoom::directChatRemoteMember()
{
if (directChatMembers().size() == 0) {
qWarning() << "No other member available in this room";
return {};
}
return new NeochatRoomMember(this, directChatMembers()[0].id());
}
void NeoChatRoom::sendLocation(float lat, float lon, const QString &description)
{
QJsonObject locationContent{
{"uri"_L1, "geo:%1,%2"_L1.arg(QString::number(lat), QString::number(lon))},
};
if (!description.isEmpty()) {
locationContent["description"_L1] = description;
}
QJsonObject content{
{"body"_L1, i18nc("'Lat' and 'Lon' as in Latitude and Longitude", "Lat: %1, Lon: %2", lat, lon)},
{"msgtype"_L1, "m.location"_L1},
{"geo_uri"_L1, "geo:%1,%2"_L1.arg(QString::number(lat), QString::number(lon))},
{"org.matrix.msc3488.location"_L1, locationContent},
{"org.matrix.msc3488.asset"_L1,
QJsonObject{
{"type"_L1, "m.pin"_L1},
}},
{"org.matrix.msc1767.text"_L1, i18nc("'Lat' and 'Lon' as in Latitude and Longitude", "Lat: %1, Lon: %2", lat, lon)},
};
postJson("m.room.message"_L1, content);
}
QByteArray NeoChatRoom::roomAcountDataJson(const QString &eventType)
{
return QJsonDocument(accountData(eventType)->fullJson()).toJson();
}
void NeoChatRoom::downloadEventFromServer(const QString &eventId)
{
if (findInTimeline(eventId) != historyEdge()) {
// For whatever reason the event has now appeared so the function that called
// this need to whatever it wanted to do with the event.
Q_EMIT extraEventLoaded(eventId);
return;
}
connection()
->callApi<GetOneRoomEventJob>(id(), eventId)
.then(
[this, eventId](const auto &job) {
// The event may have arrived in the meantime so check it's not in the timeline.
if (findInTimeline(eventId) != historyEdge()) {
Q_EMIT extraEventLoaded(eventId);
return;
}
event_ptr_tt<RoomEvent> event = fromJson<event_ptr_tt<RoomEvent>>(job->jsonData());
if (auto encEv = eventCast<EncryptedEvent>(event.get())) {
auto decryptedEvent = decryptMessage(*encEv);
if (decryptedEvent) {
event = std::move(decryptedEvent);
}
}
m_extraEvents.push_back(std::move(event));
Q_EMIT extraEventLoaded(eventId);
},
[this, eventId](const auto &job) {
if (job->error() == BaseJob::NotFound) {
Q_EMIT extraEventNotFound(eventId);
}
});
}
std::pair<const Quotient::RoomEvent *, bool> NeoChatRoom::getEvent(const QString &eventId) const
{
if (eventId.isEmpty()) {
return {};
}
const auto timelineIt = findInTimeline(eventId);
if (timelineIt != historyEdge()) {
return std::make_pair(timelineIt->get(), false);
}
auto pendingIt = findPendingEvent(eventId);
if (pendingIt != pendingEvents().end()) {
return std::make_pair(pendingIt->event(), true);
}
// findPendingEvent() searches by transaction ID, we also need to check event ID.
for (const auto &event : pendingEvents()) {
if (event->id() == eventId || event->transactionId() == eventId) {
return std::make_pair(event.event(), true);
}
}
auto extraIt = std::find_if(m_extraEvents.begin(), m_extraEvents.end(), [eventId](const Quotient::event_ptr_tt<Quotient::RoomEvent> &event) {
return event->id() == eventId;
});
return std::make_pair(extraIt != m_extraEvents.end() ? extraIt->get() : nullptr, false);
}
const RoomEvent *NeoChatRoom::findEvent(const QString &eventId) const
{
return getEvent(eventId).first;
}
const RoomEvent *NeoChatRoom::getReplyForEvent(const RoomEvent &event) const
{
#if Quotient_VERSION_MINOR > 9
const QString &replyEventId = event.replyEventId(true);
#else
const QString &replyEventId = event.contentJson()["m.relates_to"_L1].toObject()["m.in_reply_to"_L1].toObject()["event_id"_L1].toString();
#endif
if (replyEventId.isEmpty()) {
return {};
};
const auto replyIt = findInTimeline(replyEventId);
const RoomEvent *replyPtr = replyIt != historyEdge() ? &**replyIt : nullptr;
if (!replyPtr) {
for (const auto &e : m_extraEvents) {
if (e->id() == replyEventId) {
replyPtr = e.get();
break;
}
}
}
return replyPtr;
}
void NeoChatRoom::cleanupExtraEventRange(Quotient::RoomEventsRange events)
{
for (auto &&event : events) {
cleanupExtraEvent(event->id());
}
}
void NeoChatRoom::cleanupExtraEvent(const QString &eventId)
{
auto it = std::find_if(m_extraEvents.begin(), m_extraEvents.end(), [eventId](Quotient::event_ptr_tt<Quotient::RoomEvent> &event) {
return event->id() == eventId;
});
if (it != m_extraEvents.end()) {
m_extraEvents.erase(it);
}
}
QString NeoChatRoom::invitingUserId() const
{
auto event = currentState().get<RoomMemberEvent>(connection()->userId());
if (!event) {
return {};
}
return event->senderId();
}
QDateTime NeoChatRoom::inviteTimestamp() const
{
auto event = currentState().get<RoomMemberEvent>(connection()->userId());
if (!event) {
return {};
}
return event->originTimestamp();
}
void NeoChatRoom::setRoomState(const QString &type, const QString &stateKey, const QByteArray &content)
{
setState(type, stateKey, QJsonDocument::fromJson(content).object());
}
NeochatRoomMember *NeoChatRoom::qmlSafeMember(const QString &memberId)
{
if (memberId.isEmpty()) {
return nullptr;
}
if (!m_memberObjects.contains(memberId)) {
auto member = m_memberObjects.emplace(memberId, std::make_unique<NeochatRoomMember>(this, memberId)).first->second.get();
QQmlEngine::setObjectOwnership(member, QQmlEngine::CppOwnership);
return member;
}
return m_memberObjects[memberId].get();
}
void NeoChatRoom::pinEvent(const QString &eventId)
{
auto eventIds = pinnedEventIds();
eventIds.push_back(eventId);
setPinnedEvents(eventIds);
}
void NeoChatRoom::unpinEvent(const QString &eventId)
{
auto eventIds = pinnedEventIds();
eventIds.removeAll(eventId);
setPinnedEvents(eventIds);
}
bool NeoChatRoom::isEventPinned(const QString &eventId) const
{
return pinnedEventIds().contains(eventId);
}
bool NeoChatRoom::eventIsThreaded(const QString &eventId) const
{
const auto event = eventCast<const RoomMessageEvent>(getEvent(eventId).first);
if (event == nullptr) {
return false;
}
return event->isThreaded() || threads().contains(eventId);
}
QString NeoChatRoom::rootIdForThread(const QString &eventId) const
{
const auto event = eventCast<const RoomMessageEvent>(getEvent(eventId).first);
if (event == nullptr) {
return {};
}
auto rootId = event->threadRootEventId();
if (rootId.isEmpty() && threads().contains(eventId)) {
rootId = event->id();
}
return rootId;
}
void NeoChatRoom::setHiddenFilter(std::function<bool(const Quotient::RoomEvent *)> hiddenFilter)
{
NeoChatRoom::m_hiddenFilter = hiddenFilter;
}
bool NeoChatRoom::roomCreatorHasUltimatePowerLevel() const
{
bool ok = false;
auto version = this->version().toInt(&ok);
// This is terrible. For non-numeric room versions, I don't think there's a way of knowing whether they're pre- or post hydra.
// We just assume they are. Shouldn't matter for normal users anyway.
return !ok || version > 11;
}
bool NeoChatRoom::isCreator(const QString &userId) const
{
auto createEvent = currentState().get<RoomCreateEvent>();
return roomCreatorHasUltimatePowerLevel() && createEvent
&& (createEvent->senderId() == userId || createEvent->contentPart<QStringList>(u"additional_creators"_s).contains(userId));
}
QString NeoChatRoom::pinnedMessage() const
{
return m_pinnedMessage;
}
void NeoChatRoom::report(const QString &reason)
{
connection()->callApi<NeochatReportRoomJob>(id(), reason);
}
QString NeoChatRoom::findNextUnreadHighlightId()
{
const QString startEventId = !m_lastUnreadHighlightId.isEmpty() ? m_lastUnreadHighlightId : lastFullyReadEventId();
const auto startIt = findInTimeline(startEventId);
if (startIt == historyEdge()) {
return {};
}
for (auto it = startIt.base(); it != messageEvents().cend(); ++it) {
const RoomEvent *ev = it->event();
if (highlights.contains(ev)) {
m_lastUnreadHighlightId = ev->id();
Q_EMIT highlightCycleStartedChanged();
return m_lastUnreadHighlightId;
}
}
if (!m_lastUnreadHighlightId.isEmpty()) {
m_lastUnreadHighlightId.clear();
Q_EMIT highlightCycleStartedChanged();
return findNextUnreadHighlightId();
}
return {};
}
bool NeoChatRoom::highlightCycleStarted() const
{
return !m_lastUnreadHighlightId.isEmpty();
}
void NeoChatRoom::invalidateLastUnreadHighlightId(const QString &fromEventId, const QString &toEventId)
{
Q_UNUSED(fromEventId);
if (m_lastUnreadHighlightId.isEmpty()) {
return;
}
const auto lastIt = findInTimeline(m_lastUnreadHighlightId);
const auto newReadIt = findInTimeline(toEventId);
// opposite comparision because both are reverse iterators :p
if (newReadIt <= lastIt) {
m_lastUnreadHighlightId.clear();
Q_EMIT highlightCycleStartedChanged();
}
}
void NeoChatRoom::refreshAllMembers()
{
m_sortedMemberIds = memberIds();
MemberSorter sorter;
std::ranges::sort(m_sortedMemberIds, [this, &sorter](const auto &left, const auto &right) {
const auto leftPl = memberEffectivePowerLevel(left);
const auto rightPl = memberEffectivePowerLevel(right);
if (leftPl > rightPl) {
return true;
}
if (rightPl > leftPl) {
return false;
}
return sorter(left, right);
});
}
void NeoChatRoom::insertMemberSorted(const Quotient::RoomMember member)
{
if (m_sortedMemberIds.contains(member.id())) {
return;
}
m_sortedMemberIds.append(member.id());
}
bool NeoChatRoom::spaceHasUnreadMessages() const
{
if (!isSpace()) {
return false;
}
return SpaceHierarchyCache::instance().spaceHasUnreadMessages(id());
}
void NeoChatRoom::markAllChildrenMessagesAsRead()
{
if (isSpace()) {
SpaceHierarchyCache::instance().markAllChildrenMessagesAsRead(id());
}
}
QList<QString> NeoChatRoom::sortedMemberIds() const
{
return m_sortedMemberIds;
}
#include "moc_neochatroom.cpp"