summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt5
-rw-r--r--im.nheko.Nheko.yaml2
-rw-r--r--resources/qml/dialogs/EventExpirationDialog.qml167
-rw-r--r--resources/qml/dialogs/RoomSettings.qml18
-rw-r--r--src/ChatPage.cpp1
-rw-r--r--src/UserSettingsPage.cpp33
-rw-r--r--src/UserSettingsPage.h6
-rw-r--r--src/Utils.cpp84
-rw-r--r--src/ui/EventExpiry.cpp124
-rw-r--r--src/ui/EventExpiry.h67
10 files changed, 475 insertions, 32 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 50940246..1d43cfe6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -387,6 +387,8 @@ set(SRC_FILES
 	# UI components
 	src/ui/HiddenEvents.cpp
 	src/ui/HiddenEvents.h
+	src/ui/EventExpiry.cpp
+	src/ui/EventExpiry.h
 	src/ui/MxcAnimatedImage.cpp
 	src/ui/MxcAnimatedImage.h
 	src/ui/MxcMediaProxy.cpp
@@ -599,7 +601,7 @@ if(USE_BUNDLED_MTXCLIENT)
 	FetchContent_Declare(
 		MatrixClient
                 GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
-                GIT_TAG        f4425af712afc6ad704a39b93c912432bd3c1914
+                GIT_TAG        0a4cc9421a97bea81a8921f3f5e040f0a34278fc
 		)
 	set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "")
 	set(BUILD_LIB_TESTS OFF CACHE INTERNAL "")
