summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Cache.cpp39
-rw-r--r--src/Cache_p.h9
-rw-r--r--src/ChatPage.cpp1
-rw-r--r--src/UserSettingsPage.cpp33
-rw-r--r--src/UserSettingsPage.h6
-rw-r--r--src/Utils.cpp286
-rw-r--r--src/Utils.h3
-rw-r--r--src/ui/EventExpiry.cpp124
-rw-r--r--src/ui/EventExpiry.h66
9 files changed, 564 insertions, 3 deletions
diff --git a/src/Cache.cpp b/src/Cache.cpp
index 7a19cba4..3fe2892b 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -82,6 +82,8 @@ static constexpr auto DEVICES_DB("devices");
 static constexpr auto DEVICE_KEYS_DB("device_keys");
 //! room_ids that have encryption enabled.
 static constexpr auto ENCRYPTED_ROOMS_DB("encrypted_rooms");
+//! Expiration progress for each room
+static constexpr auto EVENT_EXPIRATION_BG_JOB_DB("event_expiration_bg_job");
 
 //! room_id -> pickled OlmInboundGroupSession
 static constexpr auto INBOUND_MEGOLM_SESSIONS_DB("inbound_megolm_sessions");
@@ -327,7 +329,9 @@ Cache::setup()
     megolmSessionDataDb_     = lmdb::dbi::open(txn, MEGOLM_SESSIONS_DATA_DB, MDB_CREATE);
 
     // What rooms are encrypted
-    encryptedRooms_                      = lmdb::dbi::open(txn, ENCRYPTED_ROOMS_DB, MDB_CREATE);
+    encryptedRooms_   = lmdb::dbi::open(txn, ENCRYPTED_ROOMS_DB, MDB_CREATE);
+    eventExpiryBgJob_ = lmdb::dbi::open(txn, EVENT_EXPIRATION_BG_JOB_DB, MDB_CREATE);
+
     [[maybe_unused]] auto verificationDb = getVerificationDb(txn);
     [[maybe_unused]] auto userKeysDb     = getUserKeysDb(txn);
 
@@ -585,6 +589,39 @@ Cache::pickleSecret()
 }
 
 void
