summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/AliasEditModel.cpp336
-rw-r--r--src/AliasEditModel.h79
-rw-r--r--src/Cache.cpp10
-rw-r--r--src/MainWindow.cpp8
-rw-r--r--src/PowerlevelsEditModels.cpp7
-rw-r--r--src/dbus/NhekoDBusBackend.cpp50
-rw-r--r--src/timeline/InputBar.cpp56
-rw-r--r--src/timeline/TimelineModel.cpp86
-rw-r--r--src/timeline/TimelineModel.h8
-rw-r--r--src/ui/NhekoGlobalObject.h5
10 files changed, 609 insertions, 36 deletions
diff --git a/src/AliasEditModel.cpp b/src/AliasEditModel.cpp
new file mode 100644
index 00000000..aee42dd1
--- /dev/null
+++ b/src/AliasEditModel.cpp
@@ -0,0 +1,336 @@
+// SPDX-FileCopyrightText: 2022 Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "AliasEditModel.h"
+
+#include <QSharedPointer>
+
+#include <set>
+
+#include <mtx/responses/common.hpp>
+
+#include "Cache.h"
+#include "Cache_p.h"
+#include "ChatPage.h"
+#include "Logging.h"
+#include "MatrixClient.h"
+#include "timeline/Permissions.h"
+#include "timeline/TimelineModel.h"
+
+AliasEditingModel::AliasEditingModel(const std::string &rid, QObject *parent)
+  : QAbstractListModel(parent)
+  , room_id(rid)
+  , aliasEvent(cache::client()
+                 ->getStateEvent<mtx::events::state::CanonicalAlias>(room_id)
+                 .value_or(mtx::events::StateEvent<mtx::events::state::CanonicalAlias>{})
+                 .content)
+  , canSendStateEvent(
+      Permissions(QString::fromStdString(rid)).canChange(qml_mtx_events::CanonicalAlias))
+{
+    std::set<std::string> seen_aliases;
+
+    if (!aliasEvent.alias.empty()) {
+        aliases.push_back(Entry{aliasEvent.alias, true, true, false});
+        seen_aliases.insert(aliasEvent.alias);
+    }
+
+    for (const auto &alias : aliasEvent.alt_aliases) {
+        if (!seen_aliases.count(alias)) {
+            aliases.push_back(Entry{alias, false, true, false});
+            seen_aliases.insert(alias);
+        }
+    }
+
+    for (const auto &alias : qAsConst(aliases)) {
+        fetchAliasesStatus(alias.alias);
+    }
+    fetchPublishedAliases();
+}
+
+void
+AliasEditingModel::fetchPublishedAliases()
+{
+    auto job = QSharedPointer<FetchPublishedAliasesJob>::create();
+    connect(job.data(),
+            &FetchPublishedAliasesJob::advertizedAliasesFetched,
+            this,
+            &AliasEditingModel::updatePublishedAliases);
+    http::client()->list_room_aliases(
+      room_id, [job](const mtx::responses::Aliases &aliasesFetched, mtx::http::RequestErr) {
+          emit job->advertizedAliasesFetched(std::move(aliasesFetched.aliases));
+      });
+}
+
+void
+AliasEditingModel::fetchAliasesStatus(const std::string &alias)
+{
+    auto job = QSharedPointer<FetchPublishedAliasesJob>::create();
+    connect(
+      job.data(), &FetchPublishedAliasesJob::aliasFetched, this, &AliasEditingModel::updateAlias);
+    http::client()->resolve_room_alias(
+      alias, [job, alias](const mtx::responses::RoomId &roomIdFetched, mtx::http::RequestErr e) {
+          if (!e)
+              emit job->aliasFetched(alias, std::move(roomIdFetched.room_id));
+      });
+}
+
+QHash<int, QByteArray>
+AliasEditingModel::roleNames() const
+{
+    return {
+      {Name, "name"},
+      {IsPublished, "isPublished"},
+      {IsCanonical, "isCanonical"},
+      {IsAdvertized, "isAdvertized"},
+    };
+}
+
+QVariant
+AliasEditingModel::data(const QModelIndex &index, int role) const
+{
+    if (!index.isValid() || index.row() >= aliases.size())
+        return {};
+
+    const auto &entry = aliases.at(index.row());
+
+    switch (role) {
+    case Name:
+        return QString::fromStdString(entry.alias);
+    case IsPublished:
+        return entry.published;
+    case IsCanonical:
+        return entry.canonical;
+    case IsAdvertized:
+        return entry.advertized;
+    }
+
+    return {};
+}
+
+bool
+AliasEditingModel::deleteAlias(int row)
+{
+    if (row < 0 || row >= aliases.size() || aliases.at(row).alias.empty())
+        return false;
+
+    auto alias = aliases.at(row);
+
+    beginRemoveRows(QModelIndex(), row, row);
+    aliases.remove(row);
+    endRemoveRows();
+
+    if (alias.published)
+        http::client()->delete_room_alias(alias.alias, [alias](mtx::http::RequestErr e) {
+            if (e) {
+                nhlog::net()->error("Failed to delete {}: {}", alias.alias, *e);
+                ChatPage::instance()->showNotification(
+                  tr("Failed to unpublish alias %1: %2")
+                    .arg(QString::fromStdString(alias.alias),
+                         QString::fromStdString(e->matrix_error.error)));
+            }
+        });
+
+    if (aliasEvent.alias == alias.alias)
+        aliasEvent.alias.clear();
+
+    for (size_t i = 0; i < aliasEvent.alt_aliases.size(); i++) {
+        if (aliasEvent.alt_aliases[i] == alias.alias) {
+            aliasEvent.alt_aliases.erase(aliasEvent.alt_aliases.begin() + i);
+            break;
+        }
+    }
+
+    return true;
+}
+
+void
+AliasEditingModel::addAlias(QString newAlias)
+{
+    const auto aliasStr = newAlias.toStdString();
+    for (const auto &e : qAsConst(aliases)) {
+        if (e.alias == aliasStr) {
+            return;
+        }
+    }
+
+    beginInsertRows(QModelIndex(), aliases.length(), aliases.length());
+    if (aliasEvent.alias.empty())
+        aliasEvent.alias = aliasStr;
+    else
+        aliasEvent.alt_aliases.push_back(aliasStr);
+    aliases.push_back(
+      Entry{aliasStr, aliasEvent.alias.empty() && canSendStateEvent, canSendStateEvent, false});
+    endInsertRows();
+
+    auto job = QSharedPointer<FetchPublishedAliasesJob>::create();
+    connect(
+      job.data(), &FetchPublishedAliasesJob::aliasFetched, this, &AliasEditingModel::updateAlias);
+    auto room = room_id;
+    http::client()->add_room_alias(
+      aliasStr, room_id, [job, aliasStr, room](mtx::http::RequestErr e) {
+          if (e) {
+              nhlog::net()->error("Failed to publish {}: {}", aliasStr, *e);
+              ChatPage::instance()->showNotification(
+                tr("Failed to unpublish alias %1: %2")
+                  .arg(QString::fromStdString(aliasStr),
+                       QString::fromStdString(e->matrix_error.error)));
+              emit job->aliasFetched(aliasStr, "");
+          } else {
+              emit job->aliasFetched(aliasStr, room);
+          }
+      });
+}
+
+void
+AliasEditingModel::makeCanonical(int row)
+{
+    if (!canSendStateEvent || row < 0 || row >= aliases.size() || aliases.at(row).alias.empty())
+        return;
+
+    auto moveAlias = aliases.at(row).alias;
+
+    if (!aliasEvent.alias.empty()) {
+        for (qsizetype i = 0; i < aliases.size(); i++) {
+            if (moveAlias == aliases[i].alias) {
+                if (aliases[i].canonical) {
+                    aliases[i].canonical = false;
+                    aliasEvent.alt_aliases.push_back(aliasEvent.alias);
+                    emit dataChanged(index(i), index(i), {IsCanonical});
+                }
+                break;
+            }
+        }
+    }
+
+    aliasEvent.alias = moveAlias;
+    for (auto i = aliasEvent.alt_aliases.begin(); i != aliasEvent.alt_aliases.end(); ++i) {
+        if (*i == moveAlias) {
+            aliasEvent.alt_aliases.erase(i);
+            break;
+        }
+    }
+    aliases[row].canonical  = true;
+    aliases[row].advertized = true;
+    emit dataChanged(index(row), index(row), {IsCanonical, IsAdvertized});
+}
+
+void
+AliasEditingModel::togglePublish(int row)
+{
+    if (row < 0 || row >= aliases.size() || aliases.at(row).alias.empty())
+        return;
+    auto aliasStr = aliases[row].alias;
+
+    auto job = QSharedPointer<FetchPublishedAliasesJob>::create();
+    connect(
+      job.data(), &FetchPublishedAliasesJob::aliasFetched, this, &AliasEditingModel::updateAlias);
+    auto room = room_id;
+    if (!aliases[row].published)
+        http::client()->add_room_alias(
+          aliasStr, room_id, [job, aliasStr, room](mtx::http::RequestErr e) {
+              if (e) {
+                  nhlog::net()->error("Failed to publish {}: {}", aliasStr, *e);
+                  ChatPage::instance()->showNotification(
+                    tr("Failed to unpublish alias %1: %2")
+                      .arg(QString::fromStdString(aliasStr),
+                           QString::fromStdString(e->matrix_error.error)));
+                  emit job->aliasFetched(aliasStr, "");
+              } else {
+                  emit job->aliasFetched(aliasStr, room);
+              }
+          });
+    else
+        http::client()->delete_room_alias(aliasStr, [job, aliasStr, room](mtx::http::RequestErr e) {
+            if (e) {
+                nhlog::net()->error("Failed to unpublish {}: {}", aliasStr, *e);
+                ChatPage::instance()->showNotification(
+                  tr("Failed to unpublish alias %1: %2")
+                    .arg(QString::fromStdString(aliasStr),
+                         QString::fromStdString(e->matrix_error.error)));
+                emit job->aliasFetched(aliasStr, room);
+            } else {
+                emit job->aliasFetched(aliasStr, "");
+            }
+        });
+}
+
+void
+AliasEditingModel::toggleAdvertize(int row)
+{
+    if (!canSendStateEvent || row < 0 || row >= aliases.size() || aliases.at(row).alias.empty())
+        return;
+
+    auto &moveAlias = aliases[row];
+    if (aliasEvent.alias == moveAlias.alias) {
+        moveAlias.canonical  = false;
+        moveAlias.advertized = false;
+        aliasEvent.alias.clear();
+        emit dataChanged(index(row), index(row), {IsAdvertized, IsCanonical});
+    } else if (moveAlias.advertized) {
+        for (auto i = aliasEvent.alt_aliases.begin(); i != aliasEvent.alt_aliases.end(); ++i) {
+            if (*i == moveAlias.alias) {
+                aliasEvent.alt_aliases.erase(i);
+                moveAlias.advertized = false;
+                emit dataChanged(index(row), index(row), {IsAdvertized});
+                break;
+            }
+        }
+    } else {
+        aliasEvent.alt_aliases.push_back(moveAlias.alias);
+        moveAlias.advertized = true;
+        emit dataChanged(index(row), index(row), {IsAdvertized});
+    }
+}
+
+void
+AliasEditingModel::updateAlias(std::string alias, std::string target)
+{
+    for (qsizetype i = 0; i < aliases.size(); i++) {
+        auto &e = aliases[i];
+        if (e.alias == alias) {
+            e.published = (target == room_id);
+            emit dataChanged(index(i), index(i), {IsPublished});
+        }
+    }
+}
+
+void
+AliasEditingModel::updatePublishedAliases(std::vector<std::string> advAliases)
+{
+    for (const auto &advAlias : advAliases) {
+        bool found = false;
+        for (qsizetype i = 0; i < aliases.size(); i++) {
+            auto &alias = aliases[i];
+            if (alias.alias == advAlias) {
+                alias.published = true;
+                emit dataChanged(index(i), index(i), {IsPublished});
+                found = true;
+                break;
+            }
+        }
+
+        if (!found) {
+            beginInsertRows(QModelIndex(), aliases.size(), aliases.size());
+            aliases.push_back(Entry{advAlias, false, false, true});
+            endInsertRows();
+        }
+    }
+}
+
+void
+AliasEditingModel::commit()
+{
+    if (!canSendStateEvent)
+        return;
+
+    http::client()->send_state_event(
+      room_id, aliasEvent, [](const mtx::responses::EventId &, mtx::http::RequestErr e) {
+          if (e) {
+              nhlog::net()->error("Failed to send Alias event: {}", *e);
+              ChatPage::instance()->showNotification(
+                tr("Failed to update aliases: %1")
+                  .arg(QString::fromStdString(e->matrix_error.error)));
+          }
+      });
+}
diff --git a/src/AliasEditModel.h b/src/AliasEditModel.h
new file mode 100644
index 00000000..4fccf9ce
--- /dev/null
+++ b/src/AliasEditModel.h
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: 2022 Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <QAbstractListModel>
+#include <QVector>
+
+#include <mtx/events/canonical_alias.hpp>
+
+#include "CacheStructs.h"
+
+class FetchPublishedAliasesJob : public QObject
+{
+    Q_OBJECT
+
+public:
+    explicit FetchPublishedAliasesJob(QObject *p = nullptr)
+      : QObject(p)
+    {}
+
+signals:
+    void aliasFetched(std::string alias, std::string target);
+    void advertizedAliasesFetched(std::vector<std::string> aliases);
+};
+
+class AliasEditingModel : public QAbstractListModel
+{
+    Q_OBJECT
+    Q_PROPERTY(bool canAdvertize READ canAdvertize CONSTANT)
+
+public:
+    enum Roles
+    {
+        Name,
+        IsPublished,
+        IsCanonical,
+        IsAdvertized,
+    };
+
+    explicit AliasEditingModel(const std::string &room_id_, QObject *parent = nullptr);
+
+    QHash<int, QByteArray> roleNames() const override;
+    int rowCount(const QModelIndex &) const override { return static_cast<int>(aliases.size()); }
+    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+
+    bool canAdvertize() const { return canSendStateEvent; }
+
+    Q_INVOKABLE bool deleteAlias(int row);
+    Q_INVOKABLE void addAlias(QString newAlias);
+    Q_INVOKABLE void makeCanonical(int row);
+    Q_INVOKABLE void togglePublish(int row);
+    Q_INVOKABLE void toggleAdvertize(int row);
+    Q_INVOKABLE void commit();
+
+private slots:
+    void updateAlias(std::string alias, std::string target);
+    void updatePublishedAliases(std::vector<std::string> aliases);
+
+private:
+    void fetchAliasesStatus(const std::string &alias);
+    void fetchPublishedAliases();
+
+    struct Entry
+    {
+        ~Entry() = default;
+
+        std::string alias;
+        bool canonical  = false;
+        bool advertized = false;
+        bool published  = false;
+    };
+
+    std::string room_id;
+    QVector<Entry> aliases;
+    mtx::events::state::CanonicalAlias aliasEvent;
+    bool canSendStateEvent = false;
+};
diff --git a/src/Cache.cpp b/src/Cache.cpp
index 67889543..c9baaf5e 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -4442,11 +4442,15 @@ Cache::markUserKeysOutOfDate(lmdb::txn &txn,
 
         std::string_view oldKeys;
 
-        UserKeyCache cacheEntry;
+        UserKeyCache cacheEntry{};
         auto res = db.get(txn, user, oldKeys);
         if (res) {
-            cacheEntry = nlohmann::json::parse(std::string_view(oldKeys.data(), oldKeys.size()))
-                           .get<UserKeyCache>();
+            try {
+                cacheEntry = nlohmann::json::parse(std::string_view(oldKeys.data(), oldKeys.size()))
+                               .get<UserKeyCache>();
+            } catch (std::exception &e) {
+                nhlog::db()->error("Failed to parse {}: {}", oldKeys, e.what());
+            }
         }
         cacheEntry.last_changed = sync_token;
 
diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp
index f031c80f..d2e28277 100644
--- a/src/MainWindow.cpp
+++ b/src/MainWindow.cpp
@@ -10,6 +10,7 @@
 #include <mtx/requests.hpp>
 #include <mtx/responses/login.hpp>
 
+#include "AliasEditModel.h"
 #include "BlurhashProvider.h"
 #include "Cache.h"
 #include "Cache_p.h"
@@ -179,6 +180,13 @@ MainWindow::registerQmlTypes()
     qmlRegisterType<LoginPage>("im.nheko", 1, 0, "Login");
     qmlRegisterType<RegisterPage>("im.nheko", 1, 0, "Registration");
     qmlRegisterType<HiddenEvents>("im.nheko", 1, 0, "HiddenEvents");
+    qmlRegisterUncreatableType<AliasEditingModel>(
+      "im.nheko",
+      1,
+      0,
+      "AliasEditingModel",
+      QStringLiteral("Please use editAliases to create the models"));
+
     qmlRegisterUncreatableType<PowerlevelEditingModels>(
       "im.nheko",
       1,
diff --git a/src/PowerlevelsEditModels.cpp b/src/PowerlevelsEditModels.cpp
index 38ab42dc..fcfde26e 100644
--- a/src/PowerlevelsEditModels.cpp
+++ b/src/PowerlevelsEditModels.cpp
@@ -198,6 +198,13 @@ PowerlevelsTypeListModel::data(const QModelIndex &index, int role) const
         else if (type.type == "m.sticker")
             return tr("Send stickers");
 
+        else if (type.type == "m.policy.rule.user")
+            return tr("Ban users using policy rules");
+        else if (type.type == "m.policy.rule.room")
+            return tr("Ban rooms using policy rules");
+        else if (type.type == "m.policy.rule.server")
+            return tr("Ban servers using policy rules");
+
         else if (type.type == "m.space.child")
             return tr("Edit child rooms");
         else if (type.type == "m.space.parent")
diff --git a/src/dbus/NhekoDBusBackend.cpp b/src/dbus/NhekoDBusBackend.cpp
index 836475ee..9abc0433 100644
--- a/src/dbus/NhekoDBusBackend.cpp
+++ b/src/dbus/NhekoDBusBackend.cpp
@@ -4,6 +4,9 @@
 
 #include "NhekoDBusBackend.h"
 
+#include <mutex>
+
+#include "Cache.h"
 #include "Cache_p.h"
 #include "ChatPage.h"
 #include "Logging.h"
@@ -18,15 +21,34 @@ NhekoDBusBackend::NhekoDBusBackend(RoomlistModel *parent)
   , m_parent{parent}
 {}
 
+namespace {
+struct RoomReplyState
+{
+    QVector<nheko::dbus::RoomInfoItem> model;
+    std::map<QString, RoomInfo> roominfos;
+    std::mutex m;
+};
+}
+
 QVector<nheko::dbus::RoomInfoItem>
 NhekoDBusBackend::rooms(const QDBusMessage &message)
 {
+    message.setDelayedReply(true);
+    nhlog::ui()->debug("Rooms requested over D-Bus.");
+
     const auto roomListModel = m_parent->models;
-    QSharedPointer<QVector<nheko::dbus::RoomInfoItem>> model{
-      new QVector<nheko::dbus::RoomInfoItem>};
+
+    auto state = QSharedPointer<RoomReplyState>::create();
+
+    std::vector<std::string> roomids;
+    roomids.reserve(roomids.size());
+    for (const auto &room : roomListModel) {
+        roomids.push_back(room->roomId().toStdString());
+    }
+    state->roominfos = cache::getRoomInfo(roomids);
 
     for (const auto &room : roomListModel) {
-        auto addRoom = [room, roomListModelSize = roomListModel.size(), message, model](
+        auto addRoom = [room, roomListModelSize = roomListModel.size(), message, state](
                          const QImage &image) {
             const auto aliases = cache::client()->getStateEvent<mtx::events::state::CanonicalAlias>(
               room->roomId().toStdString());
@@ -39,24 +61,30 @@ NhekoDBusBackend::rooms(const QDBusMessage &message)
                     alias = QString::fromStdString(val.alt_aliases.front());
             }
 
-            model->push_back(nheko::dbus::RoomInfoItem{
-              room->roomId(), alias, room->roomName(), image, room->notificationCount()});
+            std::lock_guard<std::mutex> childLock(state->m);
+            state->model.push_back(nheko::dbus::RoomInfoItem{
+              room->roomId(),
+              alias,
+              QString::fromStdString(state->roominfos[room->roomId()].name),
+              image,
+              room->notificationCount()});
 
-            if (model->length() == roomListModelSize) {
+            if (state->model.size() == roomListModelSize) {
+                nhlog::ui()->debug("Sending {} rooms over D-Bus...", state->model.size());
                 auto reply = message.createReply();
-                nhlog::ui()->debug("Sending {} rooms over D-Bus...", model->size());
-                reply << QVariant::fromValue(*model);
+                reply << QVariant::fromValue(state->model);
                 QDBusConnection::sessionBus().send(reply);
                 nhlog::ui()->debug("Rooms successfully sent to D-Bus.");
+            } else {
+                // nhlog::ui()->debug("DBUS: {}/{}", state->model.size(), roomListModelSize);
             }
         };
 
-        auto avatarUrl = room->roomAvatarUrl();
-        if (avatarUrl.isEmpty())
+        if (state->roominfos[room->roomId()].avatar_url.empty())
             addRoom(QImage());
         else
             MainWindow::instance()->imageProvider()->download(
-              avatarUrl.remove("mxc://"),
+              QString::fromStdString(state->roominfos[room->roomId()].avatar_url).remove("mxc://"),
               {96, 96},
               [addRoom](const QString &, const QSize &, const QImage &image, const QString &) {
                   addRoom(image);
diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index fe171deb..66bc8ef9 100644
--- a/src/timeline/InputBar.cpp
+++ b/src/timeline/InputBar.cpp
@@ -375,31 +375,37 @@ InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbow
     } else if (!room->reply().isEmpty()) {
         auto related = room->relatedInfo(room->reply());
 
-        QString body;
-        bool firstLine = true;
-        auto lines     = related.quoted_body.splitRef(u'\n');
-        for (auto line : qAsConst(lines)) {
-            if (firstLine) {
-                firstLine = false;
-                body      = QStringLiteral("> <%1> %2\n").arg(related.quoted_user, line);
-            } else {
-                body += QStringLiteral("> %1\n").arg(line);
+        // Skip reply fallbacks to users who would cause a room ping with the fallback.
+        // This should be fine, since in some cases the reply fallback can be omitted now and the
+        // alternative is worse! On Element Android this applies to any substring, but that is their
+        // bug to fix.
+        if (!related.quoted_user.startsWith("@room:")) {
+            QString body;
+            bool firstLine = true;
+            auto lines     = related.quoted_body.splitRef(u'\n');
+            for (auto line : qAsConst(lines)) {
+                if (firstLine) {
+                    firstLine = false;
+                    body      = QStringLiteral("> <%1> %2\n").arg(related.quoted_user, line);
+                } else {
+                    body += QStringLiteral("> %1\n").arg(line);
+                }
             }
-        }
 
-        text.body = QStringLiteral("%1\n%2").arg(body, msg).toStdString();
+            text.body = QStringLiteral("%1\n%2").arg(body, msg).toStdString();
 
-        // NOTE(Nico): rich replies always need a formatted_body!
-        text.format = "org.matrix.custom.html";
-        if ((ChatPage::instance()->userSettings()->markdown() &&
-             useMarkdown == MarkdownOverride::NOT_SPECIFIED) ||
-            useMarkdown == MarkdownOverride::ON)
-            text.formatted_body =
-              utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg, rainbowify))
-                .toStdString();
-        else
-            text.formatted_body =
-              utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
+            // NOTE(Nico): rich replies always need a formatted_body!
+            text.format = "org.matrix.custom.html";
+            if ((ChatPage::instance()->userSettings()->markdown() &&
+                 useMarkdown == MarkdownOverride::NOT_SPECIFIED) ||
+                useMarkdown == MarkdownOverride::ON)
+                text.formatted_body =
+                  utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg, rainbowify))
+                    .toStdString();
+            else
+                text.formatted_body =
+                  utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
+        }
 
         text.relations.relations.push_back(
           {mtx::common::RelationType::InReplyTo, related.related_event});
@@ -692,6 +698,12 @@ InputBar::command(const QString &command, QString args)
     } else if (command == QLatin1String("unban")) {
         ChatPage::instance()->unbanUser(
           room->roomId(), args.section(' ', 0, 0), args.section(' ', 1, -1));
+    } else if (command == QLatin1String("redact")) {
+        if (args.startsWith('@')) {
+            room->redactAllFromUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
+        } else if (args.startsWith('$')) {
+            room->redactEvent(args.section(' ', 0, 0), args.section(' ', 1, -1));
+        }
     } else if (command == QLatin1String("roomnick")) {
         mtx::events::state::Member member;
         member.display_name = args.toStdString();
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 1a9f957b..db56ac52 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -190,6 +190,12 @@ qml_mtx_events::toRoomEventType(mtx::events::EventType e)
         return qml_mtx_events::EventType::Sticker;
     case EventType::Tag:
         return qml_mtx_events::EventType::Tag;
+    case EventType::PolicyRuleUser:
+        return qml_mtx_events::EventType::PolicyRuleUser;
+    case EventType::PolicyRuleRoom:
+        return qml_mtx_events::EventType::PolicyRuleRoom;
+    case EventType::PolicyRuleServer:
+        return qml_mtx_events::EventType::PolicyRuleServer;
     case EventType::SpaceParent:
         return qml_mtx_events::EventType::SpaceParent;
     case EventType::SpaceChild:
@@ -303,6 +309,12 @@ qml_mtx_events::fromRoomEventType(qml_mtx_events::EventType t)
     // m.tag
     case qml_mtx_events::Tag:
         return mtx::events::EventType::Tag;
+    case qml_mtx_events::PolicyRuleUser:
+        return mtx::events::EventType::PolicyRuleUser;
+    case qml_mtx_events::PolicyRuleRoom:
+        return mtx::events::EventType::PolicyRuleRoom;
+    case qml_mtx_events::PolicyRuleServer:
+        return mtx::events::EventType::PolicyRuleServer;
     // m.space.parent
     case qml_mtx_events::SpaceParent:
         return mtx::events::EventType::SpaceParent;
@@ -1282,6 +1294,24 @@ TimelineModel::showReadReceipts(QString id)
 }
 
 void
+TimelineModel::redactAllFromUser(const QString &userid, const QString &reason)
+{
+    auto user = userid.toStdString();
+    std::vector<QString> toRedact;
+    for (auto it = events.size() - 1; it >= 0; --it) {
+        auto event = events.get(it, false);
+        if (event && mtx::accessors::sender(*event) == user &&
+            !std::holds_alternative<mtx::events::RoomEvent<mtx::events::msg::Redacted>>(*event)) {
+            toRedact.push_back(QString::fromStdString(mtx::accessors::event_id(*event)));
+        }
+    }
+
+    for (const auto &e : toRedact) {
+        redactEvent(e, reason);
+        std::this_thread::sleep_for(std::chrono::milliseconds(50));
+    }
+}
+void
 TimelineModel::redactEvent(const QString &id, const QString &reason)
 {
     if (!id.isEmpty()) {
@@ -2321,6 +2351,62 @@ TimelineModel::formatImagePackEvent(const QString &id)
         return msg;
 }
 
+QString
+TimelineModel::formatPolicyRule(const QString &id)
+{
+    mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
+    if (!e)
+        return {};
+
+    auto qsHtml = [](const std::string &s) { return QString::fromStdString(s).toHtmlEscaped(); };
+    constexpr std::string_view unstable_ban = "org.matrix.mjolnir.ban";
+
+    if (auto userRule =
+          std::get_if<mtx::events::StateEvent<mtx::events::state::policy_rule::UserRule>>(e)) {
+        auto sender = utils::replaceEmoji(displayName(QString::fromStdString(userRule->sender)));
+        if (userRule->content.entity.empty() ||
+            (userRule->content.recommendation !=
+               mtx::events::state::policy_rule::recommendation::ban &&
+             userRule->content.recommendation != unstable_ban)) {
+            return tr("%1 disabled the rule to ban users matching %2.")
+              .arg(sender, qsHtml(userRule->content.entity));
+        } else {
+            return tr("%1 added a rule to ban users matching %2 for '%3'.")
+              .arg(sender, qsHtml(userRule->content.entity), qsHtml(userRule->content.reason));
+        }
+    } else if (auto roomRule =
+                 std::get_if<mtx::events::StateEvent<mtx::events::state::policy_rule::RoomRule>>(
+                   e)) {
+        auto sender = utils::replaceEmoji(displayName(QString::fromStdString(roomRule->sender)));
+        if (roomRule->content.entity.empty() ||
+            (roomRule->content.recommendation !=
+               mtx::events::state::policy_rule::recommendation::ban &&
+             roomRule->content.recommendation != unstable_ban)) {
+            return tr("%1 disabled the rule to ban rooms matching %2.")
+              .arg(sender, qsHtml(roomRule->content.entity));
+        } else {
+            return tr("%1 added a rule to ban rooms matching %2 for '%3'.")
+              .arg(sender, qsHtml(roomRule->content.entity), qsHtml(roomRule->content.reason));
+        }
+    } else if (auto serverRule =
+                 std::get_if<mtx::events::StateEvent<mtx::events::state::policy_rule::ServerRule>>(
+                   e)) {
+        auto sender = utils::replaceEmoji(displayName(QString::fromStdString(serverRule->sender)));
+        if (serverRule->content.entity.empty() ||
+            (serverRule->content.recommendation !=
+               mtx::events::state::policy_rule::recommendation::ban &&
+             serverRule->content.recommendation != unstable_ban)) {
+            return tr("%1 disabled the rule to ban servers matching %2.")
+              .arg(sender, qsHtml(serverRule->content.entity));
+        } else {
+            return tr("%1 added a rule to ban servers matching %2 for '%3'.")
+              .arg(sender, qsHtml(serverRule->content.entity), qsHtml(serverRule->content.reason));
+        }
+    }
+
+    return {};
+}
+
 QVariantMap
 TimelineModel::formatRedactedEvent(const QString &id)
 {
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index 3b954394..6d424981 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -116,6 +116,12 @@ enum EventType
     ImagePackInAccountData,
     //! m.image_pack.rooms, currently im.ponies.emote_rooms
     ImagePackRooms,
+    // m.policy.rule.user
+    PolicyRuleUser,
+    // m.policy.rule.room
+    PolicyRuleRoom,
+    // m.policy.rule.server
+    PolicyRuleServer,
     // m.space.parent
     SpaceParent,
     // m.space.child
@@ -264,6 +270,7 @@ public:
     Q_INVOKABLE QString formatGuestAccessEvent(const QString &id);
     Q_INVOKABLE QString formatPowerLevelEvent(const QString &id);
     Q_INVOKABLE QString formatImagePackEvent(const QString &id);
+    Q_INVOKABLE QString formatPolicyRule(const QString &id);
     Q_INVOKABLE QVariantMap formatRedactedEvent(const QString &id);
 
     Q_INVOKABLE void viewRawMessage(const QString &id);
@@ -276,6 +283,7 @@ public:
     Q_INVOKABLE void pin(const QString &id);
     Q_INVOKABLE void showReadReceipts(QString id);
     Q_INVOKABLE void redactEvent(const QString &id, const QString &reason = "");
+    Q_INVOKABLE void redactAllFromUser(const QString &userid, const QString &reason = "");
     Q_INVOKABLE int idToIndex(const QString &id) const;
     Q_INVOKABLE QString indexToId(int index) const;
     Q_INVOKABLE void openMedia(const QString &eventId);
diff --git a/src/ui/NhekoGlobalObject.h b/src/ui/NhekoGlobalObject.h
index bd141f35..f9de489d 100644
--- a/src/ui/NhekoGlobalObject.h
+++ b/src/ui/NhekoGlobalObject.h
@@ -9,6 +9,7 @@
 #include <QObject>
 #include <QPalette>
 
+#include "AliasEditModel.h"
 #include "PowerlevelsEditModels.h"
 #include "Theme.h"
 #include "UserProfile.h"
@@ -59,6 +60,10 @@ public:
     {
         return new PowerlevelEditingModels(room_id_);
     }
+    Q_INVOKABLE AliasEditingModel *editAliases(QString room_id_) const
+    {
+        return new AliasEditingModel(room_id_.toStdString());
+    }
 
 public slots:
     void updateUserProfile();