@@ -763,6 +765,7 @@ set(QML_SOURCES
         resources/qml/dialogs/CreateDirect.qml
         resources/qml/dialogs/CreateRoom.qml
         resources/qml/dialogs/HiddenEventsDialog.qml
+				resources/qml/dialogs/EventExpirationDialog.qml
         resources/qml/dialogs/ImageOverlay.qml
         resources/qml/dialogs/ImagePackEditorDialog.qml
         resources/qml/dialogs/ImagePackSettingsDialog.qml
diff --git a/im.nheko.Nheko.yaml b/im.nheko.Nheko.yaml
index 5a744996..4fa8ccfb 100644
--- a/im.nheko.Nheko.yaml
+++ b/im.nheko.Nheko.yaml
@@ -214,7 +214,7 @@ modules:
     buildsystem: cmake-ninja
     name: mtxclient
     sources:
-      - commit: f4425af712afc6ad704a39b93c912432bd3c1914
+      - commit: 0a4cc9421a97bea81a8921f3f5e040f0a34278fc
         #tag: v0.9.2
         type: git
         url: https://github.com/Nheko-Reborn/mtxclient.git
diff --git a/resources/qml/dialogs/EventExpirationDialog.qml b/resources/qml/dialogs/EventExpirationDialog.qml
new file mode 100644
index 00000000..5d12bda8
--- /dev/null
+++ b/resources/qml/dialogs/EventExpirationDialog.qml
@@ -0,0 +1,167 @@
+// SPDX-FileCopyrightText: Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import ".."
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import im.nheko
+
+ApplicationWindow {
+    id: dialog
+
+    property string roomid: ""
+    property string roomName: ""
+    property var onAccepted: undefined
+
+    modality: Qt.NonModal
+    flags: Qt.Dialog | Qt.WindowTitleHint
+    width: 275
+    height: 330
+    minimumWidth: 250
+    minimumHeight: 220
+
+    EventExpiry {
+        id: eventExpiry
+
+        roomid: dialog.roomid
+    }
+
+    title: {
+        if (roomid) {
+            return qsTr("Event expiration for %1").arg(roomName);
+        }
+        else {
+            return qsTr("Event expiration");
+        }
+    }
+
+    Shortcut {
+        sequence: StandardKey.Cancel
+        onActivated: dbb.rejected()
+    }
+
+    ColumnLayout {
+        spacing: Nheko.paddingMedium
+        anchors.margins: Nheko.paddingMedium
+        anchors.fill: parent
+
+        MatrixText {
+            id: promptLabel
+            text: {
+                if (roomid) {
+                    return qsTr("You can configure when your messages will be deleted in %1. This only happens when Nheko is open and has permissions to delete messages until Matrix servers support this feature natively. In general 0 means disable.").arg(roomName);
+                }
+                else {
+                    return qsTr("You can configure when your messages will be deleted in all rooms unless configured otherwise. This only happens when Nheko is open and has permissions to delete messages until Matrix servers support this feature natively. In general 0 means disable.");
+                }
+            }
+            font.pixelSize: Math.floor(fontMetrics.font.pixelSize * 1.2)
+            Layout.fillWidth: true
+            Layout.fillHeight: false
+        }
+
+        GridLayout {
+            columns: 2
+            rowSpacing: Nheko.paddingMedium
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+
+            MatrixText {
+                text: qsTr("Expire events after X days")
+                ToolTip.text: qsTr("Automatically redacts messages after X days, unless otherwise protected. Set to 0 to disable.")
+                ToolTip.visible: hh1.hovered
+                Layout.fillWidth: true
+
+                HoverHandler {
+                    id: hh1
+                }
+            }
+
+            SpinBox {
+                Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+                from: 0
+                to: 1000
+                stepSize: 1
+                value: eventExpiry.expireEventsAfterDays
+                onValueChanged: eventExpiry.expireEventsAfterDays = value
+                editable: true
+            }
+
+            MatrixText {
+                text: qsTr("Only keep latest X events")
+                ToolTip.text: qsTr("Deletes your events in this room if there are more than X newer messages unless otherwise protected. Set to 0 to disable.")
+                ToolTip.visible: hh2.hovered
+                Layout.fillWidth: true
+
+                HoverHandler {
+                    id: hh2
+                }
+            }
+
+
+            SpinBox {
+                Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+                from: 0
+                to: 1000
+                stepSize: 1
+                value: eventExpiry.expireEventsAfterCount
+                onValueChanged: eventExpiry.expireEventsAfterCount = value
+                editable: true
+            }
+
+            MatrixText {
+                text: qsTr("Always keep latest X events")
+                ToolTip.text: qsTr("This prevents events to be deleted by the above 2 settings if they are the latest X messages from you in the room.")
+                ToolTip.visible: hh3.hovered
+                Layout.fillWidth: true
+
+                HoverHandler {
+                    id: hh3
+                }
+            }
+
+
+            SpinBox {
+                Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+                from: 0
+                to: 1000
+                stepSize: 1
+                value: eventExpiry.protectLatestEvents
+                onValueChanged: eventExpiry.protectLatestEvents = value
+                editable: true
+            }
+
+            MatrixText {
+                text: qsTr("Include state events")
+                ToolTip.text: qsTr("If this is turned on, old state events also get redacted. The latest state event of any type+key combination is excluded from redaction to not remove the room name and similar state by accident.")
+                ToolTip.visible: hh4.hovered
+                Layout.fillWidth: true
+
+                HoverHandler {
+                    id: hh4
+                }
+            }
+
+            ToggleButton {
+                Layout.alignment: Qt.AlignRight
+                checked: eventExpiry.expireStateEvents
+                onToggled: eventExpiry.expireStateEvents = checked
+            }
+        }
+    }
+
+    footer: DialogButtonBox {
+        id: dbb
+
+        standardButtons: DialogButtonBox.Ok | DialogButtonBox.Cancel
+        onAccepted: {
+            eventExpiry.save();
+            dialog.close();
+        }
+        onRejected: dialog.close();
+    }
+
+}
+
diff --git a/resources/qml/dialogs/RoomSettings.qml b/resources/qml/dialogs/RoomSettings.qml
index a3b3663f..3b8e1903 100644
--- a/resources/qml/dialogs/RoomSettings.qml
+++ b/resources/qml/dialogs/RoomSettings.qml
@@ -502,6 +502,24 @@ ApplicationWindow {
                 }
 
                 Label {
+                    text: qsTr("Automatic event deletion")
+                    color: palette.text
+                }
+
+                EventExpirationDialog {
+                    id: eventExpirationDialog
+                    roomid: roomSettings.roomId
+                    roomName: roomSettings.roomName
+                }
+
+                Button {
+                    text: qsTr("Configure")
+                    ToolTip.text: qsTr("Select if your events get automatically deleted in this room.")
+                    onClicked: eventExpirationDialog.show()
+                    Layout.alignment: Qt.AlignRight
+                }
+
+                Label {
                     text: qsTr("GENERAL SETTINGS")
                     font.bold: true
                     color: palette.text
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index c305a54a..4686b0f5 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -87,6 +87,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QObject *parent)
               if (lastSpacesUpdate < QDateTime::currentDateTime().addSecs(-20 * 60)) {
                   lastSpacesUpdate = QDateTime::currentDateTime();
                   utils::updateSpaceVias();
+                  utils::removeExpiredEvents();
               }
 
               if (!isConnected_)
diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index ea7f22c4..7c30f877 100644
--- a/src/UserSettingsPage.cpp
+++ b/src/UserSettingsPage.cpp
@@ -101,6 +101,8 @@ UserSettings::load(std::optional<QString> profile)
     exposeDBusApi_ = settings.value(QStringLiteral("user/expose_dbus_api"), false).toBool();
     updateSpaceVias_ =
       settings.value(QStringLiteral("user/space_background_maintenance"), true).toBool();
+    expireEvents_ =
+      settings.value(QStringLiteral("user/expired_events_background_maintenance"), false).toBool();
 
     mobileMode_ = settings.value(QStringLiteral("user/mobile_mode"), false).toBool();
     emojiFont_  = settings.value(QStringLiteral("user/emoji_font_family"), "emoji").toString();
@@ -309,6 +311,17 @@ UserSettings::setUpdateSpaceVias(bool state)
 }
 
 void
