From a7bc00d9a55dc2c498464a3df718b731918f9988 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 10 Aug 2025 17:49:13 +0200 Subject: [PATCH 01/41] Bump windows latest version --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c09836bd..6f85cfba 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -98,7 +98,7 @@ pages: - export LATEST_WINDOWS_NIGHTLY=$(curl "https://nheko.im/api/v4/projects/2/packages?package_name=windows-nightly&order_by=version&sort=desc" | jq -r '.[0].version') #- export LATEST_WINDOWS=$(curl "https://nheko.im/api/v4/projects/2/packages?package_name=windows&order_by=version&sort=desc" | jq -r '.[0].version') # hardcoded to avoid fuzzy matching - - export LATEST_WINDOWS='0.12.0.35798' + - export LATEST_WINDOWS='0.12.0.38759' - sed "s/0.12.1.0/${LATEST_WINDOWS_NIGHTLY}/g" -i resources/NhekoNightly.appinstaller - sed "s/0.12.1.0/${LATEST_WINDOWS}/g" -i resources/Nheko.appinstaller - mkdir public From a13ea11e57213c208b4adb7cd658ed4a251ab437 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Mon, 11 Aug 2025 09:16:49 +0200 Subject: [PATCH 02/41] Fix cmark loading on macos --- CMakeLists.txt | 10 +++------- im.nheko.Nheko.yaml | 6 ++++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ff134c5..8c085417 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -562,16 +562,12 @@ if(USE_BUNDLED_CMARK) FetchContent_Declare( cmark GIT_REPOSITORY https://github.com/commonmark/cmark.git - GIT_TAG 0.30.2 - CMAKE_ARGS "CMARK_STATIC=ON CMARK_SHARED=OFF CMARK_TESTS=OFF CMARK_TESTS=OFF" + GIT_TAG 0.31.1 + CMAKE_ARGS "BUILD_TESTING=OFF" ) FetchContent_MakeAvailable(cmark) if (NOT TARGET cmark::cmark) - if(MSVC) - add_library(cmark::cmark ALIAS cmark) - else() - add_library(cmark::cmark ALIAS cmark_static) - endif() + add_library(cmark::cmark ALIAS cmark) endif() else() find_package(cmark REQUIRED 0.29.0) diff --git a/im.nheko.Nheko.yaml b/im.nheko.Nheko.yaml index a773c5ba..b7bc6dee 100644 --- a/im.nheko.Nheko.yaml +++ b/im.nheko.Nheko.yaml @@ -70,10 +70,12 @@ modules: config-opts: - -DCMAKE_BUILD_TYPE=Release - -DCMARK_TESTS=OFF + - -DBUILD_TESTING=OFF + - -DBUILD_SHARED_LIBS=OFF sources: - - sha256: bbcb8f8c03b5af33fcfcf11a74e9499f20a9043200b8552f78a6e8ba76e04d11 + - sha256: 3da93db5469c30588cfeb283d9d62edfc6ded9eb0edc10a4f5bbfb7d722ea802 type: archive - url: https://github.com/commonmark/cmark/archive/0.31.0.tar.gz + url: https://github.com/commonmark/cmark/archive/0.31.1.tar.gz - name: fmt buildsystem: cmake-ninja config-opts: From 9c017ba1e2c2a7ae49907edfeb321126ec352e9e Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Mon, 11 Aug 2025 10:29:20 +0200 Subject: [PATCH 03/41] Fix kdsingleapplication linking on macos --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c085417..2235192d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,6 +271,7 @@ if(USE_BUNDLED_KDSINGLEAPPLICATION) kdsingleapplication GIT_REPOSITORY https://github.com/KDAB/KDSingleApplication.git GIT_TAG v1.0.0 + CMAKE_ARGS "KDSingleApplication_STATIC=ON KDSingleApplication_EXAMPLES=OFF" ) FetchContent_MakeAvailable(kdsingleapplication) else() From 2bc2dfb64c322038f4ba65f4cc52dac6c5bdec18 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Mon, 11 Aug 2025 10:49:21 +0200 Subject: [PATCH 04/41] Fix kdsingleapplication linking on macos (second take) CMAKE_ARGS don't work in FetchContent --- .ci/macos/build.sh | 1 + CMakeLists.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/macos/build.sh b/.ci/macos/build.sh index c4c92cab..1db9f009 100755 --- a/.ci/macos/build.sh +++ b/.ci/macos/build.sh @@ -24,6 +24,7 @@ cmake -GNinja -S. -Bbuild \ -DCMAKE_INSTALL_PREFIX="nheko.temp" \ -DHUNTER_ROOT="../.hunter" \ -DHUNTER_ENABLED=ON -DBUILD_SHARED_LIBS=OFF \ + -DKDSingleApplication_STATIC=ON -DKDSingleApplication_EXAMPLES=OFF \ -DCMAKE_BUILD_TYPE=RelWithDebInfo -DHUNTER_CONFIGURATION_TYPES=RelWithDebInfo \ -DQt6_DIR=${QT_BASEPATH}/lib/cmake \ -DCI_BUILD=ON diff --git a/CMakeLists.txt b/CMakeLists.txt index 2235192d..8c085417 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,7 +271,6 @@ if(USE_BUNDLED_KDSINGLEAPPLICATION) kdsingleapplication GIT_REPOSITORY https://github.com/KDAB/KDSingleApplication.git GIT_TAG v1.0.0 - CMAKE_ARGS "KDSingleApplication_STATIC=ON KDSingleApplication_EXAMPLES=OFF" ) FetchContent_MakeAvailable(kdsingleapplication) else() From f59f77a21e60c80a0f37f23e2926992a1d3a8ddc Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 16 Aug 2025 11:29:26 +0200 Subject: [PATCH 05/41] Fix sending encrypted messages in encrypted rooms when messages are disallowed Fixes #1933 --- resources/qml/MessageInput.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml index 58ae90bb..a23e1b60 100644 --- a/resources/qml/MessageInput.qml +++ b/resources/qml/MessageInput.qml @@ -39,7 +39,7 @@ Rectangle { anchors.fill: parent spacing: 0 - visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false + visible: room ? room.permissions.canSend(room.isEncrypted ? MtxEvent.Encrypted : MtxEvent.TextMessage) : false ImageButton { Layout.alignment: Qt.AlignBottom From 398cef5f8fc452fb390445fae601f31d916d6777 Mon Sep 17 00:00:00 2001 From: weeman Date: Sun, 17 Aug 2025 13:01:26 +0200 Subject: [PATCH 06/41] Extract list of valid themes Signed-off-by: weeman --- src/UserSettingsPage.cpp | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp index 705a605b..3f348d61 100644 --- a/src/UserSettingsPage.cpp +++ b/src/UserSettingsPage.cpp @@ -25,6 +25,12 @@ #include "config/nheko.h" +QStringList themes{ + QStringLiteral("light"), + QStringLiteral("dark"), + QStringLiteral("system"), +}; + QSharedPointer UserSettings::instance_; UserSettings::UserSettings() @@ -640,7 +646,7 @@ UserSettings::setShowImage(ShowImage state) void UserSettings::setTheme(QString theme) { - if (theme == theme_) + if (theme == theme_ || !themes.contains(theme)) return; theme_ = theme; save(); @@ -1182,12 +1188,7 @@ UserSettingsModel::data(const QModelIndex &index, int role) const } else if (role == Value) { switch (index.row()) { case Theme: - return QStringList{ - QStringLiteral("light"), - QStringLiteral("dark"), - QStringLiteral("system"), - } - .indexOf(i->theme()); + return themes.indexOf(i->theme()); case ScaleFactor: return utils::scaleFactor(); case MessageHoverHighlight: @@ -1741,14 +1742,10 @@ UserSettingsModel::setData(const QModelIndex &index, const QVariant &value, int if (role == Value) { switch (index.row()) { case Theme: { - if (value == 0) { - i->setTheme("light"); - return true; - } else if (value == 1) { - i->setTheme("dark"); - return true; - } else if (value == 2) { - i->setTheme("system"); + auto idx = value.toInt(); + + if (idx >= 0 && idx < themes.size()) { + i->setTheme(themes[idx]); return true; } else return false; From ffaa12cc192fc6c14f6f9af6ce781cda63b7216a Mon Sep 17 00:00:00 2001 From: weeman Date: Sun, 17 Aug 2025 13:01:33 +0200 Subject: [PATCH 07/41] Add setTheme D-Bus API Signed-off-by: weeman --- src/dbus/NhekoDBusApi.cpp | 8 ++++++++ src/dbus/NhekoDBusApi.h | 3 +++ src/dbus/NhekoDBusBackend.cpp | 7 +++++++ src/dbus/NhekoDBusBackend.h | 2 ++ 4 files changed, 20 insertions(+) diff --git a/src/dbus/NhekoDBusApi.cpp b/src/dbus/NhekoDBusApi.cpp index c2c62eb7..1da550cf 100644 --- a/src/dbus/NhekoDBusApi.cpp +++ b/src/dbus/NhekoDBusApi.cpp @@ -170,6 +170,14 @@ setStatusMessage(const QString &message) interface.call(QDBus::NoBlock, QStringLiteral("setStatusMessage"), message); } +void +setTheme(const QString &theme) +{ + if (QDBusInterface interface{QStringLiteral(NHEKO_DBUS_SERVICE_NAME), QStringLiteral("/")}; + interface.isValid()) + interface.call(QDBus::NoBlock, QStringLiteral("setTheme"), theme); +} + } // nheko::dbus /** diff --git a/src/dbus/NhekoDBusApi.h b/src/dbus/NhekoDBusApi.h index 6acb2b65..74e6aeee 100644 --- a/src/dbus/NhekoDBusApi.h +++ b/src/dbus/NhekoDBusApi.h @@ -85,6 +85,9 @@ statusMessage(); //! Sets the user's status message (if supported by the homeserver). void setStatusMessage(const QString &message); +//! Sets the current theme (supported values: "light", "dark" or "system") +void +setTheme(const QString &theme); QDBusArgument & operator<<(QDBusArgument &arg, const RoomInfoItem &item); diff --git a/src/dbus/NhekoDBusBackend.cpp b/src/dbus/NhekoDBusBackend.cpp index 898286f8..9831d5e6 100644 --- a/src/dbus/NhekoDBusBackend.cpp +++ b/src/dbus/NhekoDBusBackend.cpp @@ -11,6 +11,7 @@ #include "Logging.h" #include "MainWindow.h" #include "MxcImageProvider.h" +#include "UserSettingsPage.h" #include "timeline/RoomlistModel.h" #include "timeline/TimelineModel.h" @@ -112,6 +113,12 @@ NhekoDBusBackend::setStatusMessage(const QString &message) ChatPage::instance()->setStatus(message); } +void +NhekoDBusBackend::setTheme(const QString &theme) +{ + UserSettings::instance()->setTheme(theme); +} + void NhekoDBusBackend::bringWindowToTop() const { diff --git a/src/dbus/NhekoDBusBackend.h b/src/dbus/NhekoDBusBackend.h index 79d396f8..66b239aa 100644 --- a/src/dbus/NhekoDBusBackend.h +++ b/src/dbus/NhekoDBusBackend.h @@ -40,6 +40,8 @@ public slots: Q_SCRIPTABLE QString statusMessage() const; //! Sets the user's status message. Q_SCRIPTABLE void setStatusMessage(const QString &message); + //! Sets the current theme (supported values: "light", "dark" or "system") + Q_SCRIPTABLE void setTheme(const QString &theme); private: void bringWindowToTop() const; From f1d88ea0a3a622471841d7ff3c16b05c01429b2c Mon Sep 17 00:00:00 2001 From: Integral Date: Wed, 20 Aug 2025 22:36:23 +0800 Subject: [PATCH 08/41] Fix binding loop warnings in UserSettingsPage --- resources/qml/pages/UserSettingsPage.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/qml/pages/UserSettingsPage.qml b/resources/qml/pages/UserSettingsPage.qml index 30cfe230..1ce32725 100644 --- a/resources/qml/pages/UserSettingsPage.qml +++ b/resources/qml/pages/UserSettingsPage.qml @@ -89,7 +89,7 @@ Rectangle { roleValue: UserSettingsModel.Toggle ToggleButton { checked: model.value - onCheckedChanged: model.value = checked + onClicked: model.value = checked enabled: model.enabled } } @@ -100,7 +100,7 @@ Rectangle { model: r.model.values currentIndex: r.model.value width: Math.min(implicitWidth, scroll.availableWidth - Nheko.paddingMedium) - onCurrentIndexChanged: r.model.value = currentIndex + onActivated: r.model.value = currentIndex implicitContentWidthPolicy: ComboBox.WidestTextWhenCompleted WheelHandler{} // suppress scrolling changing values @@ -135,7 +135,7 @@ Rectangle { to: model.valueUpperBound * div stepSize: model.valueStep * div value: model.value * div - onValueChanged: model.value = value/div + onValueModified: model.value = value/div editable: true property real realValue: value / div From 896e44d507f2873a450a26ed454b77673ea2da02 Mon Sep 17 00:00:00 2001 From: alwayshopeless <38844539+alwayshopeless@users.noreply.github.com> Date: Sun, 24 Aug 2025 18:15:17 +0300 Subject: [PATCH 09/41] Update README.md Added missing library for installation in the guide for Arch-based(Manjaro) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b4c6fa8e..a7dc62e2 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ sudo pacman -S qt6-base \ gcc \ fontconfig \ lmdb \ + lmdbxx \ cmark \ qtkeychain-qt6 ``` From af2ca72030deb14a920a888e807dc732d93e3714 Mon Sep 17 00:00:00 2001 From: Heiko Becker Date: Thu, 4 Sep 2025 17:29:17 +0200 Subject: [PATCH 10/41] Search for private modules with Qt 6.10 Usage of private Qt modules requires a call to `find_package(Qt6 COMPONENTS FooPrivate)` since 6.10 [1]. The build complains about 'CMake Error at CMakeLists.txt:909 (target_link_libraries): Target "nheko" links to: Qt::QmlPrivate but the target was not found.' otherwise. [1] https://doc-snapshots.qt.io/qt6-dev/whatsnew610.html#build-system-changes --- CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c085417..3476f51a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,6 +247,9 @@ endif() # Discover Qt dependencies. # find_package(Qt6 6.5 COMPONENTS Core Widgets Gui LinguistTools Svg Multimedia Qml QuickControls2 REQUIRED) +if (Qt6Qml_VERSION VERSION_GREATER_EQUAL "6.10.0") + find_package(Qt6 REQUIRED COMPONENTS GuiPrivate QmlPrivate) +endif() find_package(Qt6DBus) if(USE_BUNDLED_QTKEYCHAIN) From 53cd31d181ac2e2398cf20d1901d3e26efc65016 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 13 Sep 2025 01:01:06 +0200 Subject: [PATCH 11/41] Allow filtering the timeline for notifications --- resources/icons/ui/alert.svg | 1 + resources/qml/MessageView.qml | 4 +++- resources/qml/TimelineView.qml | 1 + resources/qml/TopBar.qml | 33 ++++++++++++++++++++++------ resources/res.qrc | 1 + src/timeline/TimelineFilter.cpp | 38 ++++++++++++++++++++++++++++++--- src/timeline/TimelineFilter.h | 6 ++++++ 7 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 resources/icons/ui/alert.svg diff --git a/resources/icons/ui/alert.svg b/resources/icons/ui/alert.svg new file mode 100644 index 00000000..7b730a59 --- /dev/null +++ b/resources/icons/ui/alert.svg @@ -0,0 +1 @@ + diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml index 8a457afb..0c71573a 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml @@ -16,6 +16,7 @@ Item { property int availableWidth: width property int padding: Nheko.paddingMedium property string searchString: "" + property bool filterByNotifications: false property Room roommodel: room // HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu @@ -60,7 +61,7 @@ Item { boundsBehavior: Flickable.StopAtBounds displayMarginBeginning: height / 4 displayMarginEnd: height / 4 - model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room + model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent || filteredTimeline.filterByNotifications) ? filteredTimeline : room //pixelAligned: true spacing: 2 verticalLayoutDirection: ListView.BottomToTop @@ -145,6 +146,7 @@ Item { id: filteredTimeline filterByContent: chatRoot.searchString + filterByNotifications: chatRoot.filterByNotifications filterByThread: room ? room.thread : "" source: room } diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 8e83cc1f..64493c4e 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -119,6 +119,7 @@ Item { Layout.fillWidth: true implicitHeight: msgView.height - typingIndicator.height searchString: topBar.searchString + filterByNotifications: topBar.filterNotifications } Loader { source: CallManager.isOnCall && CallManager.callType != Voip.VOICE ? (Qt.platform.os != "windows" ? "voip/VideoCall.qml" : "voip/VideoCallD3D11.qml") : "" diff --git a/resources/qml/TopBar.qml b/resources/qml/TopBar.qml index 0bdd4ab8..900e59e8 100644 --- a/resources/qml/TopBar.qml +++ b/resources/qml/TopBar.qml @@ -22,6 +22,7 @@ Pane { property bool searchHasFocus: searchField.focus && searchField.enabled property string searchString: "" property bool showBackButton: false + property bool filterNotifications: false property int trustlevel: room ? room.trustlevel : Crypto.Unverified Layout.fillWidth: true @@ -129,13 +130,30 @@ Pane { selectByMouse: false text: roomTopic } + ImageButton { + id: notificationsButton + + Layout.alignment: Qt.AlignRight + Layout.column: 3 + Layout.preferredHeight: Nheko.avatarSize - Nheko.paddingMedium + Layout.preferredWidth: Nheko.avatarSize - Nheko.paddingMedium + Layout.row: 1 + Layout.rowSpan: 2 + ToolTip.text: qsTr("Show only notifications") + ToolTip.visible: hovered + image: ":/icons/icons/ui/alert.svg" + + onClicked: { + topBar.filterNotifications = !topBar.filterNotifications + } + } ImageButton { id: pinButton property bool pinsShown: !Settings.hiddenPins.includes(roomId) Layout.alignment: Qt.AlignVCenter - Layout.column: 3 + Layout.column: 4 Layout.preferredHeight: Nheko.avatarSize - Nheko.paddingMedium Layout.preferredWidth: Nheko.avatarSize - Nheko.paddingMedium Layout.row: 1 @@ -160,7 +178,7 @@ Pane { } AbstractButton { id: memberButton - Layout.column: 4 + Layout.column: 5 Layout.preferredHeight: Nheko.avatarSize - Nheko.paddingMedium Layout.preferredWidth: Nheko.avatarSize - Nheko.paddingMedium Layout.row: 1 @@ -200,7 +218,7 @@ Pane { property bool searchActive: false Layout.alignment: Qt.AlignVCenter - Layout.column: 5 + Layout.column: 6 Layout.preferredHeight: Nheko.avatarSize - Nheko.paddingMedium Layout.preferredWidth: Nheko.avatarSize - Nheko.paddingMedium Layout.row: 1 @@ -224,7 +242,7 @@ Pane { id: roomOptionsButton Layout.alignment: Qt.AlignVCenter - Layout.column: 6 + Layout.column: 7 Layout.preferredHeight: Nheko.avatarSize - Nheko.paddingMedium Layout.preferredWidth: Nheko.avatarSize - Nheko.paddingMedium Layout.row: 1 @@ -273,7 +291,7 @@ Pane { id: pinnedMessages Layout.column: 2 - Layout.columnSpan: 4 + Layout.columnSpan: 5 Layout.fillWidth: true Layout.preferredHeight: Math.min(contentHeight, Nheko.avatarSize * 4) Layout.row: 3 @@ -330,7 +348,7 @@ Pane { id: widgets Layout.column: 2 - Layout.columnSpan: 4 + Layout.columnSpan: 5 Layout.fillWidth: true Layout.preferredHeight: Math.min(contentHeight, Nheko.avatarSize * 1.5) Layout.row: 4 @@ -356,7 +374,7 @@ Pane { id: searchField Layout.column: 2 - Layout.columnSpan: 4 + Layout.columnSpan: 5 Layout.fillWidth: true Layout.row: 5 enabled: visible @@ -378,6 +396,7 @@ Pane { searchString = ""; searchButton.searchActive = false; searchField.text = ""; + filterNotifications = false; } // HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu diff --git a/resources/res.qrc b/resources/res.qrc index 642bc220..13d4c371 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -1,6 +1,7 @@ icons/ui/add-square-button.svg + icons/ui/alert.svg icons/ui/angle-arrow-left.svg icons/ui/attach.svg icons/ui/ban.svg diff --git a/src/timeline/TimelineFilter.cpp b/src/timeline/TimelineFilter.cpp index 0833900e..8e323ff6 100644 --- a/src/timeline/TimelineFilter.cpp +++ b/src/timeline/TimelineFilter.cpp @@ -96,6 +96,10 @@ TimelineFilter::setThreadId(const QString &t) { nhlog::ui()->debug("Filtering by thread '{}'", t.toStdString()); if (this->threadId != t) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + beginFilterChange(); +#endif + this->threadId = t; emit threadIdChanged(); @@ -104,11 +108,30 @@ TimelineFilter::setThreadId(const QString &t) } } +void +TimelineFilter::setFilterNotifications(bool filter) +{ + nhlog::ui()->debug("Filtering by notifications '{}'", filter); + if (this->filterByNotifications_ != filter) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + beginFilterChange(); +#endif + this->filterByNotifications_ = filter; + + emit filterNotificationsChanged(); + startFiltering(); + fetchMore({}); + } +} + void TimelineFilter::setContentFilter(const QString &c) { nhlog::ui()->debug("Filtering by content '{}'", c.toStdString()); if (this->contentFilter != c) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + beginFilterChange(); +#endif this->contentFilter = c; emit contentFilterChanged(); @@ -145,7 +168,8 @@ TimelineFilter::sourceDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { - if (!roles.contains(TimelineModel::Roles::Body) && !roles.contains(TimelineModel::ThreadId)) + if (!roles.contains(TimelineModel::Roles::Body) && !roles.contains(TimelineModel::ThreadId) && + !roles.contains(TimelineModel::Notificationlevel)) return; if (auto s = source()) { @@ -233,19 +257,27 @@ TimelineFilter::filterAcceptsRow(int source_row, const QModelIndex &) const if (source_row > incrementalSearchIndex) return false; - if (threadId.isEmpty() && contentFilter.isEmpty()) + if (threadId.isEmpty() && contentFilter.isEmpty() && !filterByNotifications_) return true; if (auto s = sourceModel()) { auto idx = s->index(source_row, 0); + if (!contentFilter.isEmpty() && !s->data(idx, TimelineModel::Body) .toString() .contains(contentFilter, Qt::CaseInsensitive)) { return false; } - if (threadId.isEmpty()) + if (filterByNotifications_ && s->data(idx, TimelineModel::Notificationlevel) + .value() != + qml_mtx_events::NotificationLevel::Highlight) { + return false; + } + + if (threadId.isEmpty()) { return true; + } return s->data(idx, TimelineModel::EventId) == threadId || s->data(idx, TimelineModel::ThreadId) == threadId; diff --git a/src/timeline/TimelineFilter.h b/src/timeline/TimelineFilter.h index 336339e2..76d5bf52 100644 --- a/src/timeline/TimelineFilter.h +++ b/src/timeline/TimelineFilter.h @@ -18,6 +18,8 @@ class TimelineFilter : public QSortFilterProxyModel QML_ELEMENT Q_PROPERTY(QString filterByThread READ filterByThread WRITE setThreadId NOTIFY threadIdChanged) + Q_PROPERTY(bool filterByNotifications READ filterByNotifications WRITE setFilterNotifications + NOTIFY filterNotificationsChanged) Q_PROPERTY(QString filterByContent READ filterByContent WRITE setContentFilter NOTIFY contentFilterChanged) Q_PROPERTY(TimelineModel *source READ source WRITE setSource NOTIFY sourceChanged) @@ -28,12 +30,14 @@ public: explicit TimelineFilter(QObject *parent = nullptr); QString filterByThread() const { return threadId; } + bool filterByNotifications() const { return filterByNotifications_; } QString filterByContent() const { return contentFilter; } TimelineModel *source() const; int currentIndex() const; bool isFiltering() const; void setThreadId(const QString &t); + void setFilterNotifications(bool v); void setContentFilter(const QString &t); void setSource(TimelineModel *t); void setCurrentIndex(int idx); @@ -47,6 +51,7 @@ public: signals: void threadIdChanged(); + void filterNotificationsChanged(); void contentFilterChanged(); void sourceChanged(); void currentIndexChanged(); @@ -67,4 +72,5 @@ private: QString threadId, contentFilter; int cachedCount = 0, incrementalSearchIndex = 0; + bool filterByNotifications_ = false; }; From 2769642d3c7bd3c0d830b2f18ef6b3bf6a710bf4 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 14 Sep 2025 23:43:20 +0200 Subject: [PATCH 12/41] Fix most reply rendering issues with qt 6.9.2 --- resources/qml/TimelineBubbleMessageStyle.qml | 31 ++++---- resources/qml/TimelineDefaultMessageStyle.qml | 32 ++++----- resources/qml/TopBar.qml | 2 +- resources/qml/delegates/Reply.qml | 70 ++++++++++--------- 4 files changed, 65 insertions(+), 70 deletions(-) diff --git a/resources/qml/TimelineBubbleMessageStyle.qml b/resources/qml/TimelineBubbleMessageStyle.qml index 560cb133..722718bc 100644 --- a/resources/qml/TimelineBubbleMessageStyle.qml +++ b/resources/qml/TimelineBubbleMessageStyle.qml @@ -210,9 +210,10 @@ TimelineEvent { AbstractButton { id: replyRow - visible: wrapper.reply + visible: wrapper.replyTo + + leftPadding: Nheko.paddingSmall + 4 - height: replyLine.height anchors.left: parent.left anchors.right: parent.right @@ -225,19 +226,7 @@ TimelineEvent { cursorShape: Qt.PointingHandCursor } - contentItem: Row { - id: replyRowLay - - spacing: Nheko.paddingSmall - - Rectangle { - id: replyLine - height: Math.min( wrapper.reply?.height, timelineView.height / 10) + Nheko.paddingSmall + replyUserButton.height - color: replyRow.userColor - width: 4 - } - - Column { + contentItem: Column { spacing: 0 id: replyCol @@ -247,7 +236,7 @@ TimelineEvent { contentItem: Label { id: userName_ - text: wrapper.reply?.userName ?? '' + text: wrapper.reply?.userName ?? 'missing name' color: replyRow.userColor textFormat: Text.RichText width: wrapper.maxWidth @@ -259,12 +248,20 @@ TimelineEvent { replyUserButton, wrapper.reply, ] - } } background: Rectangle { //width: replyRow.implicitContentWidth color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1)) + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + + id: replyLine + color: replyRow.userColor + width: 4 + } } onClicked: { diff --git a/resources/qml/TimelineDefaultMessageStyle.qml b/resources/qml/TimelineDefaultMessageStyle.qml index 2bc0171a..49454ac0 100644 --- a/resources/qml/TimelineDefaultMessageStyle.qml +++ b/resources/qml/TimelineDefaultMessageStyle.qml @@ -192,9 +192,9 @@ TimelineEvent { AbstractButton { id: replyRow - visible: wrapper.reply + visible: wrapper.replyTo - height: replyLine.height + leftPadding: Nheko.paddingSmall + 4 property color userColor: TimelineManager.userColor(wrapper.reply?.userId ?? '', palette.base) @@ -205,19 +205,7 @@ TimelineEvent { cursorShape: Qt.PointingHandCursor } - contentItem: Row { - id: replyRowLay - - spacing: Nheko.paddingSmall - - Rectangle { - id: replyLine - height: Math.min( wrapper.reply?.height, timelineView.height / 10) + Nheko.paddingSmall + replyUserButton.height - color: replyRow.userColor - width: 4 - } - - Column { + contentItem: Column { spacing: 0 id: replyCol @@ -227,7 +215,7 @@ TimelineEvent { contentItem: Label { id: userName_ - text: wrapper.reply?.userName ?? '' + text: wrapper.reply?.userName ?? 'missing name' color: replyRow.userColor textFormat: Text.RichText width: wrapper.maxWidth @@ -239,12 +227,20 @@ TimelineEvent { replyUserButton, wrapper.reply, ] - } } background: Rectangle { - //width: replyRow.implicitContentWidth color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1)) + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + + id: replyLine + color: replyRow.userColor + width: 4 + } } onClicked: { diff --git a/resources/qml/TopBar.qml b/resources/qml/TopBar.qml index 900e59e8..cd20e94e 100644 --- a/resources/qml/TopBar.qml +++ b/resources/qml/TopBar.qml @@ -330,7 +330,7 @@ Pane { ImageButton { id: deletePinButton - Layout.alignment: Qt.AlignTop | Qt.AlignLeft + Layout.alignment: Qt.AlignTop | Qt.AlignRight Layout.preferredHeight: 16 Layout.preferredWidth: 16 ToolTip.text: qsTr("Unpin") diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml index 7ee2a0a1..77cad0f0 100644 --- a/resources/qml/delegates/Reply.qml +++ b/resources/qml/delegates/Reply.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Window +import QtQuick.Layouts import im.nheko import "../" @@ -21,7 +22,11 @@ AbstractButton { property string userId: eventId ? room.dataById(eventId, Room.UserId, "") : "" property string userName: eventId ? room.dataById(eventId, Room.UserName, "") : "" implicitHeight: replyContainer.height - implicitWidth: replyContainer.implicitWidth + implicitWidth: replyContainer.implicitWidth + leftPadding + rightPadding + + leftPadding: 4 + Nheko.paddingSmall + rightPadding: Nheko.paddingSmall + required property int maxWidth property bool limitHeight: false @@ -31,14 +36,14 @@ AbstractButton { } onClicked: { - let link = reply.child.linkAt != undefined && reply.child.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight); + let link = timelineEvent.main.linkAt != undefined && timelineEvent.main.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight); if (link) { Nheko.openLink(link) } else { room.showEvent(r.eventId) } } - onPressAndHold: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight), r.eventId) + onPressAndHold: replyContextMenu.show(timelineEvent.main.copyText, timelineEvent.main.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight), r.eventId) contentItem: TimelineEvent { id: timelineEvent @@ -51,49 +56,36 @@ AbstractButton { maxWidth: r.maxWidth limitAsReply: r.limitHeight - //height: replyContainer.implicitHeight - data: Row { + data: Column { id: replyContainer - - spacing: Nheko.paddingSmall + spacing: 0 clip: r.limitHeight height: r.limitHeight ? Math.min( timelineEvent.main?.height, timelineView.height / 10) + Nheko.paddingSmall + usernameBtn.height : undefined - Rectangle { - id: colorline + // FIXME: I have no idea, why this name doesn't render in the reply popup on Qt 6.9.2 + AbstractButton { + id: usernameBtn - width: 4 - height: content.height + visible: r.eventId - color: TimelineManager.userColor(r.userId, palette.base) - } - - Column { - id: content - spacing: 0 - - AbstractButton { - id: usernameBtn - - - contentItem: Label { - id: userName_ - text: r.userName - color: r.userColor - textFormat: Text.RichText - width: timelineEvent.main?.width - } - onClicked: room.openUserProfile(r.userId) + contentItem: Label { + visible: r.eventId + id: userName_ + text: r.userName + color: r.userColor + textFormat: Text.RichText + width: timelineEvent.main?.width } - - data: [ - usernameBtn, timelineEvent.main, - ] + onClicked: room.openUserProfile(r.userId) } + data: [ + usernameBtn, timelineEvent.main, + ] } + } background: Rectangle { @@ -103,6 +95,16 @@ AbstractButton { property color userColor: TimelineManager.userColor(r.userId, palette.base) property color bgColor: palette.base color: Qt.tint(bgColor, Qt.hsla(userColor.hslHue, 0.5, userColor.hslLightness, 0.1)) + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + + id: colorline + color: backgroundItem.userColor + width: 4 + } } } From 4a85031516b424be3466e7cbfd01ac23a9d29efe Mon Sep 17 00:00:00 2001 From: Sofia/Nep Date: Mon, 22 Sep 2025 14:04:35 -0300 Subject: [PATCH 13/41] Improved v12 support --- CMakeLists.txt | 2 +- resources/qml/TimelineSectionHeader.qml | 3 +- .../qml/components/PowerlevelIndicator.qml | 7 ++- resources/qml/dialogs/RoomMembers.qml | 1 + src/Cache.cpp | 56 +++++++++++++++++++ src/Cache.h | 4 ++ src/Cache_p.h | 4 ++ src/MemberList.cpp | 24 ++++++++ src/MemberList.h | 1 + src/timeline/Permissions.cpp | 37 ++++++++---- src/timeline/Permissions.h | 2 + src/timeline/TimelineModel.cpp | 6 ++ src/timeline/TimelineModel.h | 1 + 13 files changed, 132 insertions(+), 16 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3476f51a..0eaa6783 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -618,7 +618,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG v0.10.1 + GIT_TAG d6f10427d1c5e5b1a45f426274f8d2e8dd0b64be ) set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "") set(BUILD_LIB_TESTS OFF CACHE INTERNAL "") diff --git a/resources/qml/TimelineSectionHeader.qml b/resources/qml/TimelineSectionHeader.qml index 20dbaf6a..f504c463 100644 --- a/resources/qml/TimelineSectionHeader.qml +++ b/resources/qml/TimelineSectionHeader.qml @@ -89,6 +89,7 @@ Column { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter + isV12Creator: room.isV12Creator(userId) powerlevel: userPowerlevel height: fontMetrics.ascent width: height @@ -97,7 +98,7 @@ Column { sourceSize.height: height permissions: room ? room.permissions : null - visible: isAdmin || isModerator + visible: isAdmin || isModerator || isV12Creator } ToolTip.delay: Nheko.tooltipDelay diff --git a/resources/qml/components/PowerlevelIndicator.qml b/resources/qml/components/PowerlevelIndicator.qml index 6a6d89af..07453828 100644 --- a/resources/qml/components/PowerlevelIndicator.qml +++ b/resources/qml/components/PowerlevelIndicator.qml @@ -9,13 +9,14 @@ import im.nheko Image { required property int powerlevel required property var permissions + required property bool isV12Creator readonly property bool isAdmin: permissions ? permissions.changeLevel(MtxEvent.PowerLevels) <= powerlevel : false readonly property bool isModerator: permissions ? permissions.redactLevel() <= powerlevel : false readonly property bool isDefault: permissions ? permissions.defaultLevel() <= powerlevel : false readonly property string sourceUrl: { - if (isAdmin) + if (isAdmin || isV12Creator) return "image://colorimage/:/icons/icons/ui/ribbon_star.svg?"; else if (isModerator) return "image://colorimage/:/icons/icons/ui/ribbon.svg?"; @@ -26,7 +27,9 @@ Image { source: sourceUrl + (ma.hovered ? palette.highlight : palette.buttonText) ToolTip.visible: ma.hovered ToolTip.text: { - if (isAdmin) + if (isV12Creator) + return qsTr("Creator"); + else if (isAdmin) return qsTr("Administrator: %1").arg(powerlevel); else if (isModerator) return qsTr("Moderator: %1").arg(powerlevel); diff --git a/resources/qml/dialogs/RoomMembers.qml b/resources/qml/dialogs/RoomMembers.qml index 95dc9fc3..f03fda96 100644 --- a/resources/qml/dialogs/RoomMembers.qml +++ b/resources/qml/dialogs/RoomMembers.qml @@ -168,6 +168,7 @@ ApplicationWindow { sourceSize.height: height powerlevel: model.powerlevel permissions: room.permissions + isV12Creator: room.isV12Creator(model.mxid) } EncryptionIndicator { diff --git a/src/Cache.cpp b/src/Cache.cpp index 84cad3ab..ca8644ba 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -4851,6 +4851,51 @@ Cache::getAccountData(lmdb::txn &txn, mtx::events::EventType type, const std::st return std::nullopt; } +bool +Cache::isV12Creator(const std::string &room_id, const std::string &user_id) +{ + auto txn = ro_txn(this->db->env_); + auto state = this->getStatesDb(txn, room_id); + + return this->isV12Creator(txn, state, user_id); +} + +bool +Cache::isV12Creator(lmdb::txn &txn, lmdb::dbi &state, const std::string &user_id) +{ + using namespace mtx::events; + using namespace mtx::events::state; + + bool ok; + const int room_version = this->getRoomVersion(txn, state).toInt(&ok); + if (!ok || room_version < 12) { + return false; + } + + std::string_view create_event; + if (state.get(txn, to_string(EventType::RoomCreate), create_event)) { + try { + const StateEvent evt = + nlohmann::json::parse(create_event).get>(); + if (evt.sender == user_id) { + return true; + } + + const std::optional> &additional_creators = + evt.content.additional_creators; + if (additional_creators && + std::find(additional_creators->begin(), additional_creators->end(), user_id) != + additional_creators->end()) { + return true; + } + } catch (...) { + return false; + } + } + + return false; +} + bool Cache::hasEnoughPowerLevel(const std::vector &eventTypes, const std::string &room_id, @@ -4863,6 +4908,10 @@ Cache::hasEnoughPowerLevel(const std::vector &eventTypes try { auto db_ = getStatesDb(txn, room_id); + if (this->isV12Creator(txn, db_, user_id)) { + return true; + } + int64_t min_event_level = std::numeric_limits::max(); int64_t user_level = std::numeric_limits::min(); @@ -6129,6 +6178,13 @@ roomMembers(const std::string &room_id) return instance_->roomMembers(room_id); } +//! Check if the given user is a room creator and that gives them an infinite PL. +bool +isV12Creator(const std::string &room_id, const std::string &user_id) +{ + return instance_->isV12Creator(room_id, user_id); +} + //! Check if the given user has power level greater than //! lowest power level of the given events. bool diff --git a/src/Cache.h b/src/Cache.h index 7c79881c..c6118c78 100644 --- a/src/Cache.h +++ b/src/Cache.h @@ -123,6 +123,10 @@ runMigrations(); std::vector roomMembers(const std::string &room_id); +//! Check if the given user is a room creator and that gives them an infinite PL. +bool +isV12Creator(const std::string &room_id, const std::string &user_id); + //! Check if the given user has power level greater than than //! lowest power level of the given events. bool diff --git a/src/Cache_p.h b/src/Cache_p.h index 89fdd843..ee7913d5 100644 --- a/src/Cache_p.h +++ b/src/Cache_p.h @@ -145,6 +145,10 @@ public: //! Retrieve all the user ids from a room. std::vector roomMembers(const std::string &room_id); + //! Check if the given user is a room creator and that gives them an infinite PL. + bool isV12Creator(const std::string &room_id, const std::string &user_id); + bool isV12Creator(lmdb::txn &txn, lmdb::dbi &state, const std::string &user_id); + //! Check if the given user has power leve greater than than //! lowest power level of the given events. bool hasEnoughPowerLevel(const std::vector &eventTypes, diff --git a/src/MemberList.cpp b/src/MemberList.cpp index 1d939bfa..bc493165 100644 --- a/src/MemberList.cpp +++ b/src/MemberList.cpp @@ -172,4 +172,28 @@ MemberList::filterAcceptsRow(int source_row, const QModelIndex &) const Qt::CaseInsensitive); } +bool +MemberList::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const +{ + if (this->sortRole() != MemberSortRoles::Powerlevel) { + return QSortFilterProxyModel::lessThan(source_left, source_right); + } + + const QString &left = + this->m_model.data(source_left, MemberListBackend::Roles::Mxid).toString(); + const QString &right = + this->m_model.data(source_right, MemberListBackend::Roles::Mxid).toString(); + + const std::string &room_id = this->roomId().toStdString(); + if (cache::isV12Creator(room_id, left.toStdString())) { + if (!cache::isV12Creator(room_id, right.toStdString())) { + return false; + } + // If both are creators, sort by mxid. + return left < right; + } + + return QSortFilterProxyModel::lessThan(source_left, source_right); +} + #include "moc_MemberList.cpp" diff --git a/src/MemberList.h b/src/MemberList.h index f1d39336..46d73b53 100644 --- a/src/MemberList.h +++ b/src/MemberList.h @@ -122,6 +122,7 @@ public slots: protected: bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; private: QString filterString; diff --git a/src/timeline/Permissions.cpp b/src/timeline/Permissions.cpp index 2ef6e5cd..569bf2bc 100644 --- a/src/timeline/Permissions.cpp +++ b/src/timeline/Permissions.cpp @@ -27,39 +27,45 @@ Permissions::invalidate() bool Permissions::canInvite() { - return pl.user_level(http::client()->user_id().to_string()) >= pl.invite; + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.invite; + return plCheck || this->isV12Creator(); } bool Permissions::canBan() { - return pl.user_level(http::client()->user_id().to_string()) >= pl.ban; + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.ban; + return plCheck || this->isV12Creator(); } bool Permissions::canKick() { - return pl.user_level(http::client()->user_id().to_string()) >= pl.kick; + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.kick; + return plCheck || this->isV12Creator(); } bool Permissions::canRedact() { - return pl.user_level(http::client()->user_id().to_string()) >= pl.redact; + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.redact; + return plCheck || this->isV12Creator(); } bool Permissions::canChange(int eventType) { - return pl.user_level(http::client()->user_id().to_string()) >= - pl.state_level(to_string( - qml_mtx_events::fromRoomEventType(static_cast(eventType)))); + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= + pl.state_level(to_string(qml_mtx_events::fromRoomEventType( + static_cast(eventType)))); + return plCheck || this->isV12Creator(); } bool Permissions::canSend(int eventType) { - return pl.user_level(http::client()->user_id().to_string()) >= - pl.event_level(to_string( - qml_mtx_events::fromRoomEventType(static_cast(eventType)))); + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= + pl.event_level(to_string(qml_mtx_events::fromRoomEventType( + static_cast(eventType)))); + return plCheck || this->isV12Creator(); } int @@ -88,8 +94,15 @@ Permissions::sendLevel(int eventType) bool Permissions::canPingRoom() { - return pl.user_level(http::client()->user_id().to_string()) >= - pl.notification_level(mtx::events::state::notification_keys::room); + const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= + pl.notification_level(mtx::events::state::notification_keys::room); + return plCheck || this->isV12Creator(); } +bool +Permissions::isV12Creator() +{ + return cache::client()->isV12Creator(this->roomId_.toStdString(), + http::client()->user_id().to_string()); +} #include "moc_Permissions.cpp" diff --git a/src/timeline/Permissions.h b/src/timeline/Permissions.h index 05513524..ac36d5ea 100644 --- a/src/timeline/Permissions.h +++ b/src/timeline/Permissions.h @@ -36,6 +36,8 @@ public: const mtx::events::state::PowerLevels &powerlevelEvent() const { return pl; }; private: + bool isV12Creator(); + QString roomId_; mtx::events::state::PowerLevels pl; }; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index d5645ac4..f4be6227 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -1414,6 +1414,12 @@ TimelineModel::readEvent(const std::string &id) !UserSettings::instance()->readReceipts()); } +bool +TimelineModel::isV12Creator(const QString &id) const +{ + return cache::isV12Creator(this->roomId().toStdString(), id.toStdString()); +} + QString TimelineModel::displayName(const QString &id) const { diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index ad9f574e..f28a4dd7 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -302,6 +302,7 @@ public: static QString getBareRoomLink(const QString &); static QString getRoomVias(const QString &); + Q_INVOKABLE bool isV12Creator(const QString &id) const; Q_INVOKABLE QString displayName(const QString &id) const; Q_INVOKABLE QString avatarUrl(const QString &id) const; Q_INVOKABLE QString formatDateSeparator(QDate date) const; From b0d09926a12757d3b3066a6be62975bbd5a9f5aa Mon Sep 17 00:00:00 2001 From: Sofia/Nep Date: Mon, 22 Sep 2025 16:03:41 -0300 Subject: [PATCH 14/41] Remove unnecessary references, not harmful in the absence of RVO but redundant due to move semantics --- src/MemberList.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MemberList.cpp b/src/MemberList.cpp index bc493165..fea027a3 100644 --- a/src/MemberList.cpp +++ b/src/MemberList.cpp @@ -179,12 +179,12 @@ MemberList::lessThan(const QModelIndex &source_left, const QModelIndex &source_r return QSortFilterProxyModel::lessThan(source_left, source_right); } - const QString &left = + const QString left = this->m_model.data(source_left, MemberListBackend::Roles::Mxid).toString(); - const QString &right = + const QString right = this->m_model.data(source_right, MemberListBackend::Roles::Mxid).toString(); - const std::string &room_id = this->roomId().toStdString(); + const std::string room_id = this->roomId().toStdString(); if (cache::isV12Creator(room_id, left.toStdString())) { if (!cache::isV12Creator(room_id, right.toStdString())) { return false; From 451e88fe7290e405f9174f22f76cb3297ae856ea Mon Sep 17 00:00:00 2001 From: Matti Viljanen Date: Sat, 27 Sep 2025 17:41:43 +0300 Subject: [PATCH 15/41] Add a setting to send messages with Enter, Shift+Enter or Ctrl+Enter Previously the option was just `invertEnterKey` boolean, which didn't allow any flexibility, so I replaced it with a three-choice option: Enter, Shift+Enter and Ctrl+Enter being the send message choices. Add newline combos are Shift+Enter, Enter and Shift+Enter respectively. I ended up fixing the emoji/mention pop-up behavior as a side product. If any of the three combos are pressed, the pop-up is handled and the event is accepted. This makes it impossible to accidentally send the message if a pop-up is open. If an Enter combo didn't match, it's passed to the next event handler. The old `invertEnterKey` is migrated to the new `sendMessageKey`, so this change doesn't change the existing preference. --- resources/qml/MessageInput.qml | 34 +++++++++++------ src/UserSettingsPage.cpp | 67 ++++++++++++++++++++++------------ src/UserSettingsPage.h | 22 +++++++---- 3 files changed, 81 insertions(+), 42 deletions(-) diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml index a23e1b60..4cc1445c 100644 --- a/resources/qml/MessageInput.qml +++ b/resources/qml/MessageInput.qml @@ -170,15 +170,13 @@ Rectangle { } else if (event.matches(StandardKey.SelectAll) && popup.opened) { completer.completerName = ""; popup.close(); - } else if (event.matches(StandardKey.InsertLineSeparator)) { - if (popup.opened) - popup.close(); - if (Settings.invertEnterKey) { - room.input.send(); - event.accepted = true; - } - } else if (event.matches(StandardKey.InsertParagraphSeparator)) { - if (popup.opened) { + } else if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return) { + // Handling popup takes priority over newline and sending message. + if (popup.opened && + (event.modifiers == Qt.NoModifier + || event.modifiers == Qt.ShiftModifier + || event.modifiers == Qt.ControlModifier) + ) { var currentCompletion = completer.currentCompletion(); let userid = completer.currentUserid(); @@ -191,14 +189,26 @@ Rectangle { console.log(userid); room.input.addMention(userid, currentCompletion); } - event.accepted = true; - return; } + event.accepted = true; } - if (!Settings.invertEnterKey) { + // Send message Enter key combination event. + else if (Settings.sendMessageKey == 0 && event.modifiers == Qt.NoModifier + || Settings.sendMessageKey == 1 && event.modifiers == Qt.ShiftModifier + || Settings.sendMessageKey == 2 && event.modifiers == Qt.ControlModifier + ) { room.input.send(); event.accepted = true; } + // Add newline Enter key combination event. + else if (Settings.sendMessageKey == 0 && event.modifiers == Qt.ShiftModifier + || Settings.sendMessageKey == 1 && event.modifiers == Qt.NoModifier + || Settings.sendMessageKey == 2 && event.modifiers == Qt.ShiftModifier + ) { + messageInput.insert(messageInput.cursorPosition, "\n"); + event.accepted = true; + } + // Any other Enter key combo is ignored here. } else if (event.key == Qt.Key_Tab && (event.modifiers == Qt.NoModifier || event.modifiers == Qt.ShiftModifier)) { event.accepted = true; if (popup.opened) { diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp index 3f348d61..db3bf7f1 100644 --- a/src/UserSettingsPage.cpp +++ b/src/UserSettingsPage.cpp @@ -35,6 +35,14 @@ QSharedPointer UserSettings::instance_; UserSettings::UserSettings() { + if (settings.contains("user/invert_enter_key")) { + auto oldValue = + (settings.value("user/invert_enter_key", false).toBool() ? SendMessageKey::ShiftEnter + : SendMessageKey::Enter); + settings.setValue("user/send_message_key", static_cast(oldValue)); + settings.remove("user/invert_enter_key"); + } + connect( QCoreApplication::instance(), &QCoreApplication::aboutToQuit, []() { instance_.clear(); }); } @@ -71,8 +79,13 @@ UserSettings::load(std::optional profile) settings.value("user/timeline/message_hover_highlight", false).toBool(); enlargeEmojiOnlyMessages_ = settings.value("user/timeline/enlarge_emoji_only_msg", false).toBool(); - markdown_ = settings.value("user/markdown_enabled", true).toBool(); - invertEnterKey_ = settings.value("user/invert_enter_key", false).toBool(); + markdown_ = settings.value("user/markdown_enabled", true).toBool(); + + auto sendMessageKey = settings.value("user/send_message_key", 0).toInt(); + if (sendMessageKey < 0 || sendMessageKey > 2) + sendMessageKey = static_cast(SendMessageKey::Enter); + sendMessageKey_ = static_cast(sendMessageKey); + bubbles_ = settings.value("user/bubbles_enabled", false).toBool(); smallAvatars_ = settings.value("user/small_avatars_enabled", false).toBool(); animateImagesOnHover_ = settings.value("user/animate_images_on_hover", false).toBool(); @@ -340,13 +353,12 @@ UserSettings::setMarkdown(bool state) } void -UserSettings::setInvertEnterKey(bool state) +UserSettings::setSendMessageKey(SendMessageKey key) { - if (state == invertEnterKey_) + if (key == sendMessageKey_) return; - - invertEnterKey_ = state; - emit invertEnterKeyChanged(state); + sendMessageKey_ = key; + emit sendMessageKeyChanged(key); save(); } @@ -936,7 +948,7 @@ UserSettings::save() settings.setValue("group_view", groupView_); settings.setValue("scrollbars_in_roomlist", scrollbarsInRoomlist_); settings.setValue("markdown_enabled", markdown_); - settings.setValue("invert_enter_key", invertEnterKey_); + settings.setValue("send_message_key", static_cast(sendMessageKey_)); settings.setValue("bubbles_enabled", bubbles_); settings.setValue("small_avatars_enabled", smallAvatars_); settings.setValue("animate_images_on_hover", animateImagesOnHover_); @@ -1050,8 +1062,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const return tr("Scrollbars in room list"); case Markdown: return tr("Send messages as Markdown"); - case InvertEnterKey: - return tr("Use shift+enter to send and enter to start a new line"); + case SendMessageKey: + return tr("Send messages with a shortcut"); case Bubbles: return tr("Enable message bubbles"); case SmallAvatars: @@ -1205,8 +1217,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const return i->scrollbarsInRoomlist(); case Markdown: return i->markdown(); - case InvertEnterKey: - return i->invertEnterKey(); + case SendMessageKey: + return static_cast(i->sendMessageKey()); case Bubbles: return i->bubbles(); case SmallAvatars: @@ -1371,10 +1383,11 @@ UserSettingsModel::data(const QModelIndex &index, int role) const return tr( "Allow using markdown in messages.\nWhen disabled, all messages are sent as a plain " "text."); - case InvertEnterKey: + case SendMessageKey: return tr( - "Invert the behavior of the enter key in the text input, making it send the message " - "when shift+enter is pressed and starting a new line when enter is pressed."); + "Select what Enter key combination sends the message. Shift+Enter adds a new line, " + "unless it has been selected, in which case Enter adds a new line instead.\n\n" + "If an emoji picker or a mention picker is open, it is always handled first."); case Bubbles: return tr( "Messages get a bubble background. This also triggers some layout changes (WIP)."); @@ -1542,6 +1555,7 @@ UserSettingsModel::data(const QModelIndex &index, int role) const case CameraFrameRate: case Ringtone: case ShowImage: + case SendMessageKey: return Options; case TimelineMaxWidth: case PrivacyScreenTimeout: @@ -1556,7 +1570,6 @@ UserSettingsModel::data(const QModelIndex &index, int role) const case GroupView: case ScrollbarsInRoomlist: case Markdown: - case InvertEnterKey: case Bubbles: case SmallAvatars: case AnimateImagesOnHover: @@ -1675,6 +1688,12 @@ UserSettingsModel::data(const QModelIndex &index, int role) const tr("Only in private rooms"), tr("Never"), }; + case SendMessageKey: + return QStringList{ + tr("Enter"), + tr("Shift+Enter"), + tr("Ctrl+Enter"), + }; case Microphone: return vecToList(CallDevices::instance().names(false, i->microphone().toStdString())); case Camera: @@ -1816,12 +1835,14 @@ UserSettingsModel::setData(const QModelIndex &index, const QVariant &value, int } else return false; } - case InvertEnterKey: { - if (value.userType() == QMetaType::Bool) { - i->setInvertEnterKey(value.toBool()); - return true; - } else + case SendMessageKey: { + auto newKey = value.toInt(); + if (newKey < 0 || + QMetaEnum::fromType().keyCount() <= newKey) return false; + + i->setSendMessageKey(static_cast(newKey)); + return true; } case Bubbles: { if (value.userType() == QMetaType::Bool) { @@ -2306,8 +2327,8 @@ UserSettingsModel::UserSettingsModel(QObject *p) connect(s.get(), &UserSettings::markdownChanged, this, [this]() { emit dataChanged(index(Markdown), index(Markdown), {Value}); }); - connect(s.get(), &UserSettings::invertEnterKeyChanged, this, [this]() { - emit dataChanged(index(InvertEnterKey), index(InvertEnterKey), {Value}); + connect(s.get(), &UserSettings::sendMessageKeyChanged, this, [this]() { + emit dataChanged(index(SendMessageKey), index(SendMessageKey), {Value}); }); connect(s.get(), &UserSettings::bubblesChanged, this, [this]() { emit dataChanged(index(Bubbles), index(Bubbles), {Value}); diff --git a/src/UserSettingsPage.h b/src/UserSettingsPage.h index 63a4d616..c1c198d2 100644 --- a/src/UserSettingsPage.h +++ b/src/UserSettingsPage.h @@ -29,8 +29,8 @@ class UserSettings final : public QObject Q_PROPERTY(bool scrollbarsInRoomlist READ scrollbarsInRoomlist WRITE setScrollbarsInRoomlist NOTIFY scrollbarsInRoomlistChanged) Q_PROPERTY(bool markdown READ markdown WRITE setMarkdown NOTIFY markdownChanged) - Q_PROPERTY( - bool invertEnterKey READ invertEnterKey WRITE setInvertEnterKey NOTIFY invertEnterKeyChanged) + Q_PROPERTY(SendMessageKey sendMessageKey READ sendMessageKey WRITE setSendMessageKey NOTIFY + sendMessageKeyChanged) Q_PROPERTY(bool bubbles READ bubbles WRITE setBubbles NOTIFY bubblesChanged) Q_PROPERTY(bool smallAvatars READ smallAvatars WRITE setSmallAvatars NOTIFY smallAvatarsChanged) Q_PROPERTY(bool animateImagesOnHover READ animateImagesOnHover WRITE setAnimateImagesOnHover @@ -166,6 +166,14 @@ public: }; Q_ENUM(ShowImage) + enum class SendMessageKey + { + Enter, + ShiftEnter, + CtrlEnter, + }; + Q_ENUM(SendMessageKey) + void save(); void load(std::optional profile); void applyTheme(); @@ -182,7 +190,7 @@ public: void setGroupView(bool state); void setScrollbarsInRoomlist(bool state); void setMarkdown(bool state); - void setInvertEnterKey(bool state); + void setSendMessageKey(SendMessageKey key); void setBubbles(bool state); void setSmallAvatars(bool state); void setAnimateImagesOnHover(bool state); @@ -255,7 +263,7 @@ public: bool privacyScreen() const { return privacyScreen_; } int privacyScreenTimeout() const { return privacyScreenTimeout_; } bool markdown() const { return markdown_; } - bool invertEnterKey() const { return invertEnterKey_; } + SendMessageKey sendMessageKey() const { return sendMessageKey_; } bool bubbles() const { return bubbles_; } bool smallAvatars() const { return smallAvatars_; } bool animateImagesOnHover() const { return animateImagesOnHover_; } @@ -328,7 +336,7 @@ signals: void trayChanged(bool state); void startInTrayChanged(bool state); void markdownChanged(bool state); - void invertEnterKeyChanged(bool state); + void sendMessageKeyChanged(SendMessageKey key); void bubblesChanged(bool state); void smallAvatarsChanged(bool state); void animateImagesOnHoverChanged(bool state); @@ -399,7 +407,7 @@ private: bool groupView_; bool scrollbarsInRoomlist_; bool markdown_; - bool invertEnterKey_; + SendMessageKey sendMessageKey_; bool bubbles_; bool smallAvatars_; bool animateImagesOnHover_; @@ -510,7 +518,7 @@ class UserSettingsModel : public QAbstractListModel TypingNotifications, ReadReceipts, Markdown, - InvertEnterKey, + SendMessageKey, Bubbles, SmallAvatars, From c33f7fde6d21f1472081a1f9758dce933b25aa83 Mon Sep 17 00:00:00 2001 From: RICCIARDI-Adrien Date: Wed, 15 Oct 2025 21:30:20 +0200 Subject: [PATCH 16/41] Update the french translations. --- resources/langs/nheko_fr.ts | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/resources/langs/nheko_fr.ts b/resources/langs/nheko_fr.ts index f06f278e..1cc35f80 100644 --- a/resources/langs/nheko_fr.ts +++ b/resources/langs/nheko_fr.ts @@ -17,7 +17,7 @@ You are screen sharing - Vous êtes en train de partager votre écran. + Vous êtes en train de partager votre écran @@ -312,7 +312,7 @@ Kicked user: %1 - L'utilisateur %1 a été expulsé. + L'utilisateur %1 a été expulsé @@ -322,7 +322,7 @@ Banned user: %1 - L'utilisateur %1 a été banni. + L'utilisateur %1 a été banni @@ -625,32 +625,32 @@ Eventuellement, vous pouvez fournir une explication de votre demande aux autres Kick a user from the current room. Reason is optional. If user is left out, will try to kick the sender you are replying to. - + Expulser un utilisateur de la salle actuelle. La raison est optionnelle. Si l'utilisateur est exclu, une tentative d'expulsion de l'utilisateur auquel vous êtes en train de répondre sera effectuée. Ban a user from the current room. Reason is optional. If user is left out, will try to ban the sender you are replying to. - + Bannir un utilisateur de la salle actuelle. La raison est optionnelle. Si l'utilisateur est exclu, une tentative de bannissement de l'utilisateur auquel vous êtes en train de répondre sera effectuée. Unban a user in the current room. Reason is optional. If user is left out, will try to unban the sender you are replying to. - + Annuler le banissement d'un utilisateur dans le salon actuel. La raison est optionnelle. Si l'utilisateur est exclu, une tentative d'annulation du bannissement de l'utilisateur auquel vous êtes en train de répondre sera effectuée. Redact an event by event id or that you are replying to or all locally cached messages of a user. - + Rédiger un événement grâce à un identifiant événement, ou celui auquel vous êtes en train de répondre, ou tous les messages de l'utilisateur mis en cache localement. Block all invites from a user, a server, to a specific room or set the default. - + Bloquer toutes les invitations en provenance d'un utilisateur ou d'un serveur pour un salon spécifique, ou bien définir le comportement par défaut. Allow all invites from a user, a server, to a specific room or set the default. - + Autoriser toutes les invitations en provenance d'un utilisateur ou d'un serveur pour un salon spécifique, ou bien définir le comportement par défaut. @@ -750,12 +750,12 @@ Eventuellement, vous pouvez fournir une explication de votre demande aux autres Send a message with a glitch effect. - + Envoyer un message avec un effet de déformation. Send a message that gradually glitches. - + Envoyer un message qui se déforme progressivement. @@ -2143,7 +2143,7 @@ Exemple : https://serveur.domaine.extension:8787 %1 replied with a spoiler. Format a reply in a notification. %1 is the sender. - + %1 a répondu avec un spoiler. @@ -2250,7 +2250,7 @@ Exemple : https://serveur.domaine.extension:8787 User (%1) - Utilisateur (%) + Utilisateur (%1) @@ -3706,7 +3706,7 @@ Si vous choisissez de vérifier, vous aurez besoin de l'autre appareil. Si Add or remove from community... - Ajouter ou retirer de la communauté... + Ajouter ou retirer de la communauté... @@ -4307,9 +4307,9 @@ Raison : %4 %n hour(s) later - - - + + %n heure plus tard + %n heures plus tard @@ -5495,7 +5495,7 @@ Cette fonctionnalité prendra effet au prochain redémarrage de l'applicati Repeat File Password - + Répéter le mot de passe du fichier @@ -5686,7 +5686,7 @@ Cette fonctionnalité prendra effet au prochain redémarrage de l'applicati Message contains spoiler. - + Le message contient un spoiler. @@ -5755,13 +5755,13 @@ Cette fonctionnalité prendra effet au prochain redémarrage de l'applicati You sent a spoiler. - + Vous avez envoyé un spoiler. %1 sent a spoiler. - + %1 a envoyé un spoiler. @@ -5788,7 +5788,7 @@ Cette fonctionnalité prendra effet au prochain redémarrage de l'applicati * %1 spoils something. - + * %1 a spoilé quelque chose. From 1bd2970c4dd69daa87d906f63d869d56cf5d4915 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Mon, 3 Nov 2025 03:55:23 +0100 Subject: [PATCH 17/41] Don't send empty SecretRequest cancellation if there are only 2 devices --- src/encryption/Olm.cpp | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/encryption/Olm.cpp b/src/encryption/Olm.cpp index d4074933..52fd3a53 100644 --- a/src/encryption/Olm.cpp +++ b/src/encryption/Olm.cpp @@ -398,17 +398,19 @@ handle_olm_message(const OlmMessage &msg, const UserKeyCache &otherUserDeviceKey body[local_user][dev] = secretRequest; } - http::client()->send_to_device( - http::client()->generate_txn_id(), - body, - [secret_name](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->error("Failed to send request cancellation " - "for secrect " - "'{}'", - secret_name); - } - }); + if (!body.empty()) { + http::client()->send_to_device( + http::client()->generate_txn_id(), + body, + [secret_name](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->error("Failed to send request cancellation " + "for secrect " + "'{}'", + secret_name); + } + }); + } nhlog::crypto()->info("Storing secret {}", secret_name); cache::client()->storeSecret(secret_name, e->content.secret); From 93ce60d6f14679ab9f34edec166e9ad1884d3edd Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Fri, 26 Dec 2025 18:34:30 +0100 Subject: [PATCH 18/41] Qt 6.10 compat --- CMakeLists.txt | 4 +++- src/MxcImageProvider.cpp | 6 ++---- src/timeline/RoomlistModel.cpp | 8 ++++++++ src/timeline/RoomlistModel.h | 8 ++++++++ src/timeline/TimelineFilter.cpp | 15 +++++++++++++++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3476f51a..3fdae58f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -712,7 +712,9 @@ set_target_properties(nheko # # Add qml files # - +if(QT_KNOWN_POLICY_QTP0004) + qt_policy(SET QTP0004 NEW) +endif() set(QML_SOURCES resources/qml/Root.qml resources/qml/ChatPage.qml diff --git a/src/MxcImageProvider.cpp b/src/MxcImageProvider.cpp index d4dd2f16..ba4c4dd0 100644 --- a/src/MxcImageProvider.cpp +++ b/src/MxcImageProvider.cpp @@ -299,12 +299,10 @@ MxcImageProvider::download(const QString &id, "/media_cache", fileName); QDir().mkpath(fileInfo.absolutePath()); + QFile f(fileInfo.absoluteFilePath()); - if (fileInfo.exists()) { + if (fileInfo.exists() && f.open(QIODevice::ReadOnly)) { if (encryptionInfo) { - QFile f(fileInfo.absoluteFilePath()); - f.open(QIODevice::ReadOnly); - QByteArray fileData = f.readAll(); auto tempData = mtx::crypto::to_string( mtx::crypto::decrypt_file(fileData.toStdString(), encryptionInfo.value())); diff --git a/src/timeline/RoomlistModel.cpp b/src/timeline/RoomlistModel.cpp index 823ffe20..ce2a8f84 100644 --- a/src/timeline/RoomlistModel.cpp +++ b/src/timeline/RoomlistModel.cpp @@ -977,6 +977,10 @@ FilteredRoomlistModel::getRoomById(const QString &id) const void FilteredRoomlistModel::updateHiddenTagsAndSpaces() { +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + beginFilterChange(); +#endif + hiddenTags.clear(); hiddenSpaces.clear(); hideDMs = false; @@ -991,7 +995,11 @@ FilteredRoomlistModel::updateHiddenTagsAndSpaces() hideDMs = true; } +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(); +#else invalidateFilter(); +#endif } bool diff --git a/src/timeline/RoomlistModel.h b/src/timeline/RoomlistModel.h index c3f485ef..7dfeb707 100644 --- a/src/timeline/RoomlistModel.h +++ b/src/timeline/RoomlistModel.h @@ -212,6 +212,10 @@ public slots: void updateFilterTag(QString tagId) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + if (tagId.startsWith(QLatin1String("tag:"))) { filterType = FilterBy::Tag; filterStr = tagId.mid(4); @@ -227,7 +231,11 @@ public slots: filterStr.clear(); } +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(); +#else invalidateFilter(); +#endif } void updateHiddenTagsAndSpaces(); diff --git a/src/timeline/TimelineFilter.cpp b/src/timeline/TimelineFilter.cpp index 8e323ff6..b9e93859 100644 --- a/src/timeline/TimelineFilter.cpp +++ b/src/timeline/TimelineFilter.cpp @@ -41,7 +41,13 @@ TimelineFilter::startFiltering() { incrementalSearchIndex = 0; emit isFilteringChanged(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(); +#else invalidateFilter(); +#endif + beginResetModel(); endResetModel(); @@ -181,6 +187,10 @@ void TimelineFilter::setSource(TimelineModel *s) { if (auto orig = this->source(); orig != s) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + cachedCount = 0; incrementalSearchIndex = 0; @@ -215,7 +225,12 @@ TimelineFilter::setSource(TimelineModel *s) emit sourceChanged(); emit isFilteringChanged(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(); +#else invalidateFilter(); +#endif } } From 5b065f353c93725cd96c231d078fb742b1a5aa54 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Fri, 26 Dec 2025 18:48:40 +0100 Subject: [PATCH 19/41] Fix matching strings with accents without typing them --- src/CompletionProxyModel.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CompletionProxyModel.cpp b/src/CompletionProxyModel.cpp index 895cabbb..25cc556e 100644 --- a/src/CompletionProxyModel.cpp +++ b/src/CompletionProxyModel.cpp @@ -44,6 +44,7 @@ CompletionProxyModel::CompletionProxyModel(QAbstractItemModel *model, auto string1 = sourceModel() ->data(sourceModel()->index(i, 0), CompletionModel::SearchRole) .toString() + .normalized(QString::NormalizationForm_KD) .toCaseFolded(); if (!string1.isEmpty()) { trie_.insert(string1.toUcs4(), i); @@ -53,6 +54,7 @@ CompletionProxyModel::CompletionProxyModel(QAbstractItemModel *model, auto string2 = sourceModel() ->data(sourceModel()->index(i, 0), CompletionModel::SearchRole2) .toString() + .normalized(QString::NormalizationForm_KD) .toCaseFolded(); if (!string2.isEmpty()) { trie_.insert(string2.toUcs4(), i); @@ -73,7 +75,7 @@ CompletionProxyModel::CompletionProxyModel(QAbstractItemModel *model, &CompletionProxyModel::newSearchString, this, [this](const QString &s) { - searchString_ = s.toCaseFolded(); + searchString_ = s.normalized(QString::NormalizationForm_KD).toCaseFolded(); invalidate(); }, Qt::QueuedConnection); From 4effdc69551568194951f2e6a777bd7101c220a5 Mon Sep 17 00:00:00 2001 From: Penguin-Guru Date: Thu, 22 Jan 2026 17:47:09 -0800 Subject: [PATCH 20/41] Update nheko.1.adoc Bumped C++ version to match references elsewhere. --- man/nheko.1.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/nheko.1.adoc b/man/nheko.1.adoc index ac7dadff..a4bd0a65 100644 --- a/man/nheko.1.adoc +++ b/man/nheko.1.adoc @@ -7,7 +7,7 @@ == NAME -nheko - Desktop client for Matrix using Qt and C++17 +nheko - Desktop client for Matrix using Qt and C++20 == SYNOPSIS From d9aa04778a67f4cf478dfac230642b47417a4113 Mon Sep 17 00:00:00 2001 From: Sofia/Nep Date: Mon, 26 Jan 2026 16:39:20 -0300 Subject: [PATCH 21/41] Revert "Remove unnecessary references, not harmful in the absence of RVO but redundant due to move semantics" This reverts commit b0d09926a12757d3b3066a6be62975bbd5a9f5aa. Misguided original change. --- src/MemberList.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MemberList.cpp b/src/MemberList.cpp index fea027a3..bc493165 100644 --- a/src/MemberList.cpp +++ b/src/MemberList.cpp @@ -179,12 +179,12 @@ MemberList::lessThan(const QModelIndex &source_left, const QModelIndex &source_r return QSortFilterProxyModel::lessThan(source_left, source_right); } - const QString left = + const QString &left = this->m_model.data(source_left, MemberListBackend::Roles::Mxid).toString(); - const QString right = + const QString &right = this->m_model.data(source_right, MemberListBackend::Roles::Mxid).toString(); - const std::string room_id = this->roomId().toStdString(); + const std::string &room_id = this->roomId().toStdString(); if (cache::isV12Creator(room_id, left.toStdString())) { if (!cache::isV12Creator(room_id, right.toStdString())) { return false; From 3d9e14e001df58d77bad22076a0c3fc5f3338f5d Mon Sep 17 00:00:00 2001 From: Integral Date: Sun, 1 Feb 2026 21:17:16 +0800 Subject: [PATCH 22/41] Change "Show" to "Hide" in tray icon menu when the window is visible This change updates the tray icon menu action to reflect the current state of the window. Closes: #2000 --- src/TrayIcon.cpp | 14 +++++++++++--- src/TrayIcon.h | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/TrayIcon.cpp b/src/TrayIcon.cpp index 5fe6b4dc..0493e620 100644 --- a/src/TrayIcon.cpp +++ b/src/TrayIcon.cpp @@ -107,13 +107,21 @@ TrayIcon::TrayIcon(const QString &filename, QWindow *parent) QMenu *menu = new QMenu(); setContextMenu(menu); - viewAction_ = new QAction(tr("Show"), this); + toggleAction_ = new QAction(tr("Show"), this); quitAction_ = new QAction(tr("Quit"), this); - connect(viewAction_, &QAction::triggered, parent, &QWindow::show); + connect(toggleAction_, &QAction::triggered, parent, [=, this](){ + if (parent->isVisible()) { + parent->hide(); + toggleAction_->setText(tr("Show")); + } else { + parent->show(); + toggleAction_->setText(tr("Hide")); + } + }); connect(quitAction_, &QAction::triggered, this, QApplication::quit); - menu->addAction(viewAction_); + menu->addAction(toggleAction_); menu->addAction(quitAction_); QString toolTip = QLatin1String("nheko"); diff --git a/src/TrayIcon.h b/src/TrayIcon.h index 7c0bc7b2..5ed0dad1 100644 --- a/src/TrayIcon.h +++ b/src/TrayIcon.h @@ -40,7 +40,7 @@ public slots: void setUnreadCount(int count); private: - QAction *viewAction_; + QAction *toggleAction_; QAction *quitAction_; int previousCount = 0; From 3a707a5ee5444c08522ba1a4de694d9937536153 Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Sun, 8 Feb 2026 13:31:13 -0500 Subject: [PATCH 23/41] Merge pull request #1988 from gamesguru/fix/settings-comboBox-no-autocapture-scroll prevent comboBox & spinner from capturing mouse scroll unless active --- .gitlab-ci.yml | 4 ++-- CMakeLists.txt | 8 ++++++-- resources/qml/dialogs/RoomSettingsDialog.qml | 7 +++++-- resources/qml/pages/UserSettingsPage.qml | 12 +++++++----- third_party/olm-patches/0002-fix-cmake-cmp0148.patch | 11 +++++++++++ 5 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 third_party/olm-patches/0002-fix-cmake-cmp0148.patch diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6f85cfba..724ee5d4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -450,8 +450,8 @@ linting: before_script: - apk update && apk add make git python3 py3-pip qt6-qtdeclarative-dev # clang18 seems to mess with the emit keyword when using the `->` operator - - apk add clang17-extra-tools --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main - - export PATH="$PATH:/usr/lib/llvm17/bin/:/root/.local/bin" + - apk add clang-extra-tools --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main + - export PATH="$PATH:/usr/lib/llvm/bin/:/root/.local/bin" - pip3 install --break-system-packages --user reuse script: - make lint diff --git a/CMakeLists.txt b/CMakeLists.txt index 3fdae58f..eeb0cba1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -246,6 +246,7 @@ endif() # # Discover Qt dependencies. # + find_package(Qt6 6.5 COMPONENTS Core Widgets Gui LinguistTools Svg Multimedia Qml QuickControls2 REQUIRED) if (Qt6Qml_VERSION VERSION_GREATER_EQUAL "6.10.0") find_package(Qt6 REQUIRED COMPONENTS GuiPrivate QmlPrivate) @@ -298,7 +299,8 @@ if(NOT MSVC) -fsized-deallocation \ -fdiagnostics-color=always \ -Wunreachable-code \ - -Wno-attributes" + -Wno-attributes \ + -Wno-error=unused-parameter" ) if(NOT CMAKE_COMPILER_IS_GNUCXX) # -Wshadow is buggy and broken in GCC, so do not enable it. @@ -542,7 +544,9 @@ if(USE_BUNDLED_OLM) Olm GIT_REPOSITORY https://gitlab.matrix.org/matrix-org/olm.git GIT_TAG 3.2.16 - PATCH_COMMAND git apply ${CMAKE_CURRENT_SOURCE_DIR}/third_party/olm-patches/0001-fix-list-const-ptr.patch + PATCH_COMMAND git apply + ${CMAKE_CURRENT_SOURCE_DIR}/third_party/olm-patches/0001-fix-list-const-ptr.patch + ${CMAKE_CURRENT_SOURCE_DIR}/third_party/olm-patches/0002-fix-cmake-cmp0148.patch UPDATE_DISCONNECTED 1 ) set(OLM_TESTS OFF CACHE INTERNAL "") diff --git a/resources/qml/dialogs/RoomSettingsDialog.qml b/resources/qml/dialogs/RoomSettingsDialog.qml index 696cd9c9..ab24bbf2 100644 --- a/resources/qml/dialogs/RoomSettingsDialog.qml +++ b/resources/qml/dialogs/RoomSettingsDialog.qml @@ -304,13 +304,16 @@ ApplicationWindow { } ComboBox { + id: notificationsCombo + Layout.fillWidth: true model: [qsTr("Muted"), qsTr("Mentions only"), qsTr("All messages")] currentIndex: roomSettings.notifications onActivated: (index) => { roomSettings.changeNotifications(index); } - Layout.fillWidth: true - WheelHandler{} // suppress scrolling changing values + + // Disable built-in wheel handling unless focused + wheelEnabled: activeFocus } Label { diff --git a/resources/qml/pages/UserSettingsPage.qml b/resources/qml/pages/UserSettingsPage.qml index 1ce32725..8f51e668 100644 --- a/resources/qml/pages/UserSettingsPage.qml +++ b/resources/qml/pages/UserSettingsPage.qml @@ -100,10 +100,13 @@ Rectangle { model: r.model.values currentIndex: r.model.value width: Math.min(implicitWidth, scroll.availableWidth - Nheko.paddingMedium) - onActivated: r.model.value = currentIndex + onActivated: { + r.model.value = currentIndex + } implicitContentWidthPolicy: ComboBox.WidestTextWhenCompleted - WheelHandler{} // suppress scrolling changing values + // Disable built-in wheel handling unless focused + wheelEnabled: activeFocus } } DelegateChoice { @@ -118,7 +121,7 @@ Rectangle { onValueChanged: model.value = value editable: true - WheelHandler{} // suppress scrolling changing values + wheelEnabled: activeFocus } } DelegateChoice { @@ -153,7 +156,7 @@ Rectangle { return Number.fromLocaleString(locale, text) * spinbox.div } - WheelHandler{} // suppress scrolling changing values + wheelEnabled: activeFocus } } DelegateChoice { @@ -272,6 +275,5 @@ Rectangle { ToolTip.text: qsTr("Back") onClicked: mainWindow.pop() } - } diff --git a/third_party/olm-patches/0002-fix-cmake-cmp0148.patch b/third_party/olm-patches/0002-fix-cmake-cmp0148.patch new file mode 100644 index 00000000..37518b99 --- /dev/null +++ b/third_party/olm-patches/0002-fix-cmake-cmp0148.patch @@ -0,0 +1,11 @@ +--- CMakeLists.txt.orig 2026-01-25 20:50:49.905592647 -0500 ++++ CMakeLists.txt 2026-01-25 20:50:49.908925942 -0500 +@@ -1,4 +1,7 @@ +-cmake_minimum_required(VERSION 3.4) ++cmake_minimum_required(VERSION 3.5) ++if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.27") ++ cmake_policy(SET CMP0148 OLD) ++endif() + + project(olm VERSION 3.2.16 LANGUAGES CXX C) + From c17734c7a0e69fe796053884a4104e74a7f59012 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 8 Feb 2026 19:35:46 +0100 Subject: [PATCH 24/41] Fix newer clang format versions --- .clang-format | 2 ++ src/TrayIcon.cpp | 4 ++-- src/timeline/TimelineModel.cpp | 1 - src/ui/EventExpiry.cpp | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.clang-format b/.clang-format index f26fc328..70bb3cae 100644 --- a/.clang-format +++ b/.clang-format @@ -13,6 +13,8 @@ KeepEmptyLinesAtTheStartOfBlocks: false PointerAlignment: Right Cpp11BracedListStyle: true PenaltyReturnTypeOnItsOwnLine: 0 +StatementAttributeLikeMacros: + - emit --- BasedOnStyle: WebKit Language: ObjC diff --git a/src/TrayIcon.cpp b/src/TrayIcon.cpp index 0493e620..1c2f7fb5 100644 --- a/src/TrayIcon.cpp +++ b/src/TrayIcon.cpp @@ -108,9 +108,9 @@ TrayIcon::TrayIcon(const QString &filename, QWindow *parent) setContextMenu(menu); toggleAction_ = new QAction(tr("Show"), this); - quitAction_ = new QAction(tr("Quit"), this); + quitAction_ = new QAction(tr("Quit"), this); - connect(toggleAction_, &QAction::triggered, parent, [=, this](){ + connect(toggleAction_, &QAction::triggered, parent, [=, this]() { if (parent->isVisible()) { parent->hide(); toggleAction_->setText(tr("Show")); diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index d5645ac4..03c1b725 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -1137,7 +1137,6 @@ TimelineModel::syncState(const mtx::responses::State &s) avatarChanged = true; nameChanged = true; memberCountChanged = true; - } else if (std::holds_alternative>(e)) { this->isEncrypted_ = cache::isRoomEncrypted(room_id_.toStdString()); emit encryptionChanged(); diff --git a/src/ui/EventExpiry.cpp b/src/ui/EventExpiry.cpp index 8065c397..610cdd5c 100644 --- a/src/ui/EventExpiry.cpp +++ b/src/ui/EventExpiry.cpp @@ -28,7 +28,7 @@ EventExpiry::load() if (auto temp = cache::client()->getAccountData(mtx::events::EventType::NhekoEventExpiry, roomid_.toStdString())) { auto h = std::get>(*temp); + mtx::events::account_data::nheko_extensions::EventExpiry>>(*temp); this->event = std::move(h.content); } } From ee5fbe292778890a5e4ce7d971dbff07abaa19d2 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 8 Feb 2026 22:50:41 +0100 Subject: [PATCH 25/41] Bump hunter version --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index eeb0cba1..ea77b957 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,8 +25,8 @@ set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "macos deployment target") option(HUNTER_ENABLED "Enable Hunter package manager" OFF) include("cmake/HunterGate.cmake") HunterGate( - URL "https://github.com/cpp-pm/hunter/archive/v0.26.1.tar.gz" - SHA1 "e41ac7a18c49b35ebac99ff2b5244317b2638a65" + URL "https://github.com/cpp-pm/hunter/archive/v0.26.6.tar.gz" + SHA1 "e70c29f878f5d5f5cdf1b9ccd628fb872e8624a8" LOCAL ) From 597aa36f282026eee2b251d1e57f14b200aa865c Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 8 Feb 2026 23:07:01 +0100 Subject: [PATCH 26/41] Work around hunter libevent using older cmake version --- .ci/windows/build.bat | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci/windows/build.bat b/.ci/windows/build.bat index 70d72283..332ed4f9 100644 --- a/.ci/windows/build.bat +++ b/.ci/windows/build.bat @@ -15,6 +15,7 @@ echo %DATE% call "C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Auxiliary/Build/vcvarsall.bat" x64 +set CMAKE_POLICY_VERSION_MINIMUM=3.5 cmake -G "Visual Studio 17 2022" -A x64 -S. -Bbuild -DHUNTER_ROOT="C:\hunter" -DHUNTER_ENABLED=ON -DBUILD_SHARED_LIBS=OFF -DUSE_BUNDLED_OPENSSL=ON -DUSE_BUNDLED_KDSINGLEAPPLICATION=ON -DKDSingleApplication_STATIC=ON -DCMAKE_BUILD_TYPE=Release -DHUNTER_CONFIGURATION_TYPES=Release cmake --build build --config Release -j %NUMBER_OF_PROCESSORS% From 89e06f32ddb58e1c6385a54f4ae4fba1d1247026 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Wed, 11 Feb 2026 23:01:01 +0100 Subject: [PATCH 27/41] Allow hiding ACL events fixes #2006 --- resources/qml/dialogs/HiddenEventsDialog.qml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/resources/qml/dialogs/HiddenEventsDialog.qml b/resources/qml/dialogs/HiddenEventsDialog.qml index a66a78f1..44e90a0c 100644 --- a/resources/qml/dialogs/HiddenEventsDialog.qml +++ b/resources/qml/dialogs/HiddenEventsDialog.qml @@ -112,6 +112,17 @@ ApplicationWindow { checked: !hiddenEvents.hiddenEvents.includes(MtxEvent.Sticker) onToggled: hiddenEvents.toggle(MtxEvent.Sticker) } + + MatrixText { + text: qsTr("Allowed server changes") + Layout.fillWidth: true + } + + ToggleButton { + Layout.alignment: Qt.AlignRight + checked: !hiddenEvents.hiddenEvents.includes(MtxEvent.ServerAcl) + onToggled: hiddenEvents.toggle(MtxEvent.ServerAcl) + } } } From b5ce330c825b0ff7ddeeabfba57bee664bba88f6 Mon Sep 17 00:00:00 2001 From: Integral Date: Mon, 9 Feb 2026 17:44:51 +0800 Subject: [PATCH 28/41] Update `toggleAction_` text when parent window visibility changes Currently, the `toggleAction_` text is only updated when the parent window visibility changes via the tray icon menu. If the visibility changes through other means, the action text becomes out of sync. Connect parent window visibility changes to `toggleAction_` to keep the text in sync. --- src/TrayIcon.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/TrayIcon.cpp b/src/TrayIcon.cpp index 1c2f7fb5..4f38c63d 100644 --- a/src/TrayIcon.cpp +++ b/src/TrayIcon.cpp @@ -110,14 +110,11 @@ TrayIcon::TrayIcon(const QString &filename, QWindow *parent) toggleAction_ = new QAction(tr("Show"), this); quitAction_ = new QAction(tr("Quit"), this); - connect(toggleAction_, &QAction::triggered, parent, [=, this]() { - if (parent->isVisible()) { - parent->hide(); - toggleAction_->setText(tr("Show")); - } else { - parent->show(); - toggleAction_->setText(tr("Hide")); - } + connect(parent, &QWindow::visibleChanged, toggleAction_, [=, this] { + toggleAction_->setText(tr(parent->isVisible() ? "Hide" : "Show")); + }); + connect(toggleAction_, &QAction::triggered, parent, [=] { + parent->isVisible() ? parent->hide() : parent->show(); }); connect(quitAction_, &QAction::triggered, this, QApplication::quit); From ee62c9990dd8b041d424997ad34cc36fbc077c02 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Tue, 17 Feb 2026 01:18:21 +0100 Subject: [PATCH 29/41] Fix mark event as read command --- resources/qml/MessageView.qml | 2 ++ src/timeline/TimelineModel.h | 1 + 2 files changed, 3 insertions(+) diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml index 0c71573a..c5377650 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml @@ -556,6 +556,8 @@ Item { Component { MenuItem { text: qsTr("&Mark as read") + + onTriggered: room.markEventAsRead(messageContextMenuC.eventId) } } Component { diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index ad9f574e..db7cee53 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -332,6 +332,7 @@ public: Q_INVOKABLE void openUserProfile(QString userid); Q_INVOKABLE void unpin(const QString &id); Q_INVOKABLE void pin(const QString &id); + Q_INVOKABLE void markEventAsRead(const QString &id) { this->readEvent(id.toStdString()); } Q_INVOKABLE void showReadReceipts(const QString &id); Q_INVOKABLE void redactEvent(const QString &id, const QString &reason = ""); Q_INVOKABLE void redactAllFromUser(const QString &userid, const QString &reason = ""); From e3bc05884565613c3b0ef3425d966fada8446cc9 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Fri, 20 Feb 2026 01:32:37 +0100 Subject: [PATCH 30/41] Refactor v12 support to use new user_level helper from mtxclient --- CMakeLists.txt | 2 +- im.nheko.Nheko.yaml | 4 +- resources/qml/TimelineSectionHeader.qml | 3 +- .../qml/components/PowerlevelIndicator.qml | 11 +-- resources/qml/dialogs/PowerLevelEditor.qml | 26 ++++-- resources/qml/dialogs/RoomMembers.qml | 1 - src/Cache.cpp | 89 ++++--------------- src/Cache.h | 4 - src/Cache_p.h | 4 - src/MemberList.cpp | 29 +----- src/MemberList.h | 2 +- src/PowerlevelsEditModels.cpp | 78 +++++++++++----- src/PowerlevelsEditModels.h | 30 +++++-- src/Utils.cpp | 47 ++++++++-- src/timeline/Permissions.cpp | 39 ++++---- src/timeline/Permissions.h | 10 ++- src/timeline/TimelineModel.cpp | 35 ++++---- src/timeline/TimelineModel.h | 1 - 18 files changed, 208 insertions(+), 207 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b76c488..0976231b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -622,7 +622,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG d6f10427d1c5e5b1a45f426274f8d2e8dd0b64be + GIT_TAG 873911e352a0845dfb178f77b1ddea796a5d3455 ) set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "") set(BUILD_LIB_TESTS OFF CACHE INTERNAL "") diff --git a/im.nheko.Nheko.yaml b/im.nheko.Nheko.yaml index b7bc6dee..76f15bb9 100644 --- a/im.nheko.Nheko.yaml +++ b/im.nheko.Nheko.yaml @@ -213,8 +213,8 @@ modules: - -DBUILD_SHARED_LIBS=OFF buildsystem: cmake-ninja sources: - - commit: 15b43844f4ec27faa5f2ec92c4ded313206763aa - tag: v0.10.1 + - commit: 873911e352a0845dfb178f77b1ddea796a5d3455 + #tag: v0.10.1 type: git url: https://github.com/Nheko-Reborn/mtxclient.git - name: nheko diff --git a/resources/qml/TimelineSectionHeader.qml b/resources/qml/TimelineSectionHeader.qml index f504c463..2a95807c 100644 --- a/resources/qml/TimelineSectionHeader.qml +++ b/resources/qml/TimelineSectionHeader.qml @@ -89,7 +89,6 @@ Column { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - isV12Creator: room.isV12Creator(userId) powerlevel: userPowerlevel height: fontMetrics.ascent width: height @@ -98,7 +97,7 @@ Column { sourceSize.height: height permissions: room ? room.permissions : null - visible: isAdmin || isModerator || isV12Creator + visible: isAdmin || isModerator // implicitly includes creators as well } ToolTip.delay: Nheko.tooltipDelay diff --git a/resources/qml/components/PowerlevelIndicator.qml b/resources/qml/components/PowerlevelIndicator.qml index 07453828..00942aec 100644 --- a/resources/qml/components/PowerlevelIndicator.qml +++ b/resources/qml/components/PowerlevelIndicator.qml @@ -7,10 +7,10 @@ import QtQuick.Controls import im.nheko Image { - required property int powerlevel + required property var powerlevel required property var permissions - required property bool isV12Creator + readonly property bool isV12Creator: permissions ? permissions.creatorLevel() == powerlevel : false readonly property bool isAdmin: permissions ? permissions.changeLevel(MtxEvent.PowerLevels) <= powerlevel : false readonly property bool isModerator: permissions ? permissions.redactLevel() <= powerlevel : false readonly property bool isDefault: permissions ? permissions.defaultLevel() <= powerlevel : false @@ -27,14 +27,15 @@ Image { source: sourceUrl + (ma.hovered ? palette.highlight : palette.buttonText) ToolTip.visible: ma.hovered ToolTip.text: { + let pl = powerlevel.toLocaleString(Qt.locale(), "f", 0); if (isV12Creator) return qsTr("Creator"); else if (isAdmin) - return qsTr("Administrator: %1").arg(powerlevel); + return qsTr("Administrator (%1)").arg(pl) else if (isModerator) - return qsTr("Moderator: %1").arg(powerlevel); + return qsTr("Moderator: %1").arg(pl); else - return qsTr("User: %1").arg(powerlevel); + return qsTr("User: %1").arg(pl); } HoverHandler { diff --git a/resources/qml/dialogs/PowerLevelEditor.qml b/resources/qml/dialogs/PowerLevelEditor.qml index 17b19c25..694a259e 100644 --- a/resources/qml/dialogs/PowerLevelEditor.qml +++ b/resources/qml/dialogs/PowerLevelEditor.qml @@ -94,14 +94,17 @@ ApplicationWindow { Text { visible: !model.isType; text: { + let pl = model.powerlevel.toLocaleString(Qt.locale(), "f", 0); + if (editingModel.creatorLevel == model.powerlevel) + return qsTr("Creator") if (editingModel.adminLevel == model.powerlevel) - return qsTr("Administrator (%1)").arg(model.powerlevel) + return qsTr("Administrator (%1)").arg(pl) else if (editingModel.moderatorLevel == model.powerlevel) - return qsTr("Moderator (%1)").arg(model.powerlevel) + return qsTr("Moderator (%1)").arg(pl) else if (editingModel.defaultUserLevel == model.powerlevel) - return qsTr("User (%1)").arg(model.powerlevel) + return qsTr("User (%1)").arg(pl) else - return qsTr("Custom (%1)").arg(model.powerlevel) + return qsTr("Custom (%1)").arg(pl) } color: palette.text } @@ -138,7 +141,7 @@ ApplicationWindow { color: palette.text - Keys.onPressed: { + Keys.onPressed: event => { if (typeEntry.text.includes('.') && event.matches(StandardKey.InsertParagraphSeparator)) { editingModel.types.add(typeEntry.index, typeEntry.text) typeEntry.visible = false; @@ -334,12 +337,17 @@ ApplicationWindow { Text { visible: !model.isUser; text: { + let pl = model.powerlevel.toLocaleString(Qt.locale(), "f", 0); + if (editingModel.creatorLevel == model.powerlevel) + return qsTr("Creator") if (editingModel.adminLevel == model.powerlevel) - return qsTr("Administrator (%1)").arg(model.powerlevel) + return qsTr("Administrator (%1)").arg(pl) else if (editingModel.moderatorLevel == model.powerlevel) - return qsTr("Moderator (%1)").arg(model.powerlevel) + return qsTr("Moderator (%1)").arg(pl) + else if (editingModel.defaultUserLevel == model.powerlevel) + return qsTr("User (%1)").arg(pl) else - return qsTr("Custom (%1)").arg(model.powerlevel) + return qsTr("Custom (%1)").arg(pl) } color: palette.text } @@ -349,7 +357,7 @@ ApplicationWindow { Layout.alignment: Qt.AlignRight Layout.rightMargin: 2 image: model.isUser ? ":/icons/icons/ui/dismiss.svg" : ":/icons/icons/ui/add-square-button.svg" - visible: !model.isUser || model.removeable + visible: (!model.isUser || model.removeable) && model.powerlevel != editingModel.creatorLevel hoverEnabled: true ToolTip.visible: hovered ToolTip.text: model.isUser ? qsTr("Remove user") : qsTr("Add user") diff --git a/resources/qml/dialogs/RoomMembers.qml b/resources/qml/dialogs/RoomMembers.qml index f03fda96..95dc9fc3 100644 --- a/resources/qml/dialogs/RoomMembers.qml +++ b/resources/qml/dialogs/RoomMembers.qml @@ -168,7 +168,6 @@ ApplicationWindow { sourceSize.height: height powerlevel: model.powerlevel permissions: room.permissions - isV12Creator: room.isV12Creator(model.mxid) } EncryptionIndicator { diff --git a/src/Cache.cpp b/src/Cache.cpp index ca8644ba..ee981abc 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -4621,12 +4621,14 @@ Cache::updateSpaces(lmdb::txn &txn, event.state_key.at(0) == '!') { const std::string &space = event.state_key; + auto create = getStateEvent(txn, space) + .value_or(mtx::events::StateEvent{}); auto pls = getStateEvent(txn, space); if (!pls) continue; - if (pls->content.user_level(event.sender) >= + if (pls->content.user_level(event.sender, create) >= pls->content.state_level(space_event_type)) { db->spacesChildren.put(txn, space, room); db->spacesParents.put(txn, room, space); @@ -4635,7 +4637,7 @@ Cache::updateSpaces(lmdb::txn &txn, room, space, event.sender, - pls->content.user_level(event.sender), + pls->content.user_level(event.sender, create), pls->content.state_level(space_event_type)); } } @@ -4851,51 +4853,6 @@ Cache::getAccountData(lmdb::txn &txn, mtx::events::EventType type, const std::st return std::nullopt; } -bool -Cache::isV12Creator(const std::string &room_id, const std::string &user_id) -{ - auto txn = ro_txn(this->db->env_); - auto state = this->getStatesDb(txn, room_id); - - return this->isV12Creator(txn, state, user_id); -} - -bool -Cache::isV12Creator(lmdb::txn &txn, lmdb::dbi &state, const std::string &user_id) -{ - using namespace mtx::events; - using namespace mtx::events::state; - - bool ok; - const int room_version = this->getRoomVersion(txn, state).toInt(&ok); - if (!ok || room_version < 12) { - return false; - } - - std::string_view create_event; - if (state.get(txn, to_string(EventType::RoomCreate), create_event)) { - try { - const StateEvent evt = - nlohmann::json::parse(create_event).get>(); - if (evt.sender == user_id) { - return true; - } - - const std::optional> &additional_creators = - evt.content.additional_creators; - if (additional_creators && - std::find(additional_creators->begin(), additional_creators->end(), user_id) != - additional_creators->end()) { - return true; - } - } catch (...) { - return false; - } - } - - return false; -} - bool Cache::hasEnoughPowerLevel(const std::vector &eventTypes, const std::string &room_id, @@ -4908,30 +4865,22 @@ Cache::hasEnoughPowerLevel(const std::vector &eventTypes try { auto db_ = getStatesDb(txn, room_id); - if (this->isV12Creator(txn, db_, user_id)) { - return true; - } - int64_t min_event_level = std::numeric_limits::max(); int64_t user_level = std::numeric_limits::min(); - std::string_view event; - bool res = db_.get(txn, to_string(EventType::RoomPowerLevels), event); + try { + StateEvent create = getStateEvent(txn, room_id) + .value_or(StateEvent{}); + StateEvent pls = + getStateEvent(txn, room_id) + .value_or(StateEvent{}); - if (res) { - try { - StateEvent msg = - nlohmann::json::parse(std::string_view(event.data(), event.size())) - .get>(); + user_level = pls.content.user_level(user_id, create); - user_level = msg.content.user_level(user_id); - - for (const auto &ty : eventTypes) - min_event_level = - std::min(min_event_level, msg.content.state_level(to_string(ty))); - } catch (const nlohmann::json::exception &e) { - nhlog::db()->warn("failed to parse m.room.power_levels event: {}", e.what()); - } + for (const auto &ty : eventTypes) + min_event_level = std::min(min_event_level, pls.content.state_level(to_string(ty))); + } catch (const nlohmann::json::exception &e) { + nhlog::db()->warn("failed to parse m.room.power_levels event: {}", e.what()); } return user_level >= min_event_level; @@ -6178,13 +6127,6 @@ roomMembers(const std::string &room_id) return instance_->roomMembers(room_id); } -//! Check if the given user is a room creator and that gives them an infinite PL. -bool -isV12Creator(const std::string &room_id, const std::string &user_id) -{ - return instance_->isV12Creator(room_id, user_id); -} - //! Check if the given user has power level greater than //! lowest power level of the given events. bool @@ -6466,6 +6408,7 @@ NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::JoinRules) NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::Name) NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::PinnedEvents) NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::PowerLevels) +NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::Create) NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::ServerAcl) NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::space::Child) NHEKO_CACHE_GET_STATE_EVENT_DEFINITION(mtx::events::state::space::Parent) diff --git a/src/Cache.h b/src/Cache.h index c6118c78..7c79881c 100644 --- a/src/Cache.h +++ b/src/Cache.h @@ -123,10 +123,6 @@ runMigrations(); std::vector roomMembers(const std::string &room_id); -//! Check if the given user is a room creator and that gives them an infinite PL. -bool -isV12Creator(const std::string &room_id, const std::string &user_id); - //! Check if the given user has power level greater than than //! lowest power level of the given events. bool diff --git a/src/Cache_p.h b/src/Cache_p.h index ee7913d5..89fdd843 100644 --- a/src/Cache_p.h +++ b/src/Cache_p.h @@ -145,10 +145,6 @@ public: //! Retrieve all the user ids from a room. std::vector roomMembers(const std::string &room_id); - //! Check if the given user is a room creator and that gives them an infinite PL. - bool isV12Creator(const std::string &room_id, const std::string &user_id); - bool isV12Creator(lmdb::txn &txn, lmdb::dbi &state, const std::string &user_id); - //! Check if the given user has power leve greater than than //! lowest power level of the given events. bool hasEnoughPowerLevel(const std::vector &eventTypes, diff --git a/src/MemberList.cpp b/src/MemberList.cpp index bc493165..10487974 100644 --- a/src/MemberList.cpp +++ b/src/MemberList.cpp @@ -19,6 +19,9 @@ MemberListBackend::MemberListBackend(const QString &room_id, QObject *parent) ->getStateEvent(room_id_.toStdString()) .value_or(mtx::events::StateEvent{}) .content} + , create_{cache::client() + ->getStateEvent(room_id_.toStdString()) + .value_or(mtx::events::StateEvent{})} { try { info_ = cache::singleRoomInfo(room_id_.toStdString()); @@ -92,7 +95,7 @@ MemberListBackend::data(const QModelIndex &index, int role) const } case Powerlevel: return static_cast( - powerLevels_.user_level(m_memberList[index.row()].first.user_id.toStdString())); + powerLevels_.user_level(m_memberList[index.row()].first.user_id.toStdString(), create_)); default: return {}; } @@ -172,28 +175,4 @@ MemberList::filterAcceptsRow(int source_row, const QModelIndex &) const Qt::CaseInsensitive); } -bool -MemberList::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const -{ - if (this->sortRole() != MemberSortRoles::Powerlevel) { - return QSortFilterProxyModel::lessThan(source_left, source_right); - } - - const QString &left = - this->m_model.data(source_left, MemberListBackend::Roles::Mxid).toString(); - const QString &right = - this->m_model.data(source_right, MemberListBackend::Roles::Mxid).toString(); - - const std::string &room_id = this->roomId().toStdString(); - if (cache::isV12Creator(room_id, left.toStdString())) { - if (!cache::isV12Creator(room_id, right.toStdString())) { - return false; - } - // If both are creators, sort by mxid. - return left < right; - } - - return QSortFilterProxyModel::lessThan(source_left, source_right); -} - #include "moc_MemberList.cpp" diff --git a/src/MemberList.h b/src/MemberList.h index 46d73b53..0ceaad5e 100644 --- a/src/MemberList.h +++ b/src/MemberList.h @@ -73,6 +73,7 @@ private: bool loadingMoreMembers_{false}; mtx::events::state::PowerLevels powerLevels_; + mtx::events::StateEvent create_; friend class MemberList; }; @@ -122,7 +123,6 @@ public slots: protected: bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; - bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; private: QString filterString; diff --git a/src/PowerlevelsEditModels.cpp b/src/PowerlevelsEditModels.cpp index 2ecbdd53..cf8a6944 100644 --- a/src/PowerlevelsEditModels.cpp +++ b/src/PowerlevelsEditModels.cpp @@ -17,12 +17,15 @@ #include "Logging.h" #include "MatrixClient.h" -PowerlevelsTypeListModel::PowerlevelsTypeListModel(const std::string &rid, - const mtx::events::state::PowerLevels &pl, - QObject *parent) +PowerlevelsTypeListModel::PowerlevelsTypeListModel( + const std::string &rid, + const mtx::events::state::PowerLevels &pl, + const mtx::events::StateEvent &create, + QObject *parent) : QAbstractListModel(parent) , room_id(rid) , powerLevels_(pl) + , create_(create) { std::set seen_levels; for (const auto &[type, level] : powerLevels_.events) { @@ -40,6 +43,9 @@ PowerlevelsTypeListModel::PowerlevelsTypeListModel(const std::string &rid, seen_levels.insert(level); } } + if (create_.content.room_version_creators_with_infinite_power()) { + seen_levels.insert(mtx::events::state::Creator); + } for (const auto &level : { powerLevels_.events_default, @@ -354,12 +360,15 @@ PowerlevelsTypeListModel::moveRows(const QModelIndex &, return true; } -PowerlevelsUserListModel::PowerlevelsUserListModel(const std::string &rid, - const mtx::events::state::PowerLevels &pl, - QObject *parent) +PowerlevelsUserListModel::PowerlevelsUserListModel( + const std::string &rid, + const mtx::events::state::PowerLevels &pl, + const mtx::events::StateEvent &create, + QObject *parent) : QAbstractListModel(parent) , room_id(rid) , powerLevels_(pl) + , create_(create) { std::set seen_levels; for (const auto &[user, level] : powerLevels_.users) { @@ -378,6 +387,16 @@ PowerlevelsUserListModel::PowerlevelsUserListModel(const std::string &rid, } } + if (create_.content.room_version_creators_with_infinite_power()) { + users.push_back(Entry{"", mtx::events::state::Creator}); + seen_levels.insert(mtx::events::state::Creator); + + users.push_back(Entry{create_.sender, mtx::events::state::Creator}); + for (const auto &user : create.content.additional_creators) { + users.push_back(Entry{user, mtx::events::state::Creator}); + } + } + for (const auto &level : { powerLevels_.events_default, powerLevels_.state_default, @@ -408,7 +427,7 @@ PowerlevelsUserListModel::toUsers() const { std::map> m; for (const auto &[key, pl] : std::as_const(users)) - if (key.size() > 0 && key.at(0) == '@') + if (key.size() > 0 && key.at(0) == '@' && pl != mtx::events::state::Creator) m[key] = pl; return m; } @@ -459,7 +478,7 @@ PowerlevelsUserListModel::data(const QModelIndex &index, int role) const case IsUser: return !user.mxid.empty(); case Moveable: - return !user.mxid.empty(); + return !user.mxid.empty() && user.pl != mtx::events::state::Creator; case Removeable: return !user.mxid.empty() && user.mxid.find('.') != std::string::npos; } @@ -554,7 +573,15 @@ PowerlevelsUserListModel::moveRows(const QModelIndex &, if (users.at(sourceRow).mxid.empty()) return false; - auto pl = users.at(destinationChild > 0 ? destinationChild - 1 : 0).pl; + if (users.at(sourceRow).pl == mtx::events::state::Creator) + return false; + if (users.at(destinationChild).pl == mtx::events::state::Creator) + return false; + + auto pl = users.at(destinationChild > 0 ? destinationChild - 1 : 0).pl; + if (pl == mtx::events::state::Creator) + return false; + auto sourceItem = users.takeAt(sourceRow); sourceItem.pl = pl; @@ -577,9 +604,12 @@ PowerlevelEditingModels::PowerlevelEditingModels(QString room_id, QObject *paren ->getStateEvent(room_id.toStdString()) .value_or(mtx::events::StateEvent{}) .content) - , types_(room_id.toStdString(), powerLevels_, this) - , users_(room_id.toStdString(), powerLevels_, this) - , spaces_(room_id.toStdString(), powerLevels_, this) + , create_(cache::client() + ->getStateEvent(room_id.toStdString()) + .value_or(mtx::events::StateEvent{})) + , types_(room_id.toStdString(), powerLevels_, create_, this) + , users_(room_id.toStdString(), powerLevels_, create_, this) + , spaces_(room_id.toStdString(), powerLevels_, create_, this) , room_id_(room_id.toStdString()) { connect(&types_, @@ -678,16 +708,18 @@ samePl(const mtx::events::state::PowerLevels &a, const mtx::events::state::Power b.redact); } -PowerlevelsSpacesListModel::PowerlevelsSpacesListModel(const std::string &room_id_, - const mtx::events::state::PowerLevels &pl, - QObject *parent) +PowerlevelsSpacesListModel::PowerlevelsSpacesListModel( + const std::string &room_id_, + const mtx::events::state::PowerLevels &pl, + const mtx::events::StateEvent &create, + QObject *parent) : QAbstractListModel(parent) , room_id(std::move(room_id_)) , oldPowerLevels_(std::move(pl)) { beginResetModel(); - spaces.push_back(Entry{room_id, oldPowerLevels_, true}); + spaces.push_back(Entry{room_id, oldPowerLevels_, create, true}); std::unordered_set visited; @@ -703,10 +735,16 @@ PowerlevelsSpacesListModel::PowerlevelsSpacesListModel(const std::string &room_i cache::client()->getStateEvent(s, space); if (parent && parent->content.via && !parent->content.via->empty() && parent->content.canonical) { - auto parentPl = cache::client()->getStateEvent(s); + auto childPl = cache::client()->getStateEvent(s); + auto childCreate = + cache::client()->getStateEvent(s).value_or( + mtx::events::StateEvent{}); - spaces.push_back(Entry{ - s, parentPl ? parentPl->content : mtx::events::state::PowerLevels{}, false}); + spaces.push_back( + Entry{s, + childPl ? childPl->content : mtx::events::state::PowerLevels{}, + childCreate, + false}); addChildren(s); } } @@ -813,7 +851,7 @@ PowerlevelsSpacesListModel::data(QModelIndex const &index, int role) const auto entry = spaces.at(row); switch (role) { case Roles::IsEditable: - return entry.pl.user_level(http::client()->user_id().to_string()) >= + return entry.pl.user_level(http::client()->user_id().to_string(), entry.create) >= entry.pl.state_level(to_string(mtx::events::EventType::RoomPowerLevels)); case Roles::IsDifferentFromBase: return !samePl(entry.pl, oldPowerLevels_); diff --git a/src/PowerlevelsEditModels.h b/src/PowerlevelsEditModels.h index 1fe075b7..edb3a821 100644 --- a/src/PowerlevelsEditModels.h +++ b/src/PowerlevelsEditModels.h @@ -29,9 +29,11 @@ public: Removeable, }; - explicit PowerlevelsTypeListModel(const std::string &room_id_, - const mtx::events::state::PowerLevels &pl, - QObject *parent = nullptr); + explicit PowerlevelsTypeListModel( + const std::string &room_id_, + const mtx::events::state::PowerLevels &pl, + const mtx::events::StateEvent &create, + QObject *parent = nullptr); QHash roleNames() const override; int rowCount(const QModelIndex &) const override { return static_cast(types.size()); } @@ -67,6 +69,7 @@ public: std::string room_id; QVector types; mtx::events::state::PowerLevels powerLevels_; + mtx::events::StateEvent create_; }; class PowerlevelsUserListModel final : public QAbstractListModel @@ -88,9 +91,11 @@ public: Removeable, }; - explicit PowerlevelsUserListModel(const std::string &room_id_, - const mtx::events::state::PowerLevels &pl, - QObject *parent = nullptr); + explicit PowerlevelsUserListModel( + const std::string &room_id_, + const mtx::events::state::PowerLevels &pl, + const mtx::events::StateEvent &create, + QObject *parent = nullptr); QHash roleNames() const override; int rowCount(const QModelIndex &) const override { return static_cast(users.size()); } @@ -121,6 +126,7 @@ public: std::string room_id; QVector users; mtx::events::state::PowerLevels powerLevels_; + mtx::events::StateEvent create_; }; class PowerlevelsSpacesListModel final : public QAbstractListModel @@ -147,9 +153,11 @@ public: ApplyPermissions, }; - explicit PowerlevelsSpacesListModel(const std::string &room_id_, - const mtx::events::state::PowerLevels &pl, - QObject *parent = nullptr); + explicit PowerlevelsSpacesListModel( + const std::string &room_id_, + const mtx::events::state::PowerLevels &pl, + const mtx::events::StateEvent &create, + QObject *parent = nullptr); QHash roleNames() const override; int rowCount(const QModelIndex &) const override { return static_cast(spaces.size()); } @@ -183,6 +191,7 @@ public: std::string roomid; mtx::events::state::PowerLevels pl; + mtx::events::StateEvent create; bool apply = false; }; @@ -203,6 +212,7 @@ class PowerlevelEditingModels final : public QObject Q_PROPERTY(PowerlevelsUserListModel *users READ users CONSTANT) Q_PROPERTY(PowerlevelsTypeListModel *types READ types CONSTANT) Q_PROPERTY(PowerlevelsSpacesListModel *spaces READ spaces CONSTANT) + Q_PROPERTY(qlonglong creatorLevel READ creatorLevel CONSTANT) Q_PROPERTY(qlonglong adminLevel READ adminLevel NOTIFY adminLevelChanged) Q_PROPERTY(qlonglong moderatorLevel READ moderatorLevel NOTIFY moderatorLevelChanged) Q_PROPERTY(qlonglong defaultUserLevel READ defaultUserLevel NOTIFY defaultUserLevelChanged) @@ -222,6 +232,7 @@ public: PowerlevelsUserListModel *users() { return &users_; } PowerlevelsTypeListModel *types() { return &types_; } PowerlevelsSpacesListModel *spaces() { return &spaces_; } + qlonglong creatorLevel() const { return mtx::events::state::Creator; } qlonglong adminLevel() const { return powerLevels_.state_level(to_string(mtx::events::EventType::RoomPowerLevels)); @@ -235,6 +246,7 @@ public: Q_INVOKABLE void addRole(int pl); mtx::events::state::PowerLevels powerLevels_; + mtx::events::StateEvent create_; PowerlevelsTypeListModel types_; PowerlevelsUserListModel users_; PowerlevelsSpacesListModel spaces_; diff --git a/src/Utils.cpp b/src/Utils.cpp index 2bec9d36..4556b680 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1453,6 +1453,9 @@ utils::roomVias(const std::string &roomid) auto powerlevels = cache::client()->getStateEvent(roomid).value_or( mtx::events::StateEvent{}); + auto create = + cache::client()->getStateEvent(roomid).value_or( + mtx::events::StateEvent{}); auto acls = cache::client()->getStateEvent(roomid); std::vector allowedServers; @@ -1501,6 +1504,19 @@ utils::roomVias(const std::string &roomid) std::set users_with_high_pl_in_room; // we should pick PL > 50, but imo that is broken, so we just pick users who have admins // perm + if (create.content.room_version_creators_with_infinite_power()) { + { + auto user = create.sender; + auto host = mtx::identifiers::parse(user).hostname(); + if (isHostAllowed(host)) + users_with_high_pl.insert(user); + } + for (const auto &user : create.content.additional_creators) { + auto host = mtx::identifiers::parse(user).hostname(); + if (isHostAllowed(host)) + users_with_high_pl.insert(user); + } + } for (const auto &user : powerlevels.content.users) { if (user.second >= powerlevels.content.events_default && user.second >= powerlevels.content.state_default) { @@ -1525,12 +1541,13 @@ utils::roomVias(const std::string &roomid) }); // add the highest powerlevel user - auto max_pl_user = std::max_element( - users_with_high_pl_in_room.begin(), - users_with_high_pl_in_room.end(), - [&pl_content = powerlevels.content](const std::string &a, const std::string &b) { - return pl_content.user_level(a) < pl_content.user_level(b); - }); + auto max_pl_user = std::max_element(users_with_high_pl_in_room.begin(), + users_with_high_pl_in_room.end(), + [&pl_content = powerlevels.content, &create]( + const std::string &a, const std::string &b) { + return pl_content.user_level(a, create) < + pl_content.user_level(b, create); + }); if (max_pl_user != users_with_high_pl_in_room.end()) { auto host = mtx::identifiers::parse(*max_pl_user).hostname(); @@ -1705,11 +1722,15 @@ utils::updateSpaceVias() auto spaceid = roomid.toStdString(); + auto create = cache::client()->getStateEvent(spaceid).value_or( + mtx::events::StateEvent{}); + if (auto pl = cache::client() ->getStateEvent(spaceid) .value_or(mtx::events::StateEvent{}) .content; - pl.user_level(us) < pl.state_level(to_string(mtx::events::EventType::SpaceChild))) + pl.user_level(us, create) < + pl.state_level(to_string(mtx::events::EventType::SpaceChild))) continue; auto children = cache::client()->getChildRoomIds(spaceid); @@ -1748,12 +1769,16 @@ utils::updateSpaceVias() parent->origin_server_ts < weekAgo && // ignore unset spaces (parent->content.via && !parent->content.via->empty())) { + auto childCreate = + cache::client()->getStateEvent(spaceid).value_or( + mtx::events::StateEvent{}); + if (auto pl = cache::client() ->getStateEvent(childid) .value_or(mtx::events::StateEvent{}) .content; - pl.user_level(us) < + pl.user_level(us, childCreate) < pl.state_level(to_string(mtx::events::EventType::SpaceParent))) continue; @@ -2041,11 +2066,15 @@ utils::removeExpiredEvents() if (!asus->globalExpiry && !getExpEv(roomid)) continue; + auto create = cache::client()->getStateEvent(roomid).value_or( + mtx::events::StateEvent{}); + if (auto pl = cache::client() ->getStateEvent(roomid) .value_or(mtx::events::StateEvent{}) .content; - pl.user_level(us) < pl.event_level(to_string(mtx::events::EventType::RoomRedaction))) { + pl.user_level(us, create) < + pl.event_level(to_string(mtx::events::EventType::RoomRedaction))) { nhlog::net()->warn("Can't react events in {}, not running expiration.", roomid); continue; } diff --git a/src/timeline/Permissions.cpp b/src/timeline/Permissions.cpp index 569bf2bc..5e8bd169 100644 --- a/src/timeline/Permissions.cpp +++ b/src/timeline/Permissions.cpp @@ -4,6 +4,8 @@ #include "Permissions.h" +#include + #include "Cache_p.h" #include "MatrixClient.h" #include "TimelineModel.h" @@ -22,50 +24,53 @@ Permissions::invalidate() ->getStateEvent(roomId_.toStdString()) .value_or(mtx::events::StateEvent{}) .content; + create = cache::client() + ->getStateEvent(roomId_.toStdString()) + .value_or(mtx::events::StateEvent{}); } bool Permissions::canInvite() { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.invite; - return plCheck || this->isV12Creator(); + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.invite; + return plCheck; } bool Permissions::canBan() { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.ban; - return plCheck || this->isV12Creator(); + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.ban; + return plCheck; } bool Permissions::canKick() { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.kick; - return plCheck || this->isV12Creator(); + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.kick; + return plCheck; } bool Permissions::canRedact() { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= pl.redact; - return plCheck || this->isV12Creator(); + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.redact; + return plCheck; } bool Permissions::canChange(int eventType) { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.state_level(to_string(qml_mtx_events::fromRoomEventType( static_cast(eventType)))); - return plCheck || this->isV12Creator(); + return plCheck; } bool Permissions::canSend(int eventType) { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.event_level(to_string(qml_mtx_events::fromRoomEventType( static_cast(eventType)))); - return plCheck || this->isV12Creator(); + return plCheck; } int @@ -94,15 +99,9 @@ Permissions::sendLevel(int eventType) bool Permissions::canPingRoom() { - const bool plCheck = pl.user_level(http::client()->user_id().to_string()) >= + const bool plCheck = pl.user_level(http::client()->user_id().to_string(), create) >= pl.notification_level(mtx::events::state::notification_keys::room); - return plCheck || this->isV12Creator(); + return plCheck; } -bool -Permissions::isV12Creator() -{ - return cache::client()->isV12Creator(this->roomId_.toStdString(), - http::client()->user_id().to_string()); -} #include "moc_Permissions.cpp" diff --git a/src/timeline/Permissions.h b/src/timeline/Permissions.h index ac36d5ea..9e2187a0 100644 --- a/src/timeline/Permissions.h +++ b/src/timeline/Permissions.h @@ -6,6 +6,8 @@ #include +#include +#include #include class TimelineModel; @@ -28,16 +30,20 @@ public: Q_INVOKABLE int redactLevel(); Q_INVOKABLE int changeLevel(int eventType); Q_INVOKABLE int sendLevel(int eventType); + Q_INVOKABLE qint64 creatorLevel() const { return mtx::events::state::Creator; } Q_INVOKABLE bool canPingRoom(); void invalidate(); const mtx::events::state::PowerLevels &powerlevelEvent() const { return pl; }; + const mtx::events::StateEvent &createEvent() const + { + return create; + }; private: - bool isV12Creator(); - QString roomId_; mtx::events::state::PowerLevels pl; + mtx::events::StateEvent create; }; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 7cb19c70..b00d7d8f 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -605,8 +605,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r case UserName: return QVariant(displayName(QString::fromStdString(acc::sender(event)))); case UserPowerlevel: { - return static_cast( - permissions_.powerlevelEvent().user_level(acc::sender(event))); + return static_cast(permissions_.powerlevelEvent().user_level( + acc::sender(event), permissions_.createEvent())); } case Day: { @@ -1413,12 +1413,6 @@ TimelineModel::readEvent(const std::string &id) !UserSettings::instance()->readReceipts()); } -bool -TimelineModel::isV12Creator(const QString &id) const -{ - return cache::isV12Creator(this->roomId().toStdString(), id.toStdString()); -} - QString TimelineModel::displayName(const QString &id) const { @@ -2427,6 +2421,7 @@ QString TimelineModel::formatPowerLevelEvent( const mtx::events::StateEvent &event) const { + const auto create = permissions_.createEvent(); mtx::events::StateEvent const *prevEvent = nullptr; if (!event.unsigned_data.replaces_state.empty()) { auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id); @@ -2446,15 +2441,15 @@ TimelineModel::formatPowerLevelEvent( if (!prevEvent) return tr("%1 has changed the room's permissions.").arg(sender_name); - auto calc_affected = [&event, - &prevEvent](int64_t newPowerlevelSetting) -> std::pair { + auto calc_affected = + [&event, &prevEvent, &create](int64_t newPowerlevelSetting) -> std::pair { QStringList affected{}; auto numberOfAffected = 0; // We do only compare to people with explicit PL. Usually others are not going to be // affected either way and this is cheaper to iterate over. for (auto const &[mxid, currentPowerlevel] : event.content.users) { if (currentPowerlevel == newPowerlevelSetting && - prevEvent->content.user_level(mxid) < newPowerlevelSetting) { + prevEvent->content.user_level(mxid, create) < newPowerlevelSetting) { numberOfAffected++; if (numberOfAffected <= 2) { affected.push_back(QString::fromStdString(mxid)); @@ -2631,24 +2626,25 @@ TimelineModel::formatPowerLevelEvent( // Compare if a Powerlevel of a user changed for (auto const &[mxid, powerlevel] : event.content.users) { auto nameOfChangedUser = utils::replaceEmoji(displayName(QString::fromStdString(mxid))); - if (prevEvent->content.user_level(mxid) != powerlevel) { + if (prevEvent->content.user_level(mxid, create) != powerlevel) { if (powerlevel >= administrator_power_level) { resultingMessage.append(tr("%1 has made %2 an administrator of this room.") .arg(sender_name, nameOfChangedUser)); } else if (powerlevel >= moderator_power_level && - powerlevel > prevEvent->content.user_level(mxid)) { + powerlevel > prevEvent->content.user_level(mxid, create)) { resultingMessage.append(tr("%1 has made %2 a moderator of this room.") .arg(sender_name, nameOfChangedUser)); } else if (powerlevel >= moderator_power_level && - powerlevel < prevEvent->content.user_level(mxid)) { + powerlevel < prevEvent->content.user_level(mxid, create)) { resultingMessage.append(tr("%1 has downgraded %2 to moderator of this room.") .arg(sender_name, nameOfChangedUser)); } else { - resultingMessage.append(tr("%1 has changed the powerlevel of %2 from %3 to %4.") - .arg(sender_name, - nameOfChangedUser, - QString::number(prevEvent->content.user_level(mxid)), - QString::number(powerlevel))); + resultingMessage.append( + tr("%1 has changed the powerlevel of %2 from %3 to %4.") + .arg(sender_name, + nameOfChangedUser, + QString::number(prevEvent->content.user_level(mxid, create)), + QString::number(powerlevel))); } } } @@ -3379,6 +3375,7 @@ TimelineModel::pushrulesRoomContext() const cache::displayName(room_id_.toStdString(), http::client()->user_id().to_string()), .member_count = cache::client()->memberCount(room_id_.toStdString()), .power_levels = permissions_.powerlevelEvent(), + .create = permissions_.createEvent(), }; } diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index bae4533f..db7cee53 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -302,7 +302,6 @@ public: static QString getBareRoomLink(const QString &); static QString getRoomVias(const QString &); - Q_INVOKABLE bool isV12Creator(const QString &id) const; Q_INVOKABLE QString displayName(const QString &id) const; Q_INVOKABLE QString avatarUrl(const QString &id) const; Q_INVOKABLE QString formatDateSeparator(QDate date) const; From 014d70fd64b2eb0e9d5c3c0b6cb0815673db8c71 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Fri, 20 Feb 2026 17:45:00 +0100 Subject: [PATCH 31/41] Fix failed send indicator not updating automatically --- src/timeline/EventStore.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp index 52e7fb77..bc086a14 100644 --- a/src/timeline/EventStore.cpp +++ b/src/timeline/EventStore.cpp @@ -166,6 +166,11 @@ EventStore::EventStore(std::string room_id, QObject *) nhlog::ui()->debug("failing txn id '{}'", txn_id); cache::client()->removePendingStatus(room_id_, txn_id); current_txn_error_count = 0; + + auto idx = idToIndex(txn_id); + + if (idx) + emit dataChanged(*idx, *idx); } } QTimer::singleShot(1000, this, [this]() { From 51da48c706e28283bfb7f7ed4e70b984975e8b76 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 21 Feb 2026 00:43:33 +0100 Subject: [PATCH 32/41] Fix reply popup rendering on newer qt --- resources/qml/delegates/Reply.qml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml index 77cad0f0..0bc2e4b9 100644 --- a/resources/qml/delegates/Reply.qml +++ b/resources/qml/delegates/Reply.qml @@ -64,16 +64,17 @@ AbstractButton { height: r.limitHeight ? Math.min( timelineEvent.main?.height, timelineView.height / 10) + Nheko.paddingSmall + usernameBtn.height : undefined - // FIXME: I have no idea, why this name doesn't render in the reply popup on Qt 6.9.2 AbstractButton { id: usernameBtn - visible: r.eventId - contentItem: Label { - visible: r.eventId id: userName_ - text: r.userName + // HACK: To ensure the username gets rendered in Qt 6.9.2, + // we need to always have some text in here. The name + // should never be empty, since it falls to the mxid, but + // if we have no text there, Qt culls the item, before we + // fill it... + text: r.userName || "." color: r.userColor textFormat: Text.RichText width: timelineEvent.main?.width From 58e23302d0f00a31353aa60dcee7be5b1624107c Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 21 Feb 2026 01:46:49 +0100 Subject: [PATCH 33/41] Register permissions as a qml type --- resources/qml/components/PowerlevelIndicator.qml | 2 +- src/timeline/Permissions.h | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/qml/components/PowerlevelIndicator.qml b/resources/qml/components/PowerlevelIndicator.qml index 00942aec..01f3bc39 100644 --- a/resources/qml/components/PowerlevelIndicator.qml +++ b/resources/qml/components/PowerlevelIndicator.qml @@ -8,7 +8,7 @@ import im.nheko Image { required property var powerlevel - required property var permissions + required property Permissions permissions readonly property bool isV12Creator: permissions ? permissions.creatorLevel() == powerlevel : false readonly property bool isAdmin: permissions ? permissions.changeLevel(MtxEvent.PowerLevels) <= powerlevel : false diff --git a/src/timeline/Permissions.h b/src/timeline/Permissions.h index 9e2187a0..2829bedd 100644 --- a/src/timeline/Permissions.h +++ b/src/timeline/Permissions.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include @@ -15,6 +16,8 @@ class TimelineModel; class Permissions final : public QObject { Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Only to be used to refer to C++ values") public: Permissions(QString roomId, QObject *parent = nullptr); From 5894e32482cc4c1e4b17533c4742435e830920a3 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 22 Feb 2026 14:04:16 +0100 Subject: [PATCH 34/41] Fix rooms not getting marked as tombstoned on sync --- src/Cache.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Cache.cpp b/src/Cache.cpp index ee981abc..46034fe6 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -2479,11 +2479,12 @@ try { } } - updatedInfo.name = getRoomName(txn, statesdb, membersdb).toStdString(); - updatedInfo.topic = getRoomTopic(txn, statesdb).toStdString(); - updatedInfo.avatar_url = getRoomAvatarUrl(txn, statesdb, membersdb).toStdString(); - updatedInfo.version = getRoomVersion(txn, statesdb).toStdString(); - updatedInfo.is_space = getRoomIsSpace(txn, statesdb); + updatedInfo.name = getRoomName(txn, statesdb, membersdb).toStdString(); + updatedInfo.topic = getRoomTopic(txn, statesdb).toStdString(); + updatedInfo.avatar_url = getRoomAvatarUrl(txn, statesdb, membersdb).toStdString(); + updatedInfo.version = getRoomVersion(txn, statesdb).toStdString(); + updatedInfo.is_space = getRoomIsSpace(txn, statesdb); + updatedInfo.is_tombstoned = getRoomIsTombstoned(txn, statesdb); updatedInfo.notification_count = room.second.unread_notifications.notification_count; updatedInfo.highlight_count = room.second.unread_notifications.highlight_count; From b1c387e03c5bb58f86a303de6bd344655eafc8cb Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sun, 22 Feb 2026 17:17:43 +0100 Subject: [PATCH 35/41] Fix tombstones and create events getting mixed up --- src/Cache.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache.cpp b/src/Cache.cpp index 46034fe6..dac67ae4 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -3624,7 +3624,7 @@ Cache::getRoomIsTombstoned(lmdb::txn &txn, lmdb::dbi &statesdb) using namespace mtx::events::state; std::string_view event; - bool res = statesdb.get(txn, to_string(mtx::events::EventType::RoomCreate), event); + bool res = statesdb.get(txn, to_string(mtx::events::EventType::RoomTombstone), event); if (res) { try { From 95532442cad439d2e05d634195c18a1a1727faf5 Mon Sep 17 00:00:00 2001 From: Florian Olk Date: Sun, 22 Feb 2026 19:32:53 +0100 Subject: [PATCH 36/41] fix: Remove 15fps limit for screensharing to prevent variable fps sources from crashing --- src/voip/CallDevices.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/voip/CallDevices.cpp b/src/voip/CallDevices.cpp index 90eef113..d42b378b 100644 --- a/src/voip/CallDevices.cpp +++ b/src/voip/CallDevices.cpp @@ -61,9 +61,7 @@ getFrameRate(const GValue *value) void addFrameRate(std::vector &rates, const FrameRate &rate) { - constexpr double minimumFrameRate = 15.0; - if (static_cast(rate.first) / rate.second >= minimumFrameRate) - rates.push_back(std::to_string(rate.first) + "/" + std::to_string(rate.second)); + rates.push_back(std::to_string(rate.first) + "/" + std::to_string(rate.second)); } void From 15322555e81a25fb6c360d20724da8c6c9226d5a Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Mon, 23 Feb 2026 20:41:20 +0100 Subject: [PATCH 37/41] Limit forward completer height Fixes #2009 --- resources/qml/ForwardCompleter.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/qml/ForwardCompleter.qml b/resources/qml/ForwardCompleter.qml index c5c1689a..c79abbc2 100644 --- a/resources/qml/ForwardCompleter.qml +++ b/resources/qml/ForwardCompleter.qml @@ -57,6 +57,7 @@ Popup { eventId: mid userColor: TimelineManager.userColor(replyPreview.userId, palette.window) maxWidth: parent.width + limitHeight: true } MatrixTextField { id: roomTextInput From 6a0852ee368c682bfdbc1a236d210ff4108ba52f Mon Sep 17 00:00:00 2001 From: TheK0tYaRa Date: Wed, 11 Mar 2026 00:41:46 +0200 Subject: [PATCH 38/41] voice shenanigans --- .gitignore | 22 ++ CMakeLists.txt | 5 + README.md | 45 +++ src/voip/CallManager.cpp | 562 +++++++++++++++++++++++++------------ src/voip/WebRTCSession.cpp | 98 ++++++- src/voip/WebRTCSession.h | 5 + 6 files changed, 549 insertions(+), 188 deletions(-) diff --git a/.gitignore b/.gitignore index 989e876f..3253e59a 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,25 @@ package.dir # Archives *.bz2 + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ff134c5..006315d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -248,6 +248,11 @@ endif() # find_package(Qt6 6.5 COMPONENTS Core Widgets Gui LinguistTools Svg Multimedia Qml QuickControls2 REQUIRED) find_package(Qt6DBus) +find_package(Qt6QmlPrivate REQUIRED NO_MODULE) + +if(UNIX) + find_package(Qt6GuiPrivate REQUIRED NO_MODULE) +endif() if(USE_BUNDLED_QTKEYCHAIN) include(FetchContent) diff --git a/README.md b/README.md index b4c6fa8e..1a3c0214 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,51 @@ cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=Release cmake --build build ``` +#### direnv + devenv + +If you want a reproducible Nix-based development shell for building and smoke-testing the local +binary, this repo now includes `devenv.nix` and `.envrc`. + +Requirements: + +- `nix` with the `nix-command` and `flakes` features enabled +- `devenv` 2.x +- `direnv` with its shell hook enabled in your shell rc file +- network access on the first run so `devenv` can resolve the `devenv.yaml` nixpkgs input and + fetch any missing Nix store paths +- acceptance of the current `olm` insecurity override shipped in `devenv.nix`, since nheko still + depends on `libolm` and recent nixpkgs revisions block it by default + +Usage: + +```bash +direnv allow +``` + +If you don't want automatic shell activation, you can enter the same environment manually: + +```bash +devenv shell +``` + +Inside the shell, use the provided helper commands: + +```bash +configure-nheko +build-nheko +test-nheko +run-nheko +``` + +`test-nheko` builds the project, runs `ctest --output-on-failure`, and then performs a headless +smoke check by running the built `nheko` binary with `--help`. + +You can also run the same test flow through `devenv` directly: + +```bash +devenv test +``` + To use bundled dependencies you can use hunter, i.e.: ```bash diff --git a/src/voip/CallManager.cpp b/src/voip/CallManager.cpp index 58aaa1a4..1ccb7096 100644 --- a/src/voip/CallManager.cpp +++ b/src/voip/CallManager.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -56,6 +57,67 @@ typedef RTCSessionDescriptionInit SDO; namespace { std::vector getTurnURIs(const mtx::responses::TurnServer &turnServer); + +const char * +callStateString(webrtc::State state) +{ + switch (state) { + case webrtc::State::DISCONNECTED: + return "DISCONNECTED"; + case webrtc::State::ICEFAILED: + return "ICEFAILED"; + case webrtc::State::INITIATING: + return "INITIATING"; + case webrtc::State::INITIATED: + return "INITIATED"; + case webrtc::State::OFFERSENT: + return "OFFERSENT"; + case webrtc::State::ANSWERSENT: + return "ANSWERSENT"; + case webrtc::State::CONNECTING: + return "CONNECTING"; + case webrtc::State::CONNECTED: + return "CONNECTED"; + } + + return "UNKNOWN"; +} + +void +logCallException(const char *context, + const QString &roomid, + const std::string &callid, + const std::exception &e) +{ + nhlog::ui()->warn("WebRTC: {} failed: {} (room id: {}, call id: {})", + context, + e.what(), + roomid.isEmpty() ? "" : roomid.toStdString(), + callid.empty() ? "" : callid); +} + +void +logUnknownCallException(const char *context, const QString &roomid, const std::string &callid) +{ + nhlog::ui()->warn("WebRTC: {} failed with an unknown exception (room id: {}, call id: {})", + context, + roomid.isEmpty() ? "" : roomid.toStdString(), + callid.empty() ? "" : callid); +} + +std::string +requestErrorString(const mtx::http::RequestErr &err) +{ + if (!err) + return "unknown request error"; + + std::string details = "status=" + std::to_string(static_cast(err->status_code)); + if (!err->matrix_error.error.empty()) + details += ", matrix_error=" + err->matrix_error.error; + if (!err->parse_error.empty()) + details += ", parse_error=" + err->parse_error; + return details; +} } CallManager * @@ -145,6 +207,18 @@ CallManager::CallManager(QObject *parent) CallCandidates{callid_, partyid_, candidates, callPartyVersion_}); }); + connect( + &session_, + &WebRTCSession::negotiationCreated, + this, + [this](const std::string &sdp, const std::vector &candidates) { + nhlog::ui()->debug("WebRTC: call id: {} - sending negotiation answer", callid_); + emit newMessage( + roomid_, CallNegotiate{callid_, partyid_, timeoutms_, SDO{sdp, SDO::Type::Answer}}); + emit newMessage(roomid_, + CallCandidates{callid_, partyid_, candidates, callPartyVersion_}); + }); + connect(&session_, &WebRTCSession::newICECandidate, this, @@ -158,19 +232,25 @@ CallManager::CallManager(QObject *parent) connect( this, &CallManager::turnServerRetrieved, this, [this](const mtx::responses::TurnServer &res) { - nhlog::net()->info("TURN server(s) retrieved from homeserver:"); - nhlog::net()->info("username: {}", res.username); - nhlog::net()->info("ttl: {} seconds", res.ttl); - for (const auto &u : res.uris) - nhlog::net()->info("uri: {}", u); + try { + nhlog::net()->info("TURN server(s) retrieved from homeserver:"); + nhlog::net()->info("username: {}", res.username); + nhlog::net()->info("ttl: {} seconds", res.ttl); + for (const auto &u : res.uris) + nhlog::net()->info("uri: {}", u); - // Request new credentials close to expiry - // See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 - turnURIs_ = getTurnURIs(res); - uint32_t ttl = std::max(res.ttl, std::uint32_t{3600}); - if (res.ttl < 3600) - nhlog::net()->warn("Setting ttl to 1 hour"); - turnServerTimer_.setInterval(std::chrono::seconds(ttl) * 10 / 9); + // Request new credentials close to expiry + // See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 + turnURIs_ = getTurnURIs(res); + uint32_t ttl = std::max(res.ttl, std::uint32_t{3600}); + if (res.ttl < 3600) + nhlog::net()->warn("Setting ttl to 1 hour"); + turnServerTimer_.setInterval(std::chrono::seconds(ttl) * 10 / 9); + } catch (const std::exception &e) { + nhlog::net()->warn("Failed to process TURN server response: {}", e.what()); + } catch (...) { + nhlog::net()->warn("Failed to process TURN server response: unknown exception"); + } }); connect(&session_, &WebRTCSession::stateChanged, this, [this](webrtc::State state) { @@ -237,90 +317,125 @@ void CallManager::sendInvite(const QString &roomid, CallType callType, unsigned int windowIndex) { if (isOnCall() || isOnCallOnOtherDevice()) { + nhlog::ui()->debug("WebRTC: refusing outbound invite in state {} (call id: {}, other device call id: {})", + callStateString(session_.state()), + callid_, + isOnCallOnOtherDevice_); if (isOnCallOnOtherDevice_ != "") emit ChatPage::instance()->showNotification( QStringLiteral("User is already in a call")); return; } - auto roomInfo = cache::singleRoomInfo(roomid.toStdString()); + try { + auto roomInfo = cache::singleRoomInfo(roomid.toStdString()); - std::string errorMessage; + callType_ = callType; + roomid_ = roomid; + generateCallID(); + std::vector members(cache::getMembers(roomid.toStdString())); + if (members.empty()) { + nhlog::ui()->warn("WebRTC: no room members available for outbound invite in room {}", + roomid.toStdString()); + emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); + clear(); + return; + } - callType_ = callType; - roomid_ = roomid; - generateCallID(); - std::vector members(cache::getMembers(roomid.toStdString())); - const RoomMember *callee; - if (roomInfo.member_count == 1) - callee = &members.front(); - else if (roomInfo.member_count == 2) - callee = members.front().user_id == utils::localUser() ? &members.back() : &members.front(); - else { - emit ChatPage::instance()->showNotification( - QStringLiteral("Calls are limited to rooms with less than two members")); - return; - } + const RoomMember *callee = nullptr; + if (roomInfo.member_count == 1) + callee = &members.front(); + else if (roomInfo.member_count == 2) + callee = + members.front().user_id == utils::localUser() ? &members.back() : &members.front(); + else { + emit ChatPage::instance()->showNotification( + QStringLiteral("Calls are limited to rooms with less than two members")); + clear(); + return; + } + if (!callee) { + nhlog::ui()->warn("WebRTC: failed to resolve callee for outbound invite in room {}", + roomid.toStdString()); + emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); + clear(); + return; + } #ifdef GSTREAMER_AVAILABLE - if (callType == CallType::SCREEN) { - if (screenShareType_ == ScreenShareType::X11 || - screenShareType_ == ScreenShareType::D3D11) { - if (windows_.empty() || windowIndex >= windows_.size()) { - nhlog::ui()->error("WebRTC: window index out of range"); - return; - } - } else { - ScreenCastPortal &sc_portal = ScreenCastPortal::instance(); - if (sc_portal.getStream() == nullptr) { - nhlog::ui()->error("xdg-desktop-portal stream not started"); - return; + if (callType == CallType::SCREEN) { + if (screenShareType_ == ScreenShareType::X11 || + screenShareType_ == ScreenShareType::D3D11) { + if (windows_.empty() || windowIndex >= windows_.size()) { + nhlog::ui()->error("WebRTC: window index out of range"); + clear(); + return; + } + } else { + ScreenCastPortal &sc_portal = ScreenCastPortal::instance(); + if (sc_portal.getStream() == nullptr) { + nhlog::ui()->error("xdg-desktop-portal stream not started"); + clear(); + return; + } } } - } #endif - if (haveCallInvite_) { - nhlog::ui()->debug("WebRTC: Discarding outbound call for inbound call. " - "localUser is polite party"); - if (callParty_ == callee->user_id) { - if (callType == callType_) - acceptInvite(); - else { + if (haveCallInvite_) { + nhlog::ui()->debug("WebRTC: discarding outbound call for inbound call; localUser is polite party"); + if (callParty_ == callee->user_id) { + if (callType == callType_) + acceptInvite(); + else { + emit ChatPage::instance()->showNotification( + QStringLiteral("Can't place call. Call types do not match")); + emit newMessage( + roomid_, + CallHangUp{callid_, partyid_, callPartyVersion_, CallHangUp::Reason::UserBusy}); + } + } else { emit ChatPage::instance()->showNotification( - QStringLiteral("Can't place call. Call types do not match")); + QStringLiteral("Already on a call with a different user")); emit newMessage( roomid_, CallHangUp{callid_, partyid_, callPartyVersion_, CallHangUp::Reason::UserBusy}); } - } else { - emit ChatPage::instance()->showNotification( - QStringLiteral("Already on a call with a different user")); - emit newMessage( - roomid_, - CallHangUp{callid_, partyid_, callPartyVersion_, CallHangUp::Reason::UserBusy}); + return; } - return; - } - session_.setTurnServers(turnURIs_); - std::string strCallType = - callType_ == CallType::VOICE ? "voice" : (callType_ == CallType::VIDEO ? "video" : "screen"); + session_.setTurnServers(turnURIs_); + std::string strCallType = callType_ == CallType::VOICE + ? "voice" + : (callType_ == CallType::VIDEO ? "video" : "screen"); - nhlog::ui()->debug("WebRTC: call id: {} - creating {} invite", callid_, strCallType); - callParty_ = callee->user_id; - callPartyDisplayName_ = callee->display_name.isEmpty() ? callee->user_id : callee->display_name; - callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); - invitee_ = callParty_.toStdString(); - emit newInviteState(); - playRingtone(QUrl(QStringLiteral("qrc:/media/media/ringback.ogg")), true); + nhlog::ui()->debug("WebRTC: call id: {} - creating {} invite", callid_, strCallType); + callParty_ = callee->user_id; + callPartyDisplayName_ = + callee->display_name.isEmpty() ? callee->user_id : callee->display_name; + callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); + invitee_ = callParty_.toStdString(); + emit newInviteState(); + playRingtone(QUrl(QStringLiteral("qrc:/media/media/ringback.ogg")), true); - uint32_t shareWindowId = - callType == CallType::SCREEN && - (screenShareType_ == ScreenShareType::X11 || screenShareType_ == ScreenShareType::D3D11) - ? windows_[windowIndex].second - : 0; - if (!session_.createOffer(callType, screenShareType_, shareWindowId)) { + uint32_t shareWindowId = + callType == CallType::SCREEN && + (screenShareType_ == ScreenShareType::X11 || screenShareType_ == ScreenShareType::D3D11) + ? windows_[windowIndex].second + : 0; + if (!session_.createOffer(callType, screenShareType_, shareWindowId)) { + nhlog::ui()->warn("WebRTC: call id: {} - failed to create offer in state {}", + callid_, + callStateString(session_.state())); + emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); + endCall(); + } + } catch (const std::exception &e) { + logCallException("sendInvite", roomid, callid_, e); + emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); + endCall(); + } catch (...) { + logUnknownCallException("sendInvite", roomid, callid_); emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); endCall(); } @@ -366,11 +481,17 @@ void CallManager::syncEvent(const mtx::events::collections::TimelineEvents &event) { #ifdef GSTREAMER_AVAILABLE - if (handleEvent(event) || handleEvent(event) || - handleEvent(event) || handleEvent(event) || - handleEvent(event) || handleEvent(event) || - handleEvent(event)) - return; + try { + if (handleEvent(event) || handleEvent(event) || + handleEvent(event) || handleEvent(event) || + handleEvent(event) || handleEvent(event) || + handleEvent(event)) + return; + } catch (const std::exception &e) { + logCallException("syncEvent", roomid_, callid_, e); + } catch (...) { + logUnknownCallException("syncEvent", roomid_, callid_); + } #else (void)event; #endif @@ -405,8 +526,11 @@ CallManager::handleEvent(const RoomEvent &callInviteEvent) callInviteEvent.sender, callInviteEvent.content.party_id); - if (callInviteEvent.content.call_id.empty()) + if (callInviteEvent.content.call_id.empty()) { + nhlog::ui()->debug("WebRTC: ignoring CallInvite without call id from {}", + callInviteEvent.sender); return; + } if (callInviteEvent.sender == utils::localUser().toStdString()) { if (callInviteEvent.content.party_id == partyid_) @@ -421,103 +545,133 @@ CallManager::handleEvent(const RoomEvent &callInviteEvent) } } - auto roomInfo = cache::singleRoomInfo(callInviteEvent.room_id); - callPartyVersion_ = callInviteEvent.content.version; + try { + auto roomInfo = cache::singleRoomInfo(callInviteEvent.room_id); + callPartyVersion_ = callInviteEvent.content.version; - const QString &ringtone = UserSettings::instance()->ringtone(); - bool sharesRoom = true; + const QString &ringtone = UserSettings::instance()->ringtone(); + bool sharesRoom = true; - std::vector members(cache::getMembers(callInviteEvent.room_id)); - const RoomMember &caller = - *std::find_if(members.begin(), members.end(), [&](RoomMember member) { - return member.user_id.toStdString() == callInviteEvent.sender; - }); - if (isOnCall() || isOnCallOnOtherDevice()) { - if (isOnCallOnOtherDevice_ != "") - return; - if (callParty_.toStdString() == callInviteEvent.sender) { - if (session_.state() == webrtc::State::OFFERSENT) { - if (callid_ > callInviteEvent.content.call_id) { - endCall(); - callParty_ = caller.user_id; - callPartyDisplayName_ = - caller.display_name.isEmpty() ? caller.user_id : caller.display_name; - callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); - - roomid_ = QString::fromStdString(callInviteEvent.room_id); - callid_ = callInviteEvent.content.call_id; - remoteICECandidates_.clear(); - haveCallInvite_ = true; - callType_ = isVideo ? CallType::VIDEO : CallType::VOICE; - inviteSDP_ = callInviteEvent.content.offer.sdp; - emit newInviteState(); - acceptInvite(); - } - return; - } else if (session_.state() < webrtc::State::OFFERSENT) - endCall(); - else - return; - } else - return; - } - - if (callPartyVersion_ == "0") { - if (roomInfo.member_count != 2) { - emit newMessage(QString::fromStdString(callInviteEvent.room_id), - CallHangUp{callInviteEvent.content.call_id, - partyid_, - callPartyVersion_, - CallHangUp::Reason::InviteTimeOut}); + std::vector members(cache::getMembers(callInviteEvent.room_id)); + auto callerIt = std::find_if(members.begin(), members.end(), [&](const RoomMember &member) { + return member.user_id.toStdString() == callInviteEvent.sender; + }); + if (callerIt == members.end()) { + nhlog::ui()->warn("WebRTC: ignoring CallInvite from unknown member {} in room {}", + callInviteEvent.sender, + callInviteEvent.room_id); return; } - } else { - if (caller.user_id == utils::localUser() && - callInviteEvent.content.party_id == partyid_) // remote echo - return; + const RoomMember &caller = *callerIt; - if (roomInfo.member_count == 2 || // general call in room with two members - (roomInfo.member_count == 1 && - partyid_ != - callInviteEvent.content.party_id) || // self call, ring if not the same party_id - callInviteEvent.content.invitee == "" || // empty, meant for everyone - callInviteEvent.content.invitee == - utils::localUser().toStdString()) // meant specifically for local user - { - if (roomInfo.member_count > 2) { - // check if shares room - sharesRoom = checkSharesRoom(QString::fromStdString(callInviteEvent.room_id), - callInviteEvent.content.invitee); + if (isOnCall() || isOnCallOnOtherDevice()) { + if (isOnCallOnOtherDevice_ != "") { + nhlog::ui()->debug("WebRTC: ignoring CallInvite while another device is active"); + return; + } + if (callParty_.toStdString() == callInviteEvent.sender) { + if (session_.state() == webrtc::State::OFFERSENT) { + if (callid_ > callInviteEvent.content.call_id) { + endCall(); + callParty_ = caller.user_id; + callPartyDisplayName_ = + caller.display_name.isEmpty() ? caller.user_id : caller.display_name; + callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); + + roomid_ = QString::fromStdString(callInviteEvent.room_id); + callid_ = callInviteEvent.content.call_id; + remoteICECandidates_.clear(); + haveCallInvite_ = true; + callType_ = isVideo ? CallType::VIDEO : CallType::VOICE; + inviteSDP_ = callInviteEvent.content.offer.sdp; + emit newInviteState(); + acceptInvite(); + } else { + nhlog::ui()->debug( + "WebRTC: ignoring polite glare invite with newer local call id {}", + callid_); + } + return; + } else if (session_.state() < webrtc::State::OFFERSENT) + endCall(); + else { + nhlog::ui()->debug("WebRTC: ignoring CallInvite in active state {}", + callStateString(session_.state())); + return; + } + } else { + nhlog::ui()->debug("WebRTC: ignoring CallInvite from {} while in call with {}", + callInviteEvent.sender, + callParty_.toStdString()); + return; + } + } + + if (callPartyVersion_ == "0") { + if (roomInfo.member_count != 2) { + nhlog::ui()->debug("WebRTC: rejecting v0 CallInvite in room with {} members", + roomInfo.member_count); + emit newMessage(QString::fromStdString(callInviteEvent.room_id), + CallHangUp{callInviteEvent.content.call_id, + partyid_, + callPartyVersion_, + CallHangUp::Reason::InviteTimeOut}); + return; } } else { - emit newMessage(QString::fromStdString(callInviteEvent.room_id), - CallHangUp{callInviteEvent.content.call_id, - partyid_, - callPartyVersion_, - CallHangUp::Reason::InviteTimeOut}); - return; + if (caller.user_id == utils::localUser() && + callInviteEvent.content.party_id == partyid_) { + nhlog::ui()->debug("WebRTC: ignoring echoed CallInvite from this device"); + return; + } + + if (roomInfo.member_count == 2 || + (roomInfo.member_count == 1 && partyid_ != callInviteEvent.content.party_id) || + callInviteEvent.content.invitee == "" || + callInviteEvent.content.invitee == utils::localUser().toStdString()) { + if (roomInfo.member_count > 2) { + sharesRoom = checkSharesRoom(QString::fromStdString(callInviteEvent.room_id), + callInviteEvent.content.invitee); + } + } else { + nhlog::ui()->debug("WebRTC: rejecting CallInvite not addressed to this device/user"); + emit newMessage(QString::fromStdString(callInviteEvent.room_id), + CallHangUp{callInviteEvent.content.call_id, + partyid_, + callPartyVersion_, + CallHangUp::Reason::InviteTimeOut}); + return; + } } + + if (ringtone != QLatin1String("Mute") && sharesRoom) + playRingtone(ringtone == QLatin1String("Default") + ? QUrl(QStringLiteral("qrc:/media/media/ring.ogg")) + : QUrl::fromLocalFile(ringtone), + true); + + callParty_ = caller.user_id; + callPartyDisplayName_ = caller.display_name.isEmpty() ? caller.user_id : caller.display_name; + callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); + + roomid_ = QString::fromStdString(callInviteEvent.room_id); + callid_ = callInviteEvent.content.call_id; + remoteICECandidates_.clear(); + + haveCallInvite_ = true; + callType_ = isVideo ? CallType::VIDEO : CallType::VOICE; + inviteSDP_ = callInviteEvent.content.offer.sdp; + emit newInviteState(); + } catch (const std::exception &e) { + logCallException("handleEvent(CallInvite)", + QString::fromStdString(callInviteEvent.room_id), + callInviteEvent.content.call_id, + e); + } catch (...) { + logUnknownCallException("handleEvent(CallInvite)", + QString::fromStdString(callInviteEvent.room_id), + callInviteEvent.content.call_id); } - - // ring if not mute or does not have direct message room - if (ringtone != QLatin1String("Mute") && sharesRoom) - playRingtone(ringtone == QLatin1String("Default") - ? QUrl(QStringLiteral("qrc:/media/media/ring.ogg")) - : QUrl::fromLocalFile(ringtone), - true); - - callParty_ = caller.user_id; - callPartyDisplayName_ = caller.display_name.isEmpty() ? caller.user_id : caller.display_name; - callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); - - roomid_ = QString::fromStdString(callInviteEvent.room_id); - callid_ = callInviteEvent.content.call_id; - remoteICECandidates_.clear(); - - haveCallInvite_ = true; - callType_ = isVideo ? CallType::VIDEO : CallType::VOICE; - inviteSDP_ = callInviteEvent.content.offer.sdp; - emit newInviteState(); } void @@ -525,8 +679,10 @@ CallManager::acceptInvite() { // if call was accepted/rejected elsewhere and m.call.select_answer is // received before acceptInvite - if (!haveCallInvite_) + if (!haveCallInvite_) { + nhlog::ui()->debug("WebRTC: acceptInvite ignored without a pending invite"); return; + } stopRingtone(); std::string errorMessage; @@ -541,6 +697,9 @@ CallManager::acceptInvite() session_.setTurnServers(turnURIs_); if (!session_.acceptOffer(inviteSDP_)) { + nhlog::ui()->warn("WebRTC: call id: {} - failed to accept offer in state {}", + callid_, + callStateString(session_.state())); emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); hangUp(); return; @@ -570,8 +729,11 @@ void CallManager::handleEvent(const RoomEvent &callCandidatesEvent) { if (callCandidatesEvent.sender == utils::localUser().toStdString() && - callCandidatesEvent.content.party_id == partyid_) + callCandidatesEvent.content.party_id == partyid_) { + nhlog::ui()->debug("WebRTC: ignoring echoed CallCandidates for call id {}", + callCandidatesEvent.content.call_id); return; + } nhlog::ui()->debug("WebRTC: call id: {} - incoming CallCandidates from ({}, {})", callCandidatesEvent.content.call_id, callCandidatesEvent.sender, @@ -583,9 +745,16 @@ CallManager::handleEvent(const RoomEvent &callCandidatesEvent) else { // CallInvite has been received and we're awaiting localUser to accept or // reject the call + nhlog::ui()->debug("WebRTC: queueing {} ICE candidates for pending invite {}", + callCandidatesEvent.content.candidates.size(), + callCandidatesEvent.content.call_id); for (const auto &c : callCandidatesEvent.content.candidates) remoteICECandidates_.push_back(c); } + } else { + nhlog::ui()->debug("WebRTC: ignoring CallCandidates for inactive call id {} (active: {})", + callCandidatesEvent.content.call_id, + callid_); } } @@ -596,8 +765,11 @@ CallManager::handleEvent(const RoomEvent &callAnswerEvent) callAnswerEvent.content.call_id, callAnswerEvent.sender, callAnswerEvent.content.party_id); - if (answerSelected_) + if (answerSelected_) { + nhlog::ui()->debug("WebRTC: ignoring duplicate CallAnswer for call id {}", + callAnswerEvent.content.call_id); return; + } if (callAnswerEvent.sender == utils::localUser().toStdString() && callid_ == callAnswerEvent.content.call_id) { @@ -622,9 +794,19 @@ CallManager::handleEvent(const RoomEvent &callAnswerEvent) if (isOnCall() && callid_ == callAnswerEvent.content.call_id) { stopRingtone(); if (!session_.acceptAnswer(callAnswerEvent.content.answer.sdp)) { + nhlog::ui()->warn("WebRTC: call id: {} - failed to accept answer in state {}", + callid_, + callStateString(session_.state())); emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); hangUp(); + return; } + } else { + nhlog::ui()->debug("WebRTC: ignoring CallAnswer for inactive call id {} (active: {}, state: {})", + callAnswerEvent.content.call_id, + callid_, + callStateString(session_.state())); + return; } emit newMessage( roomid_, @@ -645,6 +827,11 @@ CallManager::handleEvent(const RoomEvent &callHangUpEvent) if (callid_ == callHangUpEvent.content.call_id || isOnCallOnOtherDevice_ == callHangUpEvent.content.call_id) endCall(); + else + nhlog::ui()->debug("WebRTC: ignoring CallHangUp for inactive call id {} (active: {}, other device: {})", + callHangUpEvent.content.call_id, + callid_, + isOnCallOnOtherDevice_); } void @@ -657,7 +844,7 @@ CallManager::handleEvent(const RoomEvent &callSelectAnswerEven if (callSelectAnswerEvent.sender == utils::localUser().toStdString()) { if (callSelectAnswerEvent.content.party_id != partyid_) { if (std::find(rejectCallPartyIDs_.begin(), - rejectCallPartyIDs_.begin(), + rejectCallPartyIDs_.end(), callSelectAnswerEvent.content.selected_party_id) != rejectCallPartyIDs_.end()) endCall(); @@ -675,7 +862,7 @@ CallManager::handleEvent(const RoomEvent &callSelectAnswerEven if (callSelectAnswerEvent.content.selected_party_id != partyid_) { bool endAllCalls = false; if (std::find(rejectCallPartyIDs_.begin(), - rejectCallPartyIDs_.begin(), + rejectCallPartyIDs_.end(), callSelectAnswerEvent.content.selected_party_id) != rejectCallPartyIDs_.end()) endAllCalls = true; @@ -686,6 +873,10 @@ CallManager::handleEvent(const RoomEvent &callSelectAnswerEven endCall(endAllCalls); } else if (session_.state() == webrtc::State::DISCONNECTED) endCall(); + } else { + nhlog::ui()->debug("WebRTC: ignoring CallSelectAnswer for inactive call id {} (active: {})", + callSelectAnswerEvent.content.call_id, + callid_); } } @@ -696,8 +887,11 @@ CallManager::handleEvent(const RoomEvent &callRejectEvent) callRejectEvent.content.call_id, callRejectEvent.sender, callRejectEvent.content.party_id); - if (answerSelected_) + if (answerSelected_) { + nhlog::ui()->debug("WebRTC: ignoring CallReject after answer selection for call id {}", + callRejectEvent.content.call_id); return; + } rejectCallPartyIDs_.push_back(callRejectEvent.content.party_id); // check remote echo @@ -719,6 +913,10 @@ CallManager::handleEvent(const RoomEvent &callRejectEvent) callid_, partyid_, callPartyVersion_, callRejectEvent.content.party_id}); endCall(); } + } else { + nhlog::ui()->debug("WebRTC: ignoring CallReject for inactive call id {} (active: {})", + callRejectEvent.content.call_id, + callid_); } } @@ -730,8 +928,24 @@ CallManager::handleEvent(const RoomEvent &callNegotiateEvent) callNegotiateEvent.sender, callNegotiateEvent.content.party_id); + if (callNegotiateEvent.sender == utils::localUser().toStdString() && + callNegotiateEvent.content.party_id == partyid_) { + nhlog::ui()->debug("WebRTC: ignoring echoed CallNegotiate for call id {}", + callNegotiateEvent.content.call_id); + return; + } + if (callNegotiateEvent.content.call_id != callid_) { + nhlog::ui()->debug("WebRTC: ignoring CallNegotiate for inactive call id {} (active: {})", + callNegotiateEvent.content.call_id, + callid_); + return; + } + std::string negotiationSDP_ = callNegotiateEvent.content.description.sdp; if (!session_.acceptNegotiation(negotiationSDP_)) { + nhlog::ui()->warn("WebRTC: call id: {} - failed to accept renegotiation in state {}", + callid_, + callStateString(session_.state())); emit ChatPage::instance()->showNotification(QStringLiteral("Problem accepting new SDP")); hangUp(); return; @@ -848,6 +1062,8 @@ CallManager::retrieveTurnServer() [this](const mtx::responses::TurnServer &res, mtx::http::RequestErr err) { if (err) { turnServerTimer_.setInterval(5000); + nhlog::net()->warn("Failed to retrieve TURN server, retrying in 5s: {}", + requestErrorString(err)); return; } emit turnServerRetrieved(res); diff --git a/src/voip/WebRTCSession.cpp b/src/voip/WebRTCSession.cpp index d55b7c41..77693f3b 100644 --- a/src/voip/WebRTCSession.cpp +++ b/src/voip/WebRTCSession.cpp @@ -206,6 +206,9 @@ iceGatheringStateChanged(GstElement *webrtc, if (WebRTCSession::instance().isOffering()) { emit WebRTCSession::instance().offerCreated(localsdp_, localcandidates_); emit WebRTCSession::instance().stateChanged(State::OFFERSENT); + } else if (WebRTCSession::instance().isNegotiating()) { + emit WebRTCSession::instance().negotiationCreated(localsdp_, localcandidates_); + WebRTCSession::instance().finishNegotiation(); } else { emit WebRTCSession::instance().answerCreated(localsdp_, localcandidates_); emit WebRTCSession::instance().stateChanged(State::ANSWERSENT); @@ -765,6 +768,12 @@ WebRTCSession::createOffer(CallType callType, ScreenShareType screenShareType, uint32_t shareWindowId) { + if (state_ != State::DISCONNECTED) { + nhlog::ui()->warn("WebRTC: createOffer ignored in state {}", + static_cast(state_)); + return false; + } + clear(); isOffering_ = true; callType_ = callType; @@ -783,8 +792,11 @@ bool WebRTCSession::acceptOffer(const std::string &sdp) { nhlog::ui()->debug("WebRTC: received offer:\n{}", sdp); - if (state_ != State::DISCONNECTED) + if (state_ != State::DISCONNECTED) { + nhlog::ui()->warn("WebRTC: acceptOffer ignored in state {}", + static_cast(state_)); return false; + } clear(); GstWebRTCSessionDescription *offer = parseSDP(sdp, GST_WEBRTC_SDP_TYPE_OFFER); @@ -839,17 +851,61 @@ bool WebRTCSession::acceptNegotiation(const std::string &sdp) { nhlog::ui()->debug("WebRTC: received negotiation offer:\n{}", sdp); - if (state_ == State::DISCONNECTED) + if (state_ < State::INITIATED) { + nhlog::ui()->warn("WebRTC: acceptNegotiation ignored in state {}", + static_cast(state_)); return false; - return false; + } + if (!pipe_ || !webrtc_) { + nhlog::ui()->error("WebRTC: acceptNegotiation called without an active pipeline"); + return false; + } + + GstWebRTCSessionDescription *offer = parseSDP(sdp, GST_WEBRTC_SDP_TYPE_OFFER); + if (!offer) + return false; + + int opusPayloadType; + bool recvOnly; + bool sendOnly; + if (getMediaAttributes(offer->sdp, "audio", "opus", opusPayloadType, recvOnly, sendOnly)) { + if (opusPayloadType == -1) { + nhlog::ui()->error("WebRTC: remote negotiation offer - no opus encoding"); + gst_webrtc_session_description_free(offer); + return false; + } + } else { + nhlog::ui()->error("WebRTC: remote negotiation offer - no audio media"); + gst_webrtc_session_description_free(offer); + return false; + } + + int unusedPayloadType; + bool isVideo = getMediaAttributes( + offer->sdp, "video", "vp8", unusedPayloadType, isRemoteVideoRecvOnly_, isRemoteVideoSendOnly_); + if (isVideo && unusedPayloadType == -1) { + nhlog::ui()->error("WebRTC: remote negotiation offer - no vp8 encoding"); + gst_webrtc_session_description_free(offer); + return false; + } + + localsdp_.clear(); + localcandidates_.clear(); + isNegotiating_ = true; + GstPromise *promise = gst_promise_new_with_change_func(createAnswer, webrtc_, nullptr); + g_signal_emit_by_name(webrtc_, "set-remote-description", offer, promise); + gst_webrtc_session_description_free(offer); + return true; } bool WebRTCSession::acceptAnswer(const std::string &sdp) { nhlog::ui()->debug("WebRTC: received answer:\n{}", sdp); - if (state_ != State::OFFERSENT) + if (state_ != State::OFFERSENT) { + nhlog::ui()->warn("WebRTC: acceptAnswer ignored in state {}", static_cast(state_)); return false; + } GstWebRTCSessionDescription *answer = parseSDP(sdp, GST_WEBRTC_SDP_TYPE_ANSWER); if (!answer) { @@ -873,14 +929,19 @@ void WebRTCSession::acceptICECandidates( const std::vector &candidates) { - if (state_ >= State::INITIATED) { - for (const auto &c : candidates) { - nhlog::ui()->debug( - "WebRTC: remote candidate: (m-line:{}):{}", c.sdpMLineIndex, c.candidate); - if (!c.candidate.empty()) { - g_signal_emit_by_name( - webrtc_, "add-ice-candidate", c.sdpMLineIndex, c.candidate.c_str()); - } + if (state_ < State::INITIATED || !webrtc_) { + nhlog::ui()->debug("WebRTC: ignoring {} remote ICE candidates in state {}", + candidates.size(), + static_cast(state_)); + return; + } + + for (const auto &c : candidates) { + nhlog::ui()->debug("WebRTC: remote candidate: (m-line:{}):{}", + c.sdpMLineIndex, + c.candidate); + if (!c.candidate.empty()) { + g_signal_emit_by_name(webrtc_, "add-ice-candidate", c.sdpMLineIndex, c.candidate.c_str()); } } } @@ -888,8 +949,10 @@ WebRTCSession::acceptICECandidates( bool WebRTCSession::startPipeline(int opusPayloadType, int vp8PayloadType) { - if (state_ != State::DISCONNECTED) + if (state_ != State::DISCONNECTED) { + nhlog::ui()->warn("WebRTC: startPipeline ignored in state {}", static_cast(state_)); return false; + } emit stateChanged(State::INITIATING); @@ -956,8 +1019,10 @@ bool WebRTCSession::createPipeline(int opusPayloadType, int vp8PayloadType) { GstDevice *device = devices_.audioDevice(); - if (!device) + if (!device) { + nhlog::ui()->error("WebRTC: no audio input device available"); return false; + } GstElement *source = gst_device_create_element(device, nullptr); GstElement *volume = gst_element_factory_make("volume", "srclevel"); @@ -1039,8 +1104,10 @@ WebRTCSession::addVideoPipeline(int vp8PayloadType) std::pair resolution; std::pair frameRate; GstDevice *device = devices_.videoDevice(resolution, frameRate); - if (!device) + if (!device) { + nhlog::ui()->error("WebRTC: no video input device available"); return false; + } GstElement *camera = gst_device_create_element(device, nullptr); GstCaps *caps = gst_caps_new_simple("video/x-raw", @@ -1305,6 +1372,7 @@ WebRTCSession::clear() { callType_ = webrtc::CallType::VOICE; isOffering_ = false; + isNegotiating_ = false; isRemoteVideoRecvOnly_ = false; isRemoteVideoSendOnly_ = false; videoItem_ = nullptr; diff --git a/src/voip/WebRTCSession.h b/src/voip/WebRTCSession.h index 20c32110..f8d066b8 100644 --- a/src/voip/WebRTCSession.h +++ b/src/voip/WebRTCSession.h @@ -70,6 +70,8 @@ public: webrtc::State state() const { return state_; } bool haveLocalPiP() const; bool isOffering() const { return isOffering_; } + bool isNegotiating() const { return isNegotiating_; } + void finishNegotiation() { isNegotiating_ = false; } bool isRemoteVideoRecvOnly() const { return isRemoteVideoRecvOnly_; } bool isRemoteVideoSendOnly() const { return isRemoteVideoSendOnly_; } @@ -94,6 +96,8 @@ signals: const std::vector &); void answerCreated(const std::string &sdp, const std::vector &); + void negotiationCreated(const std::string &sdp, + const std::vector &); void newICECandidate(const mtx::events::voip::CallCandidates::Candidate &); void stateChanged(webrtc::State); @@ -111,6 +115,7 @@ private: webrtc::ScreenShareType screenShareType_ = webrtc::ScreenShareType::X11; webrtc::State state_ = webrtc::State::DISCONNECTED; bool isOffering_ = false; + bool isNegotiating_ = false; bool isRemoteVideoRecvOnly_ = false; bool isRemoteVideoSendOnly_ = false; QQuickItem *videoItem_ = nullptr; From 8dc75613c7f1ee24da01c77915e75da2585e3ef8 Mon Sep 17 00:00:00 2001 From: TheK0tYaRa Date: Wed, 11 Mar 2026 00:44:29 +0200 Subject: [PATCH 39/41] slop shenanigans --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3253e59a..fb8e73f0 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ devenv.local.yaml # pre-commit .pre-commit-config.yaml +codex-resume*.sh From 586c3e3e3ac5d98107d8a5ab3161ffd3c2e8b1f6 Mon Sep 17 00:00:00 2001 From: TheK0tYaRa Date: Wed, 11 Mar 2026 00:44:53 +0200 Subject: [PATCH 40/41] devenv shenanigans --- .envrc | 2 + PLAN-calls.md | 81 +++++++++++++++++++++++++++++++ devenv.lock | 65 +++++++++++++++++++++++++ devenv.nix | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++ devenv.yaml | 12 +++++ 5 files changed, 289 insertions(+) create mode 100644 .envrc create mode 100644 PLAN-calls.md create mode 100644 devenv.lock create mode 100644 devenv.nix create mode 100644 devenv.yaml diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..2efe544e --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +eval "$(devenv direnvrc)" +use devenv diff --git a/PLAN-calls.md b/PLAN-calls.md new file mode 100644 index 00000000..d42fb949 --- /dev/null +++ b/PLAN-calls.md @@ -0,0 +1,81 @@ + # Stabilize Existing VoIP Call Flow and Add Non-Fatal Diagnostics + + ## Summary + + Tighten the existing Matrix call flow in the current VoIP stack, focusing on real correctness issues already present in src/voip/CallManager.cpp and src/voip/WebRTCSession.cpp, and + add structured logging around non-fatal failures so future call bugs are diagnosable without crashing or silently retrying forever. + + This plan targets: + + - Core invite/answer/candidate/select-answer stability + - Inbound renegotiation support for m.call.negotiate + - Logging of non-critical exceptions and ignored/invalid call events + - No user-visible feature expansion beyond making current calls more reliable + + ## Implementation Changes + + - Fix the CallSelectAnswer rejection lookup bug in CallManager::handleEvent(const RoomEvent&) by changing the std::find(begin, begin, ...) calls to search through + end(). This is a concrete logic bug that currently prevents expected branch behavior. + - Add strict call-id and state guards across inbound handlers in CallManager and log every early-return branch that currently drops events silently. Include at least: + - mismatched call_id + - wrong local webrtc::State + - duplicate answers/select-answer/reject + - self-echo cases + - candidates received before a call is initialized + - Implement WebRTCSession::acceptNegotiation(const std::string &) instead of the current unconditional false. Use the same parsing/validation style as acceptOffer: + - require an active session, not DISCONNECTED + - parse SDP as a remote offer + - validate required audio media and optional VP8 video media + - update remote video direction flags + - call set-remote-description + - create and send an answer through the existing answerCreated path + - keep wire format unchanged; do not add local renegotiation initiation in this pass + - Extend CallManager::handleEvent(const RoomEvent&) so renegotiation only applies to the active call, logs dropped mismatches, and drains queued ICE candidates only + after renegotiation is accepted. + - Harden acceptICECandidates handling: + - log when candidates are ignored because the session is not ready + - keep buffering only in manager-side inbound invite/renegotiation waiting states + - avoid silent no-ops when candidates arrive for the wrong call or after teardown + - Improve failure handling around setup paths: + - log TURN retrieval failures in retrieveTurnServer() with the request error and retry interval + - log why createOffer, acceptOffer, acceptAnswer, and renegotiation fail, including current state and call id where available + - log pipeline creation failures with the missing device/element context instead of only generic “Problem setting up call” + - Add non-critical exception logging around call orchestration paths that touch cache/state but should not kill the process: + - syncEvent dispatch + - inbound call event handlers that query room info/members + - outbound invite setup that reads room/member data + - TURN response parsing/conversion + Catch std::exception and log with nhlog at warn or error depending on whether the call can continue; rethrow nothing for these paths. Add a final catch (...) only where needed + to prevent silent termination, with a clear “unknown exception” log line. + - Keep user notifications mostly unchanged. Prefer logs for diagnostics and only surface UI notifications for existing fatal outcomes such as setup failure, ICE failure, reject, + timeout, or answered elsewhere. + + ## Public Interfaces / Behavior + + - No QML property or signal changes. + - No Matrix event schema changes. + - Existing CallAnswer, CallCandidates, CallNegotiate, and CallSelectAnswer messages remain the transport surface. + - Behavioral change: inbound m.call.negotiate will now be handled instead of always failing. + - Behavioral change: previously silent dropped call events and recoverable exceptions will now emit logs with call id, party id, and local state. + + ## Test Plan + + - Add focused unit-style coverage where practical for pure call-manager logic, or otherwise add regression tests around isolated helper logic if the repo lacks VoIP integration + tests. + - Verify manually or with targeted harness coverage: + - outbound call reaches OFFERSENT, receives answer, transitions to CONNECTED + - inbound call invite buffers ICE before accept, then drains after accept + - duplicate CallAnswer and CallSelectAnswer are ignored with logs, not state corruption + - CallSelectAnswer rejection-party matching now works after the std::find fix + - incoming CallNegotiate on the active call produces a local answer instead of immediate failure + - CallNegotiate for a different call_id is ignored with a diagnostic log + - TURN retrieval failure logs retry details without crashing + - exceptions thrown from room/member lookup in call paths are logged and do not abort the process + - Run a build check after the changes. If the environment still lacks cmake, document that verification gap and at minimum run the strongest available static inspection. + + ## Assumptions + + - Use existing nhlog logging categories rather than raw printf; “prints” here means persistent diagnostic logging. + - Keep the existing user-facing notification policy unless a failure is already fatal to the active call. + - Renegotiation scope is inbound-only for now because the current codebase already receives m.call.negotiate but does not expose a local renegotiation initiator. + - No attempt will be made in this pass to redesign the entire call state machine or add automatic media-session recovery after teardown. diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 00000000..4cdbba71 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1773179136, + "narHash": "sha256-aOxRGdpITEvasogdZXGqlSrr8pJQ+QGiRxpzmT3i/UQ=", + "owner": "cachix", + "repo": "devenv", + "rev": "169c048513ca064c02a4233ace0ecbdd935d4f80", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1772749504, + "narHash": "sha256-eqtQIz0alxkQPym+Zh/33gdDjkkch9o6eHnMPnXFXN0=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "08543693199362c1fddb8f52126030d0d374ba2e", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1772173633, + "narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 00000000..dfb4d0f9 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,129 @@ +{ pkgs, lib, config, inputs, ... }: + +let + qtPackages = with pkgs.qt6Packages; [ + qtbase + qtdeclarative + qtimageformats + qtkeychain + qtmultimedia + qtsvg + qttools + qtwayland + qt-jdenticon + ]; + + gstreamerPackages = [ + pkgs.gst_all_1.gstreamer + pkgs.gst_all_1.gst-plugins-base + (pkgs.gst_all_1.gst-plugins-good.override { qt6Support = true; }) + pkgs.gst_all_1.gst-plugins-bad + ]; + + cmakePackages = [ + pkgs.cmark + pkgs.coeurl + pkgs.curl + pkgs.kdsingleapplication + pkgs.libevent + pkgs.libsecret + pkgs.lmdb + pkgs.lmdbxx + pkgs.mtxclient + pkgs.nlohmann_json + pkgs.olm + pkgs.re2 + pkgs.spdlog + ] ++ qtPackages ++ gstreamerPackages ++ [ pkgs.libnice ]; + + runtimePackages = [ + pkgs.pipewire + ]; + + qtPluginPath = lib.makeSearchPath "lib/qt-6/plugins" qtPackages; + qmlImportPath = lib.makeSearchPath "lib/qt-6/qml" qtPackages; + gstreamerPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstreamerPackages; + runtimeLibraryPath = lib.makeLibraryPath runtimePackages; + cmakePrefixPath = + lib.concatStringsSep ":" ( + (map (pkg: "${lib.getDev pkg}/lib/cmake") cmakePackages) + ++ (map (pkg: "${lib.getDev pkg}") cmakePackages) + ); +in +{ + packages = [ + pkgs.asciidoc + pkgs.cmake + pkgs.direnv + pkgs.gcc + pkgs.git + pkgs.gnumake + pkgs.gdb + pkgs.ninja + pkgs.pkg-config + pkgs.pipewire + pkgs.qt6Packages.wrapQtAppsHook + ] ++ cmakePackages; + + env = { + CMAKE_GENERATOR = "Ninja"; + CMAKE_PREFIX_PATH = cmakePrefixPath; + GST_PLUGIN_SYSTEM_PATH_1_0 = gstreamerPluginPath; + LD_LIBRARY_PATH = runtimeLibraryPath; + NHEKO_BUILD_DIR = "build"; + QT_PLUGIN_PATH = qtPluginPath; + QML2_IMPORT_PATH = qmlImportPath; + }; + + scripts."configure-nheko".exec = '' + cmake -S . -B "$NHEKO_BUILD_DIR" \ + -G "$CMAKE_GENERATOR" \ + -DCMAKE_BUILD_TYPE="''${CMAKE_BUILD_TYPE:-RelWithDebInfo}" \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + ''; + + scripts."build-nheko".exec = '' + if [ ! -f "$NHEKO_BUILD_DIR/CMakeCache.txt" ]; then + configure-nheko + fi + + cmake --build "$NHEKO_BUILD_DIR" --parallel "''${NIX_BUILD_CORES:-$(nproc)}" + ''; + + scripts."test-nheko".exec = '' + build-nheko + ctest --test-dir "$NHEKO_BUILD_DIR" --output-on-failure + + binary="$(find "$NHEKO_BUILD_DIR" -maxdepth 4 -type f -name nheko -executable | head -n 1)" + if [ -z "$binary" ]; then + echo "Unable to locate the built nheko binary under $NHEKO_BUILD_DIR" >&2 + exit 1 + fi + + QT_QPA_PLATFORM=offscreen "$binary" --help >/dev/null + ''; + + scripts."run-nheko".exec = '' + build-nheko + + binary="$(find "$NHEKO_BUILD_DIR" -maxdepth 4 -type f -name nheko -executable | head -n 1)" + if [ -z "$binary" ]; then + echo "Unable to locate the built nheko binary under $NHEKO_BUILD_DIR" >&2 + exit 1 + fi + + exec "$binary" "$@" + ''; + + enterShell = '' + echo "nheko devenv ready" + echo " configure-nheko Configure the CMake build tree" + echo " build-nheko Build the nheko binary" + echo " test-nheko Run ctest and a headless --help smoke test" + echo " run-nheko Launch the built client" + ''; + + enterTest = '' + test-nheko + ''; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 00000000..965f695f --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +nixpkgs: + permittedInsecurePackages: + - "olm-3.2.16" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend From 7775068bf5b42f43c8436ee326342779225dc923 Mon Sep 17 00:00:00 2001 From: TheK0tYaRa Date: Wed, 11 Mar 2026 12:58:07 +0200 Subject: [PATCH 41/41] ui and group call shenanigans --- resources/qml/emoji/StickerPicker.qml | 2 +- resources/qml/voip/ActiveCallBar.qml | 2 +- resources/qml/voip/PlaceCall.qml | 2 +- src/voip/CallManager.cpp | 31 ++++++++++++++++----------- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/resources/qml/emoji/StickerPicker.qml b/resources/qml/emoji/StickerPicker.qml index 86eca4f5..4dab7e1c 100644 --- a/resources/qml/emoji/StickerPicker.qml +++ b/resources/qml/emoji/StickerPicker.qml @@ -183,7 +183,7 @@ Menu { height: stickerDim horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter - font.family: Settings.emojiFont != "" ? Settings.emojiFont : undefined + /* font.family: Settings.emojiFont != "" ? Settings.emojiFont : undefined */ font.pixelSize: 36 text: del.modelData.unicode.replace('\ufe0f', '') } diff --git a/resources/qml/voip/ActiveCallBar.qml b/resources/qml/voip/ActiveCallBar.qml index 3b5e2464..975aafad 100644 --- a/resources/qml/voip/ActiveCallBar.qml +++ b/resources/qml/voip/ActiveCallBar.qml @@ -218,7 +218,7 @@ Rectangle { Layout.preferredWidth: 24 Layout.preferredHeight: 24 buttonTextColor: "#000000" - image: CallManager.isMicMuted ? ":/icons/icons/ui/microphone-unmute.svg" : ":/icons/icons/ui/microphone-mute.svg" + image: CallManager.isMicMuted ? ":/icons/icons/ui/microphone-mute.svg" : ":/icons/icons/ui/microphone-unmute.svg" hoverEnabled: true ToolTip.visible: hovered ToolTip.text: CallManager.isMicMuted ? qsTr("Unmute Mic") : qsTr("Mute Mic") diff --git a/resources/qml/voip/PlaceCall.qml b/resources/qml/voip/PlaceCall.qml index c0724828..d3c76c82 100644 --- a/resources/qml/voip/PlaceCall.qml +++ b/resources/qml/voip/PlaceCall.qml @@ -36,7 +36,7 @@ Popup { Layout.leftMargin: 8 Label { - text: qsTr("Place a call to %1?").arg(room.roomName) + text: qsTr("Place a call in %1?").arg(room.roomName) color: palette.windowText } diff --git a/src/voip/CallManager.cpp b/src/voip/CallManager.cpp index 1ccb7096..f7d1d72e 100644 --- a/src/voip/CallManager.cpp +++ b/src/voip/CallManager.cpp @@ -342,19 +342,15 @@ CallManager::sendInvite(const QString &roomid, CallType callType, unsigned int w return; } + const bool isRoomCall = roomInfo.member_count > 2; const RoomMember *callee = nullptr; if (roomInfo.member_count == 1) callee = &members.front(); else if (roomInfo.member_count == 2) callee = members.front().user_id == utils::localUser() ? &members.back() : &members.front(); - else { - emit ChatPage::instance()->showNotification( - QStringLiteral("Calls are limited to rooms with less than two members")); - clear(); - return; - } - if (!callee) { + + if (!isRoomCall && !callee) { nhlog::ui()->warn("WebRTC: failed to resolve callee for outbound invite in room {}", roomid.toStdString()); emit ChatPage::instance()->showNotification(QStringLiteral("Problem setting up call.")); @@ -384,7 +380,7 @@ CallManager::sendInvite(const QString &roomid, CallType callType, unsigned int w if (haveCallInvite_) { nhlog::ui()->debug("WebRTC: discarding outbound call for inbound call; localUser is polite party"); - if (callParty_ == callee->user_id) { + if ((isRoomCall && roomid_ == roomid) || (!isRoomCall && callParty_ == callee->user_id)) { if (callType == callType_) acceptInvite(); else { @@ -409,12 +405,21 @@ CallManager::sendInvite(const QString &roomid, CallType callType, unsigned int w ? "voice" : (callType_ == CallType::VIDEO ? "video" : "screen"); - nhlog::ui()->debug("WebRTC: call id: {} - creating {} invite", callid_, strCallType); - callParty_ = callee->user_id; - callPartyDisplayName_ = - callee->display_name.isEmpty() ? callee->user_id : callee->display_name; + nhlog::ui()->debug("WebRTC: call id: {} - creating {} {}invite", + callid_, + strCallType, + isRoomCall ? "room-wide " : ""); + if (isRoomCall) { + callParty_ = utils::localUser(); + callPartyDisplayName_ = + roomInfo.name.empty() ? roomid : QString::fromStdString(roomInfo.name); + } else { + callParty_ = callee->user_id; + callPartyDisplayName_ = + callee->display_name.isEmpty() ? callee->user_id : callee->display_name; + } callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); - invitee_ = callParty_.toStdString(); + invitee_ = isRoomCall ? std::string{} : callParty_.toStdString(); emit newInviteState(); playRingtone(QUrl(QStringLiteral("qrc:/media/media/ringback.ogg")), true);