File indexing completed on 2024-04-14 04:54:20

0001 // SPDX-FileCopyrightText: 2020 Tobias Fella <tobias.fella@kde.org>
0002 // SPDX-License-Identifier: GPL-2.0-or-later
0003 
0004 #include "notificationsmanager.h"
0005 
0006 #include <memory>
0007 
0008 #include <QGuiApplication>
0009 
0010 #include <KLocalizedString>
0011 #include <KNotification>
0012 #include <KNotificationReplyAction>
0013 
0014 #include <QPainter>
0015 #include <Quotient/accountregistry.h>
0016 #include <Quotient/csapi/pushrules.h>
0017 #include <Quotient/user.h>
0018 
0019 #ifdef HAVE_KIO
0020 #include <KIO/ApplicationLauncherJob>
0021 #endif
0022 
0023 #include "controller.h"
0024 #include "neochatconnection.h"
0025 #include "neochatroom.h"
0026 #include "roommanager.h"
0027 #include "texthandler.h"
0028 #include "windowcontroller.h"
0029 
0030 using namespace Quotient;
0031 
0032 NotificationsManager &NotificationsManager::instance()
0033 {
0034     static NotificationsManager _instance;
0035     return _instance;
0036 }
0037 
0038 NotificationsManager::NotificationsManager(QObject *parent)
0039     : QObject(parent)
0040 {
0041 }
0042 
0043 void NotificationsManager::handleNotifications(QPointer<NeoChatConnection> connection)
0044 {
0045     if (!m_connActiveJob.contains(connection->user()->id())) {
0046         auto job = connection->callApi<GetNotificationsJob>();
0047         m_connActiveJob.append(connection->user()->id());
0048         connect(job, &BaseJob::success, this, [this, job, connection]() {
0049             m_connActiveJob.removeAll(connection->user()->id());
0050             processNotificationJob(connection, job, !m_oldNotifications.contains(connection->user()->id()));
0051         });
0052     }
0053 }
0054 
0055 void NotificationsManager::processNotificationJob(QPointer<NeoChatConnection> connection, Quotient::GetNotificationsJob *job, bool initialization)
0056 {
0057     if (job == nullptr) {
0058         return;
0059     }
0060     if (connection == nullptr) {
0061         qWarning() << QStringLiteral("No connection for GetNotificationsJob %1").arg(job->objectName());
0062         return;
0063     }
0064 
0065     const auto connectionId = connection->user()->id();
0066 
0067     // If pagination has occurred set off the next job
0068     auto nextToken = job->jsonData()["next_token"_ls].toString();
0069     if (!nextToken.isEmpty()) {
0070         auto nextJob = connection->callApi<GetNotificationsJob>(nextToken);
0071         m_connActiveJob.append(connectionId);
0072         connect(nextJob, &BaseJob::success, this, [this, nextJob, connection, initialization]() {
0073             m_connActiveJob.removeAll(connection->user()->id());
0074             processNotificationJob(connection, nextJob, initialization);
0075         });
0076     }
0077 
0078     const auto notifications = job->jsonData()["notifications"_ls].toArray();
0079     if (initialization) {
0080         m_oldNotifications[connectionId] = QStringList();
0081         for (const auto &n : notifications) {
0082             if (!m_initialTimestamp.contains(connectionId)) {
0083                 m_initialTimestamp[connectionId] = n.toObject()["ts"_ls].toDouble();
0084             } else {
0085                 qint64 timestamp = n.toObject()["ts"_ls].toDouble();
0086                 if (timestamp > m_initialTimestamp[connectionId]) {
0087                     m_initialTimestamp[connectionId] = timestamp;
0088                 }
0089             }
0090 
0091             auto connectionNotifications = m_oldNotifications.value(connectionId);
0092             connectionNotifications += n.toObject()["event"_ls].toObject()["event_id"_ls].toString();
0093             m_oldNotifications[connectionId] = connectionNotifications;
0094         }
0095         return;
0096     }
0097     for (const auto &n : notifications) {
0098         const auto notification = n.toObject();
0099         if (notification["read"_ls].toBool()) {
0100             continue;
0101         }
0102         auto connectionNotifications = m_oldNotifications.value(connectionId);
0103         if (connectionNotifications.contains(notification["event"_ls].toObject()["event_id"_ls].toString())) {
0104             continue;
0105         }
0106         connectionNotifications += notification["event"_ls].toObject()["event_id"_ls].toString();
0107         m_oldNotifications[connectionId] = connectionNotifications;
0108 
0109         auto room = connection->room(notification["room_id"_ls].toString());
0110         if (shouldPostNotification(connection, n)) {
0111             // The room might have been deleted (for example rejected invitation).
0112             auto sender = room->user(notification["event"_ls].toObject()["sender"_ls].toString());
0113 
0114             QString body;
0115 
0116             if (notification["event"_ls].toObject()["type"_ls].toString() == "org.matrix.msc3381.poll.start"_ls) {
0117                 body = notification["event"_ls]
0118                            .toObject()["content"_ls]
0119                            .toObject()["org.matrix.msc3381.poll.start"_ls]
0120                            .toObject()["question"_ls]
0121                            .toObject()["body"_ls]
0122                            .toString();
0123             } else {
0124                 body = notification["event"_ls].toObject()["content"_ls].toObject()["body"_ls].toString();
0125             }
0126 
0127             if (notification["event"_ls]["type"_ls] == "m.room.encrypted"_ls) {
0128                 auto decrypted = connection->decryptNotification(notification);
0129                 body = decrypted["content"_ls].toObject()["body"_ls].toString();
0130                 if (body.isEmpty()) {
0131                     body = i18n("Encrypted Message");
0132                 }
0133             }
0134 
0135             QImage avatar_image;
0136             if (!sender->avatarUrl(room).isEmpty()) {
0137                 avatar_image = sender->avatar(128, room);
0138             } else {
0139                 avatar_image = room->avatar(128);
0140             }
0141             postNotification(dynamic_cast<NeoChatRoom *>(room),
0142                              sender->displayname(room),
0143                              body,
0144                              avatar_image,
0145                              notification["event"_ls].toObject()["event_id"_ls].toString(),
0146                              true);
0147         }
0148     }
0149 }
0150 
0151 bool NotificationsManager::shouldPostNotification(QPointer<NeoChatConnection> connection, const QJsonValue &notification)
0152 {
0153     if (connection == nullptr) {
0154         return false;
0155     }
0156 
0157     auto room = connection->room(notification["room_id"_ls].toString());
0158     if (room == nullptr) {
0159         return false;
0160     }
0161 
0162     // If the room is the current room and the application is active the notification
0163     // should not be shown.
0164     // This is setup so that if the application is inactive the notification will
0165     // always be posted, even if the room is the current room.
0166     bool isCurrentRoom = RoomManager::instance().currentRoom() && room->id() == RoomManager::instance().currentRoom()->id();
0167     if (isCurrentRoom && QGuiApplication::applicationState() == Qt::ApplicationActive) {
0168         return false;
0169     }
0170 
0171     // If the notification timestamp is earlier than the initial timestamp assume
0172     // the notification is old and shouldn't be posted.
0173     qint64 timestamp = notification["ts"_ls].toDouble();
0174     if (timestamp < m_initialTimestamp[connection->user()->id()]) {
0175         return false;
0176     }
0177 
0178     return true;
0179 }
0180 
0181 void NotificationsManager::postNotification(NeoChatRoom *room,
0182                                             const QString &sender,
0183                                             const QString &text,
0184                                             const QImage &icon,
0185                                             const QString &replyEventId,
0186                                             bool canReply)
0187 {
0188     const QString roomId = room->id();
0189     KNotification *notification = m_notifications.value(roomId);
0190     if (!notification) {
0191         notification = new KNotification(QStringLiteral("message"));
0192         m_notifications.insert(roomId, notification);
0193         connect(notification, &KNotification::closed, this, [this, roomId] {
0194             m_notifications.remove(roomId);
0195         });
0196     }
0197 
0198     QString entry;
0199     if (sender == room->displayName()) {
0200         notification->setTitle(sender);
0201         entry = text.toHtmlEscaped();
0202     } else {
0203         notification->setTitle(room->displayName());
0204         entry = i18n("%1: %2", sender, text.toHtmlEscaped());
0205     }
0206 
0207     notification->setText(notification->text() + QLatin1Char('\n') + entry);
0208     notification->setPixmap(createNotificationImage(icon, room));
0209 
0210     auto defaultAction = notification->addDefaultAction(i18n("Open NeoChat in this room"));
0211     connect(defaultAction, &KNotificationAction::activated, this, [notification, room]() {
0212         WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken());
0213         if (!room) {
0214             return;
0215         }
0216         auto connection = dynamic_cast<NeoChatConnection *>(Controller::instance().accounts().get(room->localUser()->id()));
0217         Controller::instance().setActiveConnection(connection);
0218         RoomManager::instance().setConnection(connection);
0219         RoomManager::instance().resolveResource(room->id());
0220     });
0221 
0222     if (canReply) {
0223         std::unique_ptr<KNotificationReplyAction> replyAction(new KNotificationReplyAction(i18n("Reply")));
0224         replyAction->setPlaceholderText(i18n("Reply..."));
0225         connect(replyAction.get(), &KNotificationReplyAction::replied, this, [room, replyEventId](const QString &text) {
0226             TextHandler textHandler;
0227             textHandler.setData(text);
0228             room->postMessage(text, textHandler.handleSendText(), RoomMessageEvent::MsgType::Text, replyEventId, QString());
0229         });
0230         notification->setReplyAction(std::move(replyAction));
0231     }
0232 
0233     notification->setHint(QStringLiteral("x-kde-origin-name"), room->localUser()->id());
0234     notification->sendEvent();
0235 }
0236 
0237 void NotificationsManager::postInviteNotification(NeoChatRoom *rawRoom, const QString &title, const QString &sender, const QImage &icon)
0238 {
0239     QPointer room(rawRoom);
0240     QPixmap img;
0241     img.convertFromImage(icon);
0242     KNotification *notification = new KNotification(QStringLiteral("invite"));
0243     notification->setText(i18n("%1 invited you to a room", sender));
0244     notification->setTitle(title);
0245     notification->setPixmap(createNotificationImage(icon, nullptr));
0246     notification->setFlags(KNotification::Persistent);
0247     auto defaultAction = notification->addDefaultAction(i18n("Open this invitation in NeoChat"));
0248     connect(defaultAction, &KNotificationAction::activated, this, [notification, room]() {
0249         if (!room) {
0250             return;
0251         }
0252         WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken());
0253         notification->close();
0254         RoomManager::instance().resolveResource(room->id());
0255     });
0256 
0257     const auto acceptAction = notification->addAction(i18nc("@action:button The thing being accepted is an invitation to chat", "Accept"));
0258     const auto rejectAction = notification->addAction(i18nc("@action:button The thing being rejected is an invitation to chat", "Reject"));
0259     const auto rejectAndIgnoreAction = notification->addAction(i18nc("@action:button The thing being rejected is an invitation to chat", "Reject and Ignore User"));
0260     connect(acceptAction, &KNotificationAction::activated, this, [room, notification]() {
0261         if (!room) {
0262             return;
0263         }
0264         room->acceptInvitation();
0265         notification->close();
0266     });
0267     connect(rejectAction, &KNotificationAction::activated, this, [room, notification]() {
0268         if (!room) {
0269             return;
0270         }
0271         RoomManager::instance().leaveRoom(room);
0272         notification->close();
0273     });
0274     connect(rejectAndIgnoreAction, &KNotificationAction::activated, this, [room, notification]() {
0275         if (!room) {
0276             return;
0277         }
0278         RoomManager::instance().leaveRoom(room);
0279         room->connection()->addToIgnoredUsers(room->invitingUser());
0280         notification->close();
0281     });
0282     connect(notification, &KNotification::closed, this, [this, room]() {
0283         if (!room) {
0284             return;
0285         }
0286         m_invitations.remove(room->id());
0287     });
0288 
0289     notification->setHint(QStringLiteral("x-kde-origin-name"), room->localUser()->id());
0290 
0291     notification->sendEvent();
0292     m_invitations.insert(room->id(), notification);
0293 }
0294 
0295 void NotificationsManager::clearInvitationNotification(const QString &roomId)
0296 {
0297     if (m_invitations.contains(roomId)) {
0298         m_invitations[roomId]->close();
0299     }
0300 }
0301 
0302 void NotificationsManager::postPushNotification(const QByteArray &message)
0303 {
0304     const auto json = QJsonDocument::fromJson(message).object();
0305 
0306     const auto type = json["notification"_ls]["type"_ls].toString();
0307 
0308     // the only two types of push notifications we support right now
0309     if (type == QStringLiteral("m.room.message") || type == QStringLiteral("m.room.encrypted")) {
0310         auto notification = new KNotification("message"_ls);
0311 
0312         const auto sender = json["notification"_ls]["sender_display_name"_ls].toString();
0313         const auto roomName = json["notification"_ls]["room_name"_ls].toString();
0314         const auto roomId = json["notification"_ls]["room_id"_ls].toString();
0315 
0316         if (roomName.isEmpty() || sender == roomName) {
0317             notification->setTitle(sender);
0318         } else {
0319             notification->setTitle(i18n("%1 (%2)", sender, roomName));
0320         }
0321 
0322         if (type == QStringLiteral("m.room.message")) {
0323             const auto text = json["notification"_ls]["content"_ls]["body"_ls].toString();
0324             notification->setText(text.toHtmlEscaped());
0325         } else if (type == QStringLiteral("m.room.encrypted")) {
0326             notification->setText(i18n("Encrypted Message"));
0327         }
0328 
0329 #ifdef HAVE_KIO
0330         auto openAction = notification->addAction(i18n("Open NeoChat"));
0331         connect(openAction, &KNotificationAction::activated, this, [=]() {
0332             QString properId = roomId;
0333             properId = properId.replace(QStringLiteral("#"), QString());
0334             properId = properId.replace(QStringLiteral("!"), QString());
0335 
0336             auto *job = new KIO::ApplicationLauncherJob(KService::serviceByDesktopName(QStringLiteral("org.kde.neochat")));
0337             job->setUrls({QUrl::fromUserInput(QStringLiteral("matrix:r/%1").arg(properId))});
0338             job->start();
0339         });
0340 #endif
0341 
0342         connect(notification, &KNotification::closed, qGuiApp, &QGuiApplication::quit);
0343 
0344         notification->sendEvent();
0345 
0346         m_notifications.insert(roomId, notification);
0347     } else {
0348         qWarning() << "Skipping unsupported push notification" << type;
0349     }
0350 }
0351 
0352 QPixmap NotificationsManager::createNotificationImage(const QImage &icon, NeoChatRoom *room)
0353 {
0354     // Handle avatars that are lopsided in one dimension
0355     const int biggestDimension = std::max(icon.width(), icon.height());
0356     const QRect imageRect{0, 0, biggestDimension, biggestDimension};
0357 
0358     QImage roundedImage(imageRect.size(), QImage::Format_ARGB32);
0359     roundedImage.fill(Qt::transparent);
0360 
0361     QPainter painter(&roundedImage);
0362     painter.setRenderHint(QPainter::SmoothPixmapTransform);
0363     painter.setPen(Qt::NoPen);
0364 
0365     // Fill background for transparent avatars
0366     painter.setBrush(Qt::white);
0367     painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
0368 
0369     QBrush brush(icon.scaledToHeight(biggestDimension));
0370     painter.setBrush(brush);
0371     painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
0372 
0373     if (room != nullptr) {
0374         const QImage roomAvatar = room->avatar(imageRect.width(), imageRect.height());
0375         if (icon != roomAvatar) {
0376             const QRect lowerQuarter{imageRect.center(), imageRect.size() / 2};
0377 
0378             painter.setBrush(Qt::white);
0379             painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
0380 
0381             painter.setBrush(roomAvatar.scaled(lowerQuarter.size()));
0382             painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
0383         }
0384     }
0385 
0386     return QPixmap::fromImage(roundedImage);
0387 }
0388 
0389 #include "moc_notificationsmanager.cpp"