+UserSettings::setExpireEvents(bool state)
+{
+    if (expireEvents_ == state)
+        return;
+
+    expireEvents_ = state;
+    emit expireEventsChanged(state);
+    save();
+}
+
+void
 UserSettings::setMarkdown(bool state)
 {
     if (state == markdown_)
@@ -924,6 +937,7 @@ UserSettings::save()
     settings.setValue(QStringLiteral("open_video_external"), openVideoExternal_);
     settings.setValue(QStringLiteral("expose_dbus_api"), exposeDBusApi_);
     settings.setValue(QStringLiteral("space_background_maintenance"), updateSpaceVias_);
+    settings.setValue(QStringLiteral("expired_events_background_maintenance"), expireEvents_);
 
     settings.endGroup(); // user
 
@@ -1129,6 +1143,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const
             return tr("Expose room information via D-Bus");
         case UpdateSpaceVias:
             return tr("Periodically update community routing information");
+        case ExpireEvents:
+            return tr("Periodically delete expired events");
         }
     } else if (role == Value) {
         switch (index.row()) {
@@ -1266,6 +1282,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const
             return i->exposeDBusApi();
         case UpdateSpaceVias:
             return i->updateSpaceVias();
+        case ExpireEvents:
+            return i->expireEvents();
         }
     } else if (role == Description) {
         switch (index.row()) {
@@ -1449,6 +1467,10 @@ UserSettingsModel::data(const QModelIndex &index, int role) const
               "information about what servers participate in a room to community members. Since "
               "the room participants can change over time, this needs to be updated from time to "
               "time. This setting enables a background job to do that automatically.");
+        case ExpireEvents:
+            return tr("Regularly redact expired events as specified in the event expiration "
+                      "configuration. Since this is currently not executed server side, you need "
+                      "to have one client running this regularly.");
         }
     } else if (role == Type) {
         switch (index.row()) {
@@ -1499,6 +1521,7 @@ UserSettingsModel::data(const QModelIndex &index, int role) const
         case UseOnlineKeyBackup:
         case ExposeDBusApi:
         case UpdateSpaceVias:
+        case ExpireEvents:
         case SpaceNotifications:
         case FancyEffects:
         case ReducedMotion:
@@ -1994,6 +2017,13 @@ UserSettingsModel::setData(const QModelIndex &index, const QVariant &value, int
             } else
                 return false;
         }
+        case ExpireEvents: {
+            if (value.userType() == QMetaType::Bool) {
+                i->setExpireEvents(value.toBool());
+                return true;
+            } else
+                return false;
+        }
         }
     }
     return false;
