// SPDX-FileCopyrightText: 2025 Arno Rehn // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL #include "widgetmodel.h" #include #include #include using namespace Quotient; namespace { // This URL will take us to a jitsi room that is configured on a best effort basis. // Critically, it appears that we cannot pass avatars via the "fragment API". // The important stuff is working though: conference id, user name, subject. // // The alternative would be to rely on a Jitsi wrapper like https://app.element.io/jitsi.html. // This one parses the fragment and then uses the the actual Jitsi IFrame API to set // up the Jitsi meeting. Nice, but ties us to that external wrapper. // // Or we ship our own wrapper as a local HTML file and open that one? constexpr char JitsiMeetUrlTemplate[] = "https://$domain/$conferenceId" "#jitsi_meet_external_api_id=0" "&config.subject=\"$roomName\"" "&config.startAudioOnly=$isAudioOnly" "&config.startWithAudioMuted=$startWithAudioMuted" "&config.startWithVideoMuted=$startWithVideoMuted" "&isVideoChannel=$isVideoChannel" "&userInfo.displayName=\"$matrix_display_name\""; // Same thing with etherpad, as long as it's on scalar.vector.im constexpr char EtherpadScalarVectorImUrl[] = "https://scalar.vector.im/api/widgets/etherpad.html"; constexpr char EtherpadUrlTemplate[] = "https://etherpad.integrations.element.io/p/" "$padName" "?showControls=true" "&showChat=false" "&chatAndUsers=false" "&alwaysShowChat=false" "&showLineNumbers=true" "&useMonospaceFont=false" "&userName=$matrix_user_id"; QUrl avatarUrl(NeoChatRoom *room) { const auto avatarUrl = room->member(room->connection()->userId()).avatarUrl(); return room->connection()->getUrlForApi(avatarUrl); } // Many widgets require proper interfacing with integration managers, but this needs authentication // with an integration manager: https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/1961-integrations-auth.md // Also requires accepting the terms: https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/2140-terms-of-service-2.md // Example: // curl -X POST "https://scalar.vector.im/_matrix/integrations/v1/register" \ // -H "Content-Type: application/json" \ // --data '' // Then append the resulting json key-value pairs as query items to the widget url. QUrl buildWidgetUrl(QByteArray templateString, NeoChatRoom *room, const QJsonObject &data) { constexpr auto enc = [](const auto &s) { return QUrl::toPercentEncoding(s); }; templateString.replace("$matrix_user_id"_L1, enc(room->connection()->userId())) .replace("$matrix_room_id"_L1, enc(room->id())) .replace("$matrix_display_name"_L1, enc(room->member(room->connection()->userId()).displayName())) .replace("$matrix_avatar_url"_L1, avatarUrl(room).toEncoded()); for (auto it = data.begin(); it != data.end(); ++it) { const QByteArray key = '$' + it.key().toUtf8(); templateString.replace(key, enc(it.value().toString())); } return QUrl::fromEncoded(templateString); } } class WidgetModelPrivate { Q_DISABLE_COPY(WidgetModelPrivate); public: WidgetModelPrivate(WidgetModel *q) : q_ptr(q) { } Q_DECLARE_PUBLIC(WidgetModel) WidgetModel *const q_ptr; // clang-format off struct NoModelResetTag_t {}; static constexpr NoModelResetTag_t NoModelReset {}; // clang-format on void reload(); void reload(NoModelResetTag_t); void updateWidgets(Quotient::RoomEventsRange events); void handleEvent(const Quotient::RoomEvent *event); void handlePendingEvent(const Quotient::RoomEvent *event); void buildJitsiIndex(); NeoChatRoom *room = nullptr; QMap state; int jitsiIndex = -1; }; WidgetModel::WidgetModel(QObject *parent) : QAbstractListModel{parent} , d_ptr(std::make_unique(this)) { } WidgetModel::~WidgetModel() { } QHash WidgetModel::roleNames() const { return { {TextRole, "text"}, {UrlRole, "url"}, {TypeRole, "type"}, }; } QVariant WidgetModel::data(const QModelIndex &index, int role) const { Q_D(const WidgetModel); const int row = index.row(); if (row < 0 || row >= d->state.count()) { return {}; } const auto *ev = std::next(d->state.begin(), row).value(); const auto type = ev->contentPart("type"_L1); const auto url = ev->contentPart("url"_L1).toUtf8(); switch (role) { case TextRole: return ev->contentPart("name"_L1); case TypeRole: return type; case UrlRole: { QByteArray urlTemplate = url; if (type == "jitsi"_L1) { // Jitsi is special-cased even in Element. The URL that we get from the // widget is just "https://scalar.vector.im/api/widgets/jitsi.html" which // is not sufficient. urlTemplate = JitsiMeetUrlTemplate; } else if (type == "m.etherpad"_L1 && url.startsWith(EtherpadScalarVectorImUrl)) { // Etherpad is not special-cased, but the scalar.vector.im wrapper requires authentication // with the integration manager. We can just resolve this to the actual etherpad instance // to skip integration manager auth. urlTemplate = EtherpadUrlTemplate; } return buildWidgetUrl(urlTemplate, room(), ev->contentPart("data"_L1)); } } return {}; } int WidgetModel::rowCount(const QModelIndex &parent) const { Q_D(const WidgetModel); Q_UNUSED(parent); return d->state.count(); } void WidgetModelPrivate::reload(NoModelResetTag_t) { state.clear(); if (room) { const auto events = room->currentState().eventsOfType(Quotient::WidgetEvent::MetaType.matrixId); for (auto ev : events) { handleEvent(ev); } } } void WidgetModelPrivate::reload() { Q_Q(WidgetModel); q->beginResetModel(); reload(NoModelReset); buildJitsiIndex(); q->endResetModel(); } void WidgetModelPrivate::updateWidgets(Quotient::RoomEventsRange events) { using namespace std::ranges; constexpr auto isWidgetEvent = [](auto &&ev) { return ev->template is(); }; if (any_of(events, isWidgetEvent)) { reload(); } } void WidgetModelPrivate::handleEvent(const Quotient::RoomEvent *event) { const WidgetEvent *widgetEvent = eventCast(event); if (!widgetEvent) { return; } auto it = state.find(widgetEvent->stateKey()); if (it != state.end() && widgetEvent->replacedState() == it.value()->id()) { // we alrady have this and it should be... if (widgetEvent->contentJson().isEmpty()) { // ... removed because replacement is empty state.erase(it); } else { // replaced it.value() = widgetEvent; } } else if (!widgetEvent->contentJson().isEmpty()) { // insert if not empty it = state.insert(widgetEvent->stateKey(), widgetEvent); } } void WidgetModelPrivate::handlePendingEvent(const RoomEvent *event) { Q_Q(WidgetModel); if (!event->is()) { return; } q->beginResetModel(); reload(NoModelReset); handleEvent(event); buildJitsiIndex(); q->endResetModel(); } void WidgetModelPrivate::buildJitsiIndex() { Q_Q(WidgetModel); const auto newIndex = [&] -> int { auto it = std::find_if(state.cbegin(), state.cend(), [](const WidgetEvent *e) { return e->contentPart("type"_L1) == "jitsi"_L1; }); if (it == state.cend()) { return -1; } return std::distance(state.cbegin(), it); }(); if (newIndex != jitsiIndex) { jitsiIndex = newIndex; Q_EMIT q->jitsiIndexChanged(); } } NeoChatRoom *WidgetModel::room() const { Q_D(const WidgetModel); return d->room; } void WidgetModel::setRoom(NeoChatRoom *newRoom) { Q_D(WidgetModel); if (d->room == newRoom) { return; } if (d->room) { disconnect(d->room, &NeoChatRoom::baseStateLoaded, this, nullptr); disconnect(d->room, &NeoChatRoom::aboutToAddNewMessages, this, nullptr); disconnect(d->room, &NeoChatRoom::pendingEventAboutToAdd, this, nullptr); } d->room = newRoom; if (d->room) { connect(d->room, &NeoChatRoom::baseStateLoaded, this, [d] { d->reload(); }); connect(d->room, &NeoChatRoom::aboutToAddNewMessages, this, [d](Quotient::RoomEventsRange events) { d->updateWidgets(events); }); connect(d->room, &NeoChatRoom::pendingEventAboutToAdd, this, [d](Quotient::RoomEvent *event) { d->handlePendingEvent(event); }); } d->reload(); Q_EMIT roomChanged(); } int WidgetModel::jitsiIndex() const { Q_D(const WidgetModel); return d->jitsiIndex; } QUrl WidgetModel::addJitsiConference() { // URL is not spec-compliant, but this is what Element does as well. const auto conferenceId = QUuid::createUuid().toString(QUuid::WithoutBraces); // clang-format off const QJsonObject content{ {"name"_L1, "Jitsi Meet"_L1}, {"type"_L1, "jitsi"_L1}, {"url"_L1, "https://scalar.vector.im/api/widgets/jitsi.html"_L1}, {"data"_L1, QJsonObject{ {"conferenceId"_L1, conferenceId}, {"domain"_L1, "meet.element.io"_L1}, // TODO: make domain configurable {"isAudioOnly"_L1, false}, {"roomName"_L1, room()->displayName()}, }}, }; // clang-format on // Re-use conferenceId as state_key room()->setState(WidgetEvent::MetaType.matrixId, conferenceId, content); return buildWidgetUrl(JitsiMeetUrlTemplate, room(), content["data"_L1].toObject()); } bool WidgetModel::removeWidget(int index) { Q_D(const WidgetModel); if (index < 0 || index >= rowCount()) { return false; } const auto stateKey = std::next(d->state.begin(), index).value()->stateKey(); room()->setState(WidgetEvent::MetaType.matrixId, stateKey, QJsonObject{}); return true; }