+Cache::storeEventExpirationProgress(const std::string &room,
+                                    const std::string &expirationSettings,
+                                    const std::string &stopMarker)
+{
+    nlohmann::json j;
+    j["s"] = expirationSettings;
+    j["m"] = stopMarker;
+
+    auto txn = lmdb::txn::begin(env_);
+    eventExpiryBgJob_.put(txn, room, j.dump());
+    txn.commit();
+}
+
+std::string
+Cache::loadEventExpirationProgress(const std::string &room, const std::string &expirationSettings)
+
+{
+    try {
+        auto txn = ro_txn(env_);
+        std::string_view data;
+        if (!eventExpiryBgJob_.get(txn, room, data))
+            return "";
+
+        auto j = nlohmann::json::parse(data);
+        if (j.value("s", "") == expirationSettings)
+            return j.value("m", "");
+    } catch (...) {
+        return "";
+    }
+    return "";
+}
+
+void
 Cache::setEncryptedRoom(lmdb::txn &txn, const std::string &room_id)
 {
     nhlog::db()->info("mark room {} as encrypted", room_id);
diff --git a/src/Cache_p.h b/src/Cache_p.h
index 121e7e66..8d51c7c4 100644
--- a/src/Cache_p.h
+++ b/src/Cache_p.h
@@ -87,6 +87,13 @@ public:
     //! Retrieve if the room is tombstoned (closed or replaced by a different room)
     bool getRoomIsTombstoned(lmdb::txn &txn, lmdb::dbi &statesdb);
 
+    // for the event expiry background job
+    void storeEventExpirationProgress(const std::string &room,
+                                      const std::string &expirationSettings,
+                                      const std::string &stopMarker);
+    std::string
+    loadEventExpirationProgress(const std::string &room, const std::string &expirationSettings);
+
     //! Get a specific state event
     template<typename T>
     std::optional<mtx::events::StateEvent<T>>
@@ -714,6 +721,8 @@ private:
 
     lmdb::dbi encryptedRooms_;
 
+    lmdb::dbi eventExpiryBgJob_;
+
     QString localUserId_;
     QString cacheDirectory_;
 
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 8ff8cec6..0ea42a27 100644
--- a/src/Utils.cpp
+++ b/src/Utils.cpp
@@ -22,6 +22,7 @@
 
 #include <array>
 #include <cmath>
+#include <mtx/responses/messages.hpp>
 #include <unordered_set>
 #include <variant>
 
@@ -1466,7 +1467,7 @@ utils::updateSpaceVias()
                               ChatPage::instance()->callFunctionOnGuiThread(
                                 [state    = std::move(state),
                                  interval = e->matrix_error.retry_after]() {
-                                    QTimer::singleShot(interval,
+                                    QTimer::singleShot(interval * 3,
                                                        ChatPage::instance(),
                                                        [self = std::move(state)]() mutable {
                                                            next(std::move(self));
@@ -1501,7 +1502,7 @@ utils::updateSpaceVias()
                               ChatPage::instance()->callFunctionOnGuiThread(
                                 [state    = std::move(state),
                                  interval = e->matrix_error.retry_after]() {
-                                    QTimer::singleShot(interval,
+                                    QTimer::singleShot(interval * 3,
                                                        ChatPage::instance(),
                                                        [self = std::move(state)]() mutable {
                                                            next(std::move(self));
@@ -1604,3 +1605,284 @@ utils::updateSpaceVias()
 
     ApplySpaceUpdatesState::next(std::move(asus));
 }
+
+std::atomic<bool> event_expiration_running = false;
+void
+utils::removeExpiredEvents()
+{
+    if (!UserSettings::instance()->expireEvents())
+        return;
+
+    if (event_expiration_running.exchange(true)) {
+        nhlog::net()->info("Event expiration still running, not starting second job.");
+        return;
+    }
+
+    nhlog::net()->info("Remove expired events starting.");
+
+    auto rooms = cache::roomInfo(false);
+
+    auto us = http::client()->user_id().to_string();
+
+    using ExpType =
+      mtx::events::AccountDataEvent<mtx::events::account_data::nheko_extensions::EventExpiry>;
+    static auto getExpEv = [](const std::string &room = "") -> std::optional<ExpType> {
+        if (auto accountEvent =
+              cache::client()->getAccountData(mtx::events::EventType::NhekoEventExpiry, room))
+            if (auto ev = std::get_if<ExpType>(&*accountEvent);
+                ev && (ev->content.expire_after_ms || ev->content.keep_only_latest))
+                return std::optional{*ev};
+        return std::nullopt;
+    };
+
+    struct ApplyEventExpiration
+    {
+        std::optional<ExpType> globalExpiry;
+        std::vector<std::string> roomsToUpdate;
+        std::string filter;
+
+        std::string currentRoom;
+        bool firstMessagesCall         = true;
+        std::uint64_t currentRoomCount = 0;
+
+        // batch token for pagination
+        std::string currentRoomPrevToken;
+        // event id of an event redacted in a previous run
+        std::string currentRoomStopAt;
+        // event id of first event redacted in the current run, hoping that the order stays the
+        // same.
+        std::string currentRoomFirstRedactedEvent;
+        // (evtype,state_key) tuple to keep the latest state event of each.
+        std::set<std::pair<std::string, std::string>> currentRoomStateEvents;
+        // event ids pending redaction
+        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(
+                  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(
+                                [state    = std::move(state),
+                                 interval = e->matrix_error.retry_after]() {
+                                    // triple interval to allow other traffic as well
+                                    QTimer::singleShot(interval * 3,
+                                                       ChatPage::instance(),
+                                                       [self = std::move(state)]() mutable {
+                                                           next(std::move(self));
+                                                       });
+                                });
+                              return;
+                          } else {
+                              nhlog::net()->error("Failed to redact event {} in {}: {}",
+                                                  evid,
+                                                  state->currentRoom,
+                                                  *e);
+                              state->currentRoomRedactionQueue.pop_back();
+                              next(std::move(state));
+                          }
+                      } else {
+                          nhlog::net()->info("Redacted event {} in {}", evid, state->currentRoom);
+
+                          if (state->currentRoomFirstRedactedEvent.empty())
+                              state->currentRoomFirstRedactedEvent = evid;
+
+                          state->currentRoomRedactionQueue.pop_back();
+                          next(std::move(state));
+                      }
+                  });
+            } else if (!state->currentRoom.empty()) {
+                if (state->currentRoomPrevToken.empty() && !state->firstMessagesCall) {
+                    nhlog::net()->info("Finished room {}", state->currentRoom);
+
+                    if (!state->currentRoomFirstRedactedEvent.empty())
+                        cache::client()->storeEventExpirationProgress(
+                          state->currentRoom,
+                          nlohmann::json(state->currentExpiry).dump(),
+                          state->currentRoomFirstRedactedEvent);
+
+                    state->currentRoom.clear();
+                    next(std::move(state));
+                    return;
+                }
+
+                mtx::http::MessagesOpts opts{};
+                opts.dir     = mtx::http::PaginationDirection::Backwards;
+                opts.from    = state->currentRoomPrevToken;
+                opts.limit   = 1000;
+                opts.filter  = state->filter;
+                opts.room_id = state->currentRoom;
+
+                state->firstMessagesCall = false;
+
+                http::client()->messages(
+                  opts,
+                  [state = std::move(state)](const mtx::responses::Messages &msgs,
+                                             mtx::http::RequestErr error) mutable {
+                      if (error) {
+                          // skip success handler
+                          nhlog::net()->warn(
+                            "Finished room {} with error {}", state->currentRoom, *error);
+                          state->currentRoom.clear();
+                      } else if (msgs.chunk.empty()) {
+                          state->currentRoomPrevToken.clear();
+                      } else {
+                          state->currentRoomPrevToken = msgs.end;
+
+                          auto now = (uint64_t)QDateTime::currentMSecsSinceEpoch();
+                          auto us  = http::client()->user_id().to_string();
+
+                          for (const auto &e : msgs.chunk) {
+                              if (std::holds_alternative<
+                                    mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(e))
+                                  continue;
+
+                              if (std::holds_alternative<
+                                    mtx::events::RoomEvent<mtx::events::msg::Redacted>>(e) ||
+                                  std::holds_alternative<
+                                    mtx::events::StateEvent<mtx::events::msg::Redacted>>(e)) {
+                                  if (!state->currentRoomStopAt.empty() &&
+                                      mtx::accessors::event_id(e) == state->currentRoomStopAt) {
+                                      // There is no filter to remove redacted events from
+                                      // pagination, so we try to stop early by caching what event
+                                      // we redacted last if we reached the end of a room.
+                                      nhlog::net()->info(
+                                        "Found previous redaction marker, stopping early: {}",
+                                        state->currentRoom);
+                                      state->currentRoomPrevToken.clear();
+                                      break;
+                                  }
+                                  continue;
+                              }
+
+                              if (std::holds_alternative<
+                                    mtx::events::StateEvent<mtx::events::msg::Redacted>>(e))
+                                  continue;
+
+                              // synapse protects these 2 against redaction
+                              if (std::holds_alternative<
+                                    mtx::events::StateEvent<mtx::events::state::Create>>(e))
+                                  continue;
+
+                              if (std::holds_alternative<
+                                    mtx::events::StateEvent<mtx::events::state::ServerAcl>>(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;
+
+                              state->currentRoomCount++;
+                              if (state->currentRoomCount <= state->currentExpiry.protect_latest) {
+                                  continue;
+                              }
+
+                              if (state->currentExpiry.exclude_state_events &&
+                                  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 true;
+                                        },
+                                        e))
+                                      continue;
+                              }
+
+                              if (state->currentExpiry.keep_only_latest &&
+                                  state->currentRoomCount > state->currentExpiry.keep_only_latest) {
+                                  state->currentRoomRedactionQueue.push_back(
+                                    mtx::accessors::event_id(e));
+                              } else if (state->currentExpiry.expire_after_ms &&
+                                         (state->currentExpiry.expire_after_ms +
+                                          mtx::accessors::origin_server_ts(e).toMSecsSinceEpoch()) <
+                                           now) {
+                                  state->currentRoomRedactionQueue.push_back(
+                                    mtx::accessors::event_id(e));
+                              }
+                          }
+                      }
+
+                      next(std::move(state));
+                  });
+            } else if (!state->roomsToUpdate.empty()) {
+                const auto &room = state->roomsToUpdate.back();
+
+                auto localExp = getExpEv(room);
+                if (localExp) {
+                    state->currentRoom   = room;
+                    state->currentExpiry = localExp->content;
+                } else if (state->globalExpiry) {
+                    state->currentRoom   = room;
+                    state->currentExpiry = state->globalExpiry->content;
+                }
+                state->firstMessagesCall    = true;
+                state->currentRoomCount     = 0;
+                state->currentRoomPrevToken = "";
+                state->currentRoomRedactionQueue.clear();
+                state->currentRoomStateEvents.clear();
+
+                state->currentRoomStopAt = cache::client()->loadEventExpirationProgress(
+                  state->currentRoom, nlohmann::json(state->currentExpiry).dump());
+
+                state->roomsToUpdate.pop_back();
+                next(std::move(state));
+            } else {
+                nhlog::net()->info("Finished event expiry");
+                event_expiration_running = false;
+            }
+        }
+    };
+
+    auto asus = std::make_shared<ApplyEventExpiration>();
+
+    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();
+
+    for (const auto &[roomid_, info] : rooms.toStdMap()) {
+        auto roomid = roomid_.toStdString();
+
+        if (!asus->globalExpiry && !getExpEv(roomid))
+            continue;
+
+        if (auto pl = cache::client()
+                        ->getStateEvent<mtx::events::state::PowerLevels>(roomid)
+                        .value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{})
+                        .content;
+            pl.user_level(us) < pl.event_level(to_string(mtx::events::EventType::RoomRedaction))) {
+            nhlog::net()->warn("Can't react events in {}, not running expiration.", roomid);
+            continue;
+        }
+
+        asus->roomsToUpdate.push_back(roomid);
+    }
+
+    nhlog::db()->info("Running expiration in {} rooms", asus->roomsToUpdate.size());
+
+    ApplyEventExpiration::next(std::move(asus));
+}
diff --git a/src/Utils.h b/src/Utils.h
index af5ea340..83f2cad1 100644
--- a/src/Utils.h
+++ b/src/Utils.h
@@ -339,4 +339,7 @@ roomVias(const std::string &roomid);
 
 void
 updateSpaceVias();
+
+void
+removeExpiredEvents();
 }
diff --git a/src/ui/EventExpiry.cpp b/src/ui/EventExpiry.cpp
new file mode 100644
index 00000000..ef3f0933
--- /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 = std::uint64_t(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 = std::uint64_t(val);
+    else
+        this->event.expire_after_ms = 0;
+    emit protectLatestEventsChanged();
+}
+
+void
+EventExpiry::setExpireEventsAfterCount(int val)
+{
+    if (val > 0)
+        this->event.keep_only_latest = std::uint64_t(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..378c4484
--- /dev/null
+++ b/src/ui/EventExpiry.h
@@ -0,0 +1,66 @@
+// 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();
+};