@@ -2249,4 +2279,7 @@ UserSettingsModel::UserSettingsModel(QObject *p)
     connect(s.get(), &UserSettings::updateSpaceViasChanged, this, [this] {
         emit dataChanged(index(UpdateSpaceVias), index(UpdateSpaceVias), {Value});
     });
+    connect(s.get(), &UserSettings::expireEventsChanged, this, [this] {
+        emit dataChanged(index(ExpireEvents), index(ExpireEvents), {Value});
+    });
 }
diff --git a/src/UserSettingsPage.h b/src/UserSettingsPage.h
index 34dae2ea..4e2691e5 100644
--- a/src/UserSettingsPage.h
+++ b/src/UserSettingsPage.h
@@ -128,6 +128,7 @@ class UserSettings final : public QObject
       bool exposeDBusApi READ exposeDBusApi WRITE setExposeDBusApi NOTIFY exposeDBusApiChanged)
     Q_PROPERTY(bool updateSpaceVias READ updateSpaceVias WRITE setUpdateSpaceVias NOTIFY
                  updateSpaceViasChanged)
+    Q_PROPERTY(bool expireEvents READ expireEvents WRITE setExpireEvents NOTIFY expireEventsChanged)
 
     UserSettings();
 
@@ -233,6 +234,7 @@ public:
     void setCollapsedSpaces(QList<QStringList> spaces);
     void setExposeDBusApi(bool state);
     void setUpdateSpaceVias(bool state);
+    void setExpireEvents(bool state);
 
     QString theme() const { return !theme_.isEmpty() ? theme_ : defaultTheme_; }
     bool messageHoverHighlight() const { return messageHoverHighlight_; }
@@ -308,6 +310,7 @@ public:
     QList<QStringList> collapsedSpaces() const { return collapsedSpaces_; }
     bool exposeDBusApi() const { return exposeDBusApi_; }
     bool updateSpaceVias() const { return updateSpaceVias_; }
+    bool expireEvents() const { return expireEvents_; }
 
 signals:
     void groupViewStateChanged(bool state);
@@ -372,6 +375,7 @@ signals:
     void recentReactionsChanged();
     void exposeDBusApiChanged(bool state);
     void updateSpaceViasChanged(bool state);
+    void expireEventsChanged(bool state);
 
 private:
     // Default to system theme if QT_QPA_PLATFORMTHEME var is set.
@@ -446,6 +450,7 @@ private:
     bool openVideoExternal_;
     bool exposeDBusApi_;
     bool updateSpaceVias_;
+    bool expireEvents_;
 
     QSettings settings;
 
@@ -478,6 +483,7 @@ class UserSettingsModel : public QAbstractListModel
         ExposeDBusApi,
 #endif
         UpdateSpaceVias,
+        ExpireEvents,
 
         AccessibilitySection,
         ReducedMotion,
diff --git a/src/Utils.cpp b/src/Utils.cpp
index 7a412db0..663609fe 100644
--- a/src/Utils.cpp
+++ b/src/Utils.cpp
@@ -1610,8 +1610,7 @@ std::atomic<bool> event_expiration_running = false;
 void
 utils::removeExpiredEvents()
 {
-    // TODO(Nico): Add its own toggle...
-    if (!UserSettings::instance()->updateSpaceVias())
+    if (!UserSettings::instance()->expireEvents())
         return;
 
     if (event_expiration_running.exchange(true)) {
@@ -1645,18 +1644,20 @@ utils::removeExpiredEvents()
         std::string currentRoom;
         std::uint64_t currentRoomCount = 0;
         std::string currentRoomPrevToken;
+        std::set<std::pair<std::string, std::string>> currentRoomStateEvents;
         std::vector<std::string> currentRoomRedactionQueue;
         mtx::events::account_data::nheko_extensions::EventExpiry currentExpiry;
 
         static void next(std::shared_ptr<ApplyEventExpiration> state)
         {
             if (!state->currentRoomRedactionQueue.empty()) {
+                auto evid = state->currentRoomRedactionQueue.back();
+                auto room = state->currentRoom;
                 http::client()->redact_event(
-                  state->currentRoom,
-                  state->currentRoomRedactionQueue.back(),
-                  [state = std::move(state)](const mtx::responses::EventId &,
-                                             mtx::http::RequestErr e) mutable {
-                      const auto &event_id = state->currentRoomRedactionQueue.back();
+                  room,
+                  evid,
+                  [state = std::move(state), evid](const mtx::responses::EventId &,
+                                                   mtx::http::RequestErr e) mutable {
                       if (e) {
                           if (e->status_code == 429 && e->matrix_error.retry_after.count() != 0) {
                               ChatPage::instance()->callFunctionOnGuiThread(
@@ -1669,17 +1670,19 @@ utils::removeExpiredEvents()
                                                        });
                                 });
                               return;
+                          } else {
+                              nhlog::net()->error("Failed to redact event {} in {}: {}",
+                                                  evid,
+                                                  state->currentRoom,
+                                                  *e);
+                              state->currentRoomRedactionQueue.pop_back();
+                              next(std::move(state));
                           }
-
-                          nhlog::net()->error("Failed to redact event {} in {}: {}",
-                                              event_id,
-                                              state->currentRoom,
-                                              *e);
+                      } else {
+                          nhlog::net()->info("Redacted event {} in {}", evid, state->currentRoom);
+                          state->currentRoomRedactionQueue.pop_back();
+                          next(std::move(state));
                       }
-                      nhlog::net()->info(
-                        "Redacted event {} in {}: {}", event_id, state->currentRoom, *e);
-                      state->currentRoomRedactionQueue.pop_back();
-                      next(std::move(state));
                   });
             } else if (!state->currentRoom.empty()) {
                 mtx::http::MessagesOpts opts{};
@@ -1687,6 +1690,7 @@ utils::removeExpiredEvents()
                 opts.from  = state->currentRoomPrevToken;
                 opts.limit = 1000;
                 opts.filter = state->filter;
+                opts.room_id = state->currentRoom;
 
                 http::client()->messages(
                   opts,
@@ -1708,6 +1712,19 @@ utils::removeExpiredEvents()
                                     mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(e))
                                   continue;
 
+                              if (std::holds_alternative<
+                                    mtx::events::RoomEvent<mtx::events::msg::Redacted>>(e))
+                                  continue;
+
+                              if (std::holds_alternative<
+                                    mtx::events::StateEvent<mtx::events::msg::Redacted>>(e))
+                                  continue;
+
+                              // skip events we don't know to protect us from mistakes.
+                              if (std::holds_alternative<
+                                    mtx::events::RoomEvent<mtx::events::Unknown>>(e))
+                                  continue;
+
                               if (mtx::accessors::sender(e) != us)
                                   continue;
 
@@ -1720,6 +1737,21 @@ utils::removeExpiredEvents()
                                   mtx::accessors::is_state_event(e))
                                   continue;
 
+                              if (mtx::accessors::is_state_event(e)) {
+                                  // skip the first state event of a type
+                                  if (std::visit(
+                                        [&state](const auto &se) {
+                                            if constexpr (requires { se.state_key; })
+                                                return state->currentRoomStateEvents
+                                                  .emplace(to_string(se.type), se.state_key)
+                                                  .second;
+                                            else
+                                                return false;
+                                        },
+                                        e))
+                                      continue;
+                              }
+
                               if (state->currentExpiry.keep_only_latest &&
                                   state->currentRoomCount > state->currentExpiry.keep_only_latest) {
                                   state->currentRoomRedactionQueue.push_back(
@@ -1738,6 +1770,7 @@ utils::removeExpiredEvents()
                           state->currentRoom.clear();
                           state->currentRoomCount = 0;
                           state->currentRoomPrevToken.clear();
+                          state->currentRoomStateEvents.clear();
                       }
 
                       next(std::move(state));
@@ -1764,20 +1797,11 @@ utils::removeExpiredEvents()
 
     auto asus = std::make_shared<ApplyEventExpiration>();
 
-    asus->filter =
-      nlohmann::json{
-        "room",
-        nlohmann::json::object({
-          {
-            "timeline",
-            nlohmann::json::object({
-              {"senders", nlohmann::json::array({us})},
-              {"not_types", nlohmann::json::array({"m.room.redaction"})},
-            }),
-          },
-        }),
-      }
-        .dump();
+    nlohmann::json filter;
+    filter["timeline"]["senders"]   = nlohmann::json::array({us});
+    filter["timeline"]["not_types"] = nlohmann::json::array({"m.room.redaction"});
+
+    asus->filter = filter.dump();
 
     asus->globalExpiry = getExpEv();
 
diff --git a/src/ui/EventExpiry.cpp b/src/ui/EventExpiry.cpp
new file mode 100644
index 00000000..ca149dc3
--- /dev/null
+++ b/src/ui/EventExpiry.cpp
@@ -0,0 +1,124 @@
+// SPDX-FileCopyrightText: Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "EventExpiry.h"
+
+#include "Cache_p.h"
+#include "MainWindow.h"
+#include "MatrixClient.h"
+#include "timeline/TimelineModel.h"
+
+void
+EventExpiry::load()
+{
+    using namespace mtx::events;
+
+    this->event = {};
+
+    if (auto temp = cache::client()->getAccountData(mtx::events::EventType::NhekoEventExpiry, "")) {
+        auto h = std::get<
+          mtx::events::AccountDataEvent<mtx::events::account_data::nheko_extensions::EventExpiry>>(
+          *temp);
+        this->event = std::move(h.content);
+    }
+
+    if (!roomid_.isEmpty()) {
+        if (auto temp = cache::client()->getAccountData(mtx::events::EventType::NhekoEventExpiry,
+                                                        roomid_.toStdString())) {
+            auto h      = std::get<mtx::events::AccountDataEvent<
+              mtx::events::account_data::nheko_extensions::EventExpiry>>(*temp);
+            this->event = std::move(h.content);
+        }
+    }
+
+    emit expireEventsAfterDaysChanged();
+    emit expireEventsAfterCountChanged();
+    emit protectLatestEventsChanged();
+    emit expireStateEventsChanged();
+}
+
+void
+EventExpiry::save()
+{
+    if (roomid_.isEmpty())
+        http::client()->put_account_data(event, [](mtx::http::RequestErr e) {
+            if (e) {
+                nhlog::net()->error("Failed to set hidden events: {}", *e);
+                MainWindow::instance()->showNotification(
+                  tr("Failed to set hidden events: %1")
+                    .arg(QString::fromStdString(e->matrix_error.error)));
+            }
+        });
+    else
+        http::client()->put_room_account_data(
+          roomid_.toStdString(), event, [](mtx::http::RequestErr e) {
+              if (e) {
+                  nhlog::net()->error("Failed to set hidden events: {}", *e);
+                  MainWindow::instance()->showNotification(
+                    tr("Failed to set hidden events: %1")
+                      .arg(QString::fromStdString(e->matrix_error.error)));
+              }
+          });
+}
+
+int
+EventExpiry::expireEventsAfterDays() const
+{
+    return event.expire_after_ms / (1000 * 60 * 60 * 24);
+}
+
+int
+EventExpiry::expireEventsAfterCount() const
+{
+    return event.keep_only_latest;
+}
+
+int
+EventExpiry::protectLatestEvents() const
+{
+    return event.protect_latest;
+}
+
+bool
+EventExpiry::expireStateEvents() const
+{
+    return !event.exclude_state_events;
+}
+
+void
+EventExpiry::setExpireEventsAfterDays(int val)
+{
+    if (val > 0)
+        this->event.expire_after_ms = val * (1000 * 60 * 60 * 24);
+    else
+        this->event.expire_after_ms = 0;
+    emit expireEventsAfterDaysChanged();
+}
+
+void
+EventExpiry::setProtectLatestEvents(int val)
+{
+    if (val > 0)
+        this->event.protect_latest = val;
+    else
+        this->event.expire_after_ms = 0;
+    emit protectLatestEventsChanged();
+}
+
+void
+EventExpiry::setExpireEventsAfterCount(int val)
+{
+    if (val > 0)
+        this->event.keep_only_latest = val;
+    else
+        this->event.keep_only_latest = 0;
+    emit expireEventsAfterCountChanged();
+}
+
+void
+EventExpiry::setExpireStateEvents(bool val)
+{
+    this->event.exclude_state_events = !val;
+    emit expireEventsAfterCountChanged();
+}
diff --git a/src/ui/EventExpiry.h b/src/ui/EventExpiry.h
new file mode 100644
index 00000000..aa144dc3
--- /dev/null
+++ b/src/ui/EventExpiry.h
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <QObject>
+#include <QQmlEngine>
+#include <QString>
+#include <QVariantList>
+
+#include <mtx/events/nheko_extensions/event_expiry.hpp>
+
+class EventExpiry : public QObject
+{
+    Q_OBJECT
+    QML_ELEMENT
+    Q_PROPERTY(QString roomid READ roomid WRITE setRoomid NOTIFY roomidChanged REQUIRED)
+    Q_PROPERTY(int expireEventsAfterDays READ expireEventsAfterDays WRITE setExpireEventsAfterDays
+                 NOTIFY expireEventsAfterDaysChanged)
+    Q_PROPERTY(bool expireStateEvents READ expireStateEvents WRITE setExpireStateEvents NOTIFY
+                 expireStateEventsChanged)
+    Q_PROPERTY(int expireEventsAfterCount READ expireEventsAfterCount WRITE
+                 setExpireEventsAfterCount NOTIFY expireEventsAfterCountChanged)
+    Q_PROPERTY(int protectLatestEvents READ protectLatestEvents WRITE setProtectLatestEvents NOTIFY
+                 protectLatestEventsChanged)
+public:
+    explicit EventExpiry(QObject *p = nullptr)
+      : QObject(p)
+    {
+    }
+
+    Q_INVOKABLE void save();
+
+    [[nodiscard]] QString roomid() const { return roomid_; }
+    void setRoomid(const QString &r)
+    {
+        roomid_ = r;
+        emit roomidChanged();
+
+        load();
+    }
+
+    [[nodiscard]] int expireEventsAfterDays() const;
+    [[nodiscard]] int expireEventsAfterCount() const;
+    [[nodiscard]] int protectLatestEvents() const;
+    [[nodiscard]] bool expireStateEvents() const;
+    void setExpireEventsAfterDays(int);
+    void setExpireEventsAfterCount(int);
+    void setProtectLatestEvents(int);
+    void setExpireStateEvents(bool);
+
+signals:
+    void roomidChanged();
+
+    void expireEventsAfterDaysChanged();
+    void expireEventsAfterCountChanged();
+    void protectLatestEventsChanged();
+    void expireStateEventsChanged();
+
+private:
+    QString roomid_;
+    mtx::events::account_data::nheko_extensions::EventExpiry event = {};
+
+    void load();
+};
+