summary refs log tree commit diff
path: root/src/timeline
diff options
context:
space:
mode:
authorNicolas Werner <nicolas.werner@hotmail.de>2023-10-10 00:33:39 +0200
committerNicolas Werner <nicolas.werner@hotmail.de>2023-10-10 00:33:39 +0200
commit3a0d5788e1fc49816742ddd4799ad0d658925ba2 (patch)
tree55686a2e0bd354260ebd8c17fedb755b5d0d131c /src/timeline
parentTranslated using Weblate (French) (diff)
parentRestore reply context menu (diff)
downloadnheko-3a0d5788e1fc49816742ddd4799ad0d658925ba2.tar.xz
Merge branch 'delegate-rework'
Diffstat (limited to 'src/timeline')
-rw-r--r--src/timeline/EventDelegateChooser.cpp356
-rw-r--r--src/timeline/EventDelegateChooser.h276
-rw-r--r--src/timeline/EventStore.cpp4
-rw-r--r--src/timeline/RoomlistModel.cpp2
-rw-r--r--src/timeline/TimelineModel.cpp297
-rw-r--r--src/timeline/TimelineModel.h35
6 files changed, 838 insertions, 132 deletions
diff --git a/src/timeline/EventDelegateChooser.cpp b/src/timeline/EventDelegateChooser.cpp
new file mode 100644
index 00000000..4367bb9c
--- /dev/null
+++ b/src/timeline/EventDelegateChooser.cpp
@@ -0,0 +1,356 @@
+// SPDX-FileCopyrightText: Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "EventDelegateChooser.h"
+#include "TimelineModel.h"
+
+#include "Logging.h"
+
+#include <QQmlEngine>
+#include <QtGlobal>
+
+#include <ranges>
+
+// privat qt headers to access required properties
+#include <QtQml/private/qqmlincubator_p.h>
+#include <QtQml/private/qqmlobjectcreator_p.h>
+
+QQmlComponent *
+EventDelegateChoice::delegate() const
+{
+    return delegate_;
+}
+
+void
+EventDelegateChoice::setDelegate(QQmlComponent *delegate)
+{
+    if (delegate != delegate_) {
+        delegate_ = delegate;
+        emit delegateChanged();
+        emit changed();
+    }
+}
+
+QList<int>
+EventDelegateChoice::roleValues() const
+{
+    return roleValues_;
+}
+
+void
+EventDelegateChoice::setRoleValues(const QList<int> &value)
+{
+    if (value != roleValues_) {
+        roleValues_ = value;
+        emit roleValuesChanged();
+        emit changed();
+    }
+}
+
+QQmlListProperty<EventDelegateChoice>
+EventDelegateChooser::choices()
+{
+    return QQmlListProperty<EventDelegateChoice>(this,
+                                                 this,
+                                                 &EventDelegateChooser::appendChoice,
+                                                 &EventDelegateChooser::choiceCount,
+                                                 &EventDelegateChooser::choice,
+                                                 &EventDelegateChooser::clearChoices);
+}
+
+void
+EventDelegateChooser::appendChoice(QQmlListProperty<EventDelegateChoice> *p, EventDelegateChoice *c)
+{
+    EventDelegateChooser *dc = static_cast<EventDelegateChooser *>(p->object);
+    dc->choices_.append(c);
+}
+
+qsizetype
+EventDelegateChooser::choiceCount(QQmlListProperty<EventDelegateChoice> *p)
+{
+    return static_cast<EventDelegateChooser *>(p->object)->choices_.count();
+}
+EventDelegateChoice *
+EventDelegateChooser::choice(QQmlListProperty<EventDelegateChoice> *p, qsizetype index)
+{
+    return static_cast<EventDelegateChooser *>(p->object)->choices_.at(index);
+}
+void
+EventDelegateChooser::clearChoices(QQmlListProperty<EventDelegateChoice> *p)
+{
+    static_cast<EventDelegateChooser *>(p->object)->choices_.clear();
+}
+
+void
+EventDelegateChooser::componentComplete()
+{
+    QQuickItem::componentComplete();
+    eventIncubator.reset(eventId_);
+    replyIncubator.reset(replyId);
+    // eventIncubator.forceCompletion();
+}
+
+void
+EventDelegateChooser::DelegateIncubator::setInitialState(QObject *obj)
+{
+    auto item = qobject_cast<QQuickItem *>(obj);
+    if (!item)
+        return;
+
+    item->setParentItem(&chooser);
+    item->setParent(&chooser);
+
+    auto roleNames = chooser.room_->roleNames();
+    QHash<QByteArray, int> nameToRole;
+    for (const auto &[k, v] : roleNames.asKeyValueRange()) {
+        nameToRole.insert(v, k);
+    }
+
+    QHash<int, int> roleToPropIdx;
+    std::vector<QModelRoleData> roles;
+    // Workaround for https://bugreports.qt.io/browse/QTBUG-98846
+    QHash<QString, RequiredPropertyKey> requiredProperties;
+    for (const auto &[propKey, prop] :
+         QQmlIncubatorPrivate::get(this)->requiredProperties()->asKeyValueRange()) {
+        requiredProperties.insert(prop.propertyName, propKey);
+    }
+
+    // collect required properties
+    auto mo = obj->metaObject();
+    for (int i = 0; i < mo->propertyCount(); i++) {
+        auto prop = mo->property(i);
+        // nhlog::ui()->critical("Found prop {}", prop.name());
+        //  See https://bugreports.qt.io/browse/QTBUG-98846
+        if (!prop.isRequired() && !requiredProperties.contains(prop.name()))
+            continue;
+
+        if (auto role = nameToRole.find(prop.name()); role != nameToRole.end()) {
+            roleToPropIdx.insert(*role, i);
+            roles.emplace_back(*role);
+
+            // nhlog::ui()->critical("Found prop {}, idx {}, role {}", prop.name(), i, *role);
+        } else {
+            nhlog::ui()->critical("Required property {} not found in model!", prop.name());
+        }
+    }
+
+    // nhlog::ui()->debug("Querying data for id {}", currentId.toStdString());
+    chooser.room_->multiData(currentId, forReply ? chooser.eventId_ : QString(), roles);
+
+    Qt::beginPropertyUpdateGroup();
+    auto attached = qobject_cast<EventDelegateChooserAttachedType *>(
+      qmlAttachedPropertiesObject<EventDelegateChooser>(obj));
+    Q_ASSERT(attached != nullptr);
+    attached->setIsReply(this->forReply);
+
+    for (const auto &role : roles) {
+        const auto &roleName = roleNames[role.role()];
+        // nhlog::ui()->critical("Setting role {}, {} to {}",
+        //                       role.role(),
+        //                       roleName.toStdString(),
+        //                       role.data().toString().toStdString());
+
+        // nhlog::ui()->critical("Setting {}", mo->property(roleToPropIdx[role.role()]).name());
+        mo->property(roleToPropIdx[role.role()]).write(obj, role.data());
+
+        if (const auto &req = requiredProperties.find(roleName); req != requiredProperties.end())
+            QQmlIncubatorPrivate::get(this)->requiredProperties()->remove(*req);
+    }
+
+    Qt::endPropertyUpdateGroup();
+
+    // setInitialProperties(rolesToSet);
+
+    auto update =
+      [this, obj, roleToPropIdx = std::move(roleToPropIdx)](const QList<int> &changedRoles) {
+          if (changedRoles.empty() || changedRoles.contains(TimelineModel::Roles::Type)) {
+              int type = chooser.room_
+                           ->dataById(currentId,
+                                      TimelineModel::Roles::Type,
+                                      forReply ? chooser.eventId_ : QString())
+                           .toInt();
+              if (type != oldType) {
+                  // nhlog::ui()->debug("Type changed!");
+                  reset(currentId);
+                  return;
+              }
+          }
+
+          std::vector<QModelRoleData> rolesToRequest;
+
+          if (changedRoles.empty()) {
+              for (const auto role :
+                   std::ranges::subrange(roleToPropIdx.keyBegin(), roleToPropIdx.keyEnd()))
+                  rolesToRequest.emplace_back(role);
+          } else {
+              for (auto role : changedRoles) {
+                  if (roleToPropIdx.contains(role)) {
+                      rolesToRequest.emplace_back(role);
+                  }
+              }
+          }
+
+          if (rolesToRequest.empty())
+              return;
+
+          auto mo = obj->metaObject();
+          chooser.room_->multiData(
+            currentId, forReply ? chooser.eventId_ : QString(), rolesToRequest);
+
+          Qt::beginPropertyUpdateGroup();
+          for (const auto &role : rolesToRequest) {
+              mo->property(roleToPropIdx[role.role()]).write(obj, role.data());
+          }
+          Qt::endPropertyUpdateGroup();
+      };
+
+    if (!forReply) {
+        auto row        = chooser.room_->idToIndex(currentId);
+        auto connection = connect(
+          chooser.room_,
+          &QAbstractItemModel::dataChanged,
+          obj,
+          [row, update](const QModelIndex &topLeft,
+                        const QModelIndex &bottomRight,
+                        const QList<int> &changedRoles) {
+              if (row < topLeft.row() || row > bottomRight.row())
+                  return;
+
+              update(changedRoles);
+          },
+          Qt::QueuedConnection);
+        connect(&this->chooser, &EventDelegateChooser::destroyed, obj, [connection]() {
+            QObject::disconnect(connection);
+        });
+    }
+}
+
+void
+EventDelegateChooser::DelegateIncubator::reset(QString id)
+{
+    if (!chooser.room_ || id.isEmpty())
+        return;
+
+    // nhlog::ui()->debug("Reset with id {}, reply {}", id.toStdString(), forReply);
+
+    this->currentId = id;
+
+    auto role =
+      chooser.room_
+        ->dataById(id, TimelineModel::Roles::Type, forReply ? chooser.eventId_ : QString())
+        .toInt();
+    this->oldType = role;
+
+    for (const auto choice : qAsConst(chooser.choices_)) {
+        const auto &choiceValue = choice->roleValues();
+        if (choiceValue.contains(role) || choiceValue.empty()) {
+            // nhlog::ui()->debug(
+            //   "Instantiating type: {}, c {}", (int)role, choiceValue.contains(role));
+
+            if (auto child = qobject_cast<QQuickItem *>(object())) {
+                child->setParentItem(nullptr);
+            }
+
+            choice->delegate()->create(*this, QQmlEngine::contextForObject(&chooser));
+            return;
+        }
+    }
+}
+
+void
+EventDelegateChooser::DelegateIncubator::statusChanged(QQmlIncubator::Status status)
+{
+    if (status == QQmlIncubator::Ready) {
+        auto child = qobject_cast<QQuickItem *>(object());
+        if (child == nullptr) {
+            nhlog::ui()->error("Delegate has to be derived of Item!");
+            return;
+        }
+
+        child->setParentItem(&chooser);
+        QQmlEngine::setObjectOwnership(child, QQmlEngine::ObjectOwnership::JavaScriptOwnership);
+
+        // connect(child, &QQuickItem::parentChanged, child, [child](QQuickItem *) {
+        //     // QTBUG-115687
+        //     if (child->flags().testFlag(QQuickItem::ItemObservesViewport)) {
+        //         nhlog::ui()->critical("SETTING OBSERVES VIEWPORT");
+        //         // Re-trigger the parent traversal to get subtreeTransformChangedEnabled turned
+        //         on child->setFlag(QQuickItem::ItemObservesViewport);
+        //     }
+        // });
+
+        if (forReply)
+            emit chooser.replyChanged();
+        else
+            emit chooser.mainChanged();
+
+        chooser.polish();
+    } else if (status == QQmlIncubator::Error) {
+        auto errors_ = errors();
+        for (const auto &e : qAsConst(errors_))
+            nhlog::ui()->error("Error instantiating delegate: {}", e.toString().toStdString());
+    }
+}
+
+void
+EventDelegateChooser::updatePolish()
+{
+    auto mainChild  = qobject_cast<QQuickItem *>(eventIncubator.object());
+    auto replyChild = qobject_cast<QQuickItem *>(replyIncubator.object());
+
+    // nhlog::ui()->trace("POLISHING {}", (void *)this);
+
+    auto layoutItem = [this](QQuickItem *item, int inset) {
+        if (item) {
+            auto attached = qobject_cast<EventDelegateChooserAttachedType *>(
+              qmlAttachedPropertiesObject<EventDelegateChooser>(item));
+            Q_ASSERT(attached != nullptr);
+
+            int maxWidth = maxWidth_ - inset;
+
+            // in theory we could also reset the width, but that doesn't seem to work nicely for
+            // text areas because of how they cache it.
+            if (attached->maxWidth() > 0)
+                item->setWidth(attached->maxWidth());
+            else
+                item->setWidth(maxWidth);
+            item->ensurePolished();
+            auto width = item->implicitWidth();
+
+            if (width < 1 || width > maxWidth)
+                width = maxWidth;
+
+            if (attached->maxWidth() > 0 && width > attached->maxWidth())
+                width = attached->maxWidth();
+
+            if (attached->keepAspectRatio()) {
+                auto height = width * attached->aspectRatio();
+                if (attached->maxHeight() && height > attached->maxHeight()) {
+                    height = attached->maxHeight();
+                    width  = height / attached->aspectRatio();
+                }
+
+                item->setHeight(height);
+            }
+
+            item->setWidth(width);
+            item->ensurePolished();
+        }
+    };
+
+    layoutItem(mainChild, mainInset_);
+    layoutItem(replyChild, replyInset_);
+}
+
+void
+EventDelegateChooserAttachedType::polishChooser()
+{
+    auto p = parent();
+    if (p) {
+        auto chooser = qobject_cast<EventDelegateChooser *>(p->parent());
+        if (chooser) {
+            chooser->polish();
+        }
+    }
+}
diff --git a/src/timeline/EventDelegateChooser.h b/src/timeline/EventDelegateChooser.h
new file mode 100644
index 00000000..df1953ab
--- /dev/null
+++ b/src/timeline/EventDelegateChooser.h
@@ -0,0 +1,276 @@
+// SPDX-FileCopyrightText: Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <QAbstractItemModel>
+#include <QQmlComponent>
+#include <QQmlIncubator>
+#include <QQmlListProperty>
+#include <QQuickItem>
+#include <QtCore/QObject>
+#include <QtCore/QVariant>
+
+#include "TimelineModel.h"
+
+class EventDelegateChooserAttachedType : public QObject
+{
+    Q_OBJECT
+    Q_PROPERTY(bool keepAspectRatio READ keepAspectRatio WRITE setKeepAspectRatio NOTIFY
+                 keepAspectRatioChanged)
+    Q_PROPERTY(double aspectRatio READ aspectRatio WRITE setAspectRatio NOTIFY aspectRatioChanged)
+    Q_PROPERTY(int maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged)
+    Q_PROPERTY(int maxHeight READ maxHeight WRITE setMaxHeight NOTIFY maxHeightChanged)
+    Q_PROPERTY(bool isReply READ isReply WRITE setIsReply NOTIFY isReplyChanged)
+
+    QML_ANONYMOUS
+public:
+    EventDelegateChooserAttachedType(QObject *parent)
+      : QObject(parent)
+    {
+    }
+
+    bool keepAspectRatio() const { return keepAspectRatio_; }
+    void setKeepAspectRatio(bool fill)
+    {
+        if (fill != keepAspectRatio_) {
+            keepAspectRatio_ = fill;
+            emit keepAspectRatioChanged();
+            polishChooser();
+        }
+    }
+
+    double aspectRatio() const { return aspectRatio_; }
+    void setAspectRatio(double fill)
+    {
+        aspectRatio_ = fill;
+        emit aspectRatioChanged();
+        polishChooser();
+    }
+
+    int maxWidth() const { return maxWidth_; }
+    void setMaxWidth(int fill)
+    {
+        maxWidth_ = fill;
+        emit maxWidthChanged();
+        polishChooser();
+    }
+
+    int maxHeight() const { return maxHeight_; }
+    void setMaxHeight(int fill)
+    {
+        maxHeight_ = fill;
+        emit maxHeightChanged();
+    }
+
+    bool isReply() const { return isReply_; }
+    void setIsReply(bool fill)
+    {
+        if (fill != isReply_) {
+            isReply_ = fill;
+            emit isReplyChanged();
+            polishChooser();
+        }
+    }
+
+signals:
+    void keepAspectRatioChanged();
+    void aspectRatioChanged();
+    void maxWidthChanged();
+    void maxHeightChanged();
+    void isReplyChanged();
+
+private:
+    void polishChooser();
+
+    double aspectRatio_   = 1.;
+    int maxWidth_         = -1;
+    int maxHeight_        = -1;
+    bool keepAspectRatio_ = false;
+    bool isReply_         = false;
+};
+
+class EventDelegateChoice : public QObject
+{
+    Q_OBJECT
+    QML_ELEMENT
+    Q_CLASSINFO("DefaultProperty", "delegate")
+
+public:
+    Q_PROPERTY(QList<int> roleValues READ roleValues WRITE setRoleValues NOTIFY roleValuesChanged
+                 REQUIRED FINAL)
+    Q_PROPERTY(
+      QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY delegateChanged REQUIRED FINAL)
+
+    [[nodiscard]] QQmlComponent *delegate() const;
+    void setDelegate(QQmlComponent *delegate);
+
+    [[nodiscard]] QList<int> roleValues() const;
+    void setRoleValues(const QList<int> &value);
+
+signals:
+    void delegateChanged();
+    void roleValuesChanged();
+    void changed();
+
+private:
+    QList<int> roleValues_;
+    QQmlComponent *delegate_ = nullptr;
+};
+
+class EventDelegateChooser : public QQuickItem
+{
+    Q_OBJECT
+    QML_ELEMENT
+    Q_CLASSINFO("DefaultProperty", "choices")
+
+    QML_ATTACHED(EventDelegateChooserAttachedType)
+
+    Q_PROPERTY(QQmlListProperty<EventDelegateChoice> choices READ choices CONSTANT FINAL)
+    Q_PROPERTY(QQuickItem *main READ main NOTIFY mainChanged FINAL)
+    Q_PROPERTY(QQuickItem *reply READ reply NOTIFY replyChanged FINAL)
+    Q_PROPERTY(QString eventId READ eventId WRITE setEventId NOTIFY eventIdChanged REQUIRED FINAL)
+    Q_PROPERTY(QString replyTo READ replyTo WRITE setReplyTo NOTIFY replyToChanged REQUIRED FINAL)
+    Q_PROPERTY(TimelineModel *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED FINAL)
+    Q_PROPERTY(bool sameWidth READ sameWidth WRITE setSameWidth NOTIFY sameWidthChanged)
+    Q_PROPERTY(int maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged)
+    Q_PROPERTY(int replyInset READ replyInset WRITE setReplyInset NOTIFY replyInsetChanged)
+    Q_PROPERTY(int mainInset READ mainInset WRITE setMainInset NOTIFY mainInsetChanged)
+
+public:
+    QQmlListProperty<EventDelegateChoice> choices();
+
+    [[nodiscard]] QQuickItem *main() const
+    {
+        return qobject_cast<QQuickItem *>(eventIncubator.object());
+    }
+    [[nodiscard]] QQuickItem *reply() const
+    {
+        return qobject_cast<QQuickItem *>(replyIncubator.object());
+    }
+
+    bool sameWidth() const { return sameWidth_; }
+    void setSameWidth(bool width)
+    {
+        sameWidth_ = width;
+        emit sameWidthChanged();
+    }
+    int maxWidth() const { return maxWidth_; }
+    void setMaxWidth(int width)
+    {
+        maxWidth_ = width;
+        emit maxWidthChanged();
+        polish();
+    }
+
+    int replyInset() const { return replyInset_; }
+    void setReplyInset(int width)
+    {
+        replyInset_ = width;
+        emit replyInsetChanged();
+        polish();
+    }
+
+    int mainInset() const { return mainInset_; }
+    void setMainInset(int width)
+    {
+        mainInset_ = width;
+        emit mainInsetChanged();
+        polish();
+    }
+
+    void setRoom(TimelineModel *m)
+    {
+        if (m != room_) {
+            room_ = m;
+            emit roomChanged();
+
+            if (isComponentComplete()) {
+                eventIncubator.reset(eventId_);
+                replyIncubator.reset(replyId);
+            }
+        }
+    }
+    [[nodiscard]] TimelineModel *room() { return room_; }
+
+    void setEventId(QString idx)
+    {
+        eventId_ = idx;
+        emit eventIdChanged();
+
+        if (isComponentComplete())
+            eventIncubator.reset(eventId_);
+    }
+    [[nodiscard]] QString eventId() const { return eventId_; }
+    void setReplyTo(QString id)
+    {
+        replyId = id;
+        emit replyToChanged();
+
+        if (isComponentComplete())
+            replyIncubator.reset(replyId);
+    }
+    [[nodiscard]] QString replyTo() const { return replyId; }
+
+    void componentComplete() override;
+
+    static EventDelegateChooserAttachedType *qmlAttachedProperties(QObject *object)
+    {
+        return new EventDelegateChooserAttachedType(object);
+    }
+
+    void updatePolish() override;
+
+signals:
+    void mainChanged();
+    void replyChanged();
+    void roomChanged();
+    void eventIdChanged();
+    void replyToChanged();
+    void sameWidthChanged();
+    void maxWidthChanged();
+    void replyInsetChanged();
+    void mainInsetChanged();
+
+private:
+    struct DelegateIncubator final : public QQmlIncubator
+    {
+        DelegateIncubator(EventDelegateChooser &parent, bool forReply)
+          : QQmlIncubator(QQmlIncubator::AsynchronousIfNested)
+          , chooser(parent)
+          , forReply(forReply)
+        {
+        }
+        void setInitialState(QObject *object) override;
+        void statusChanged(QQmlIncubator::Status status) override;
+
+        void reset(QString id);
+
+        EventDelegateChooser &chooser;
+        bool forReply;
+        QString currentId;
+
+        QString instantiatedId;
+        int instantiatedRole                  = -1;
+        QAbstractItemModel *instantiatedModel = nullptr;
+        int oldType                           = -1;
+    };
+
+    QVariant roleValue_;
+    QList<EventDelegateChoice *> choices_;
+    DelegateIncubator eventIncubator{*this, false};
+    DelegateIncubator replyIncubator{*this, true};
+    TimelineModel *room_{nullptr};
+    QString eventId_;
+    QString replyId;
+    bool sameWidth_ = false;
+    int maxWidth_   = 400;
+    int replyInset_ = 0;
+    int mainInset_  = 0;
+
+    static void appendChoice(QQmlListProperty<EventDelegateChoice> *, EventDelegateChoice *);
+    static qsizetype choiceCount(QQmlListProperty<EventDelegateChoice> *);
+    static EventDelegateChoice *choice(QQmlListProperty<EventDelegateChoice> *, qsizetype index);
+    static void clearChoices(QQmlListProperty<EventDelegateChoice> *);
+};
diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp
index 63b67474..e29dfb4c 100644
--- a/src/timeline/EventStore.cpp
+++ b/src/timeline/EventStore.cpp
@@ -843,8 +843,8 @@ EventStore::get(const std::string &id,
                                               nhlog::net()->error(
                                                 "Failed to retrieve event with id {}, which was "
                                                 "requested to show the replyTo for event {}",
-                                                relatedTo,
-                                                id);
+                                                id,
+                                                relatedTo);
                                               return;
                                           }
                                           emit eventFetched(id, relatedTo, timeline);
diff --git a/src/timeline/RoomlistModel.cpp b/src/timeline/RoomlistModel.cpp
index 8d8d2977..2bffc9be 100644
--- a/src/timeline/RoomlistModel.cpp
+++ b/src/timeline/RoomlistModel.cpp
@@ -541,7 +541,7 @@ RoomlistModel::sync(const mtx::responses::Sync &sync_)
                 if (auto t =
                       std::get_if<mtx::events::EphemeralEvent<mtx::events::ephemeral::Typing>>(
                         &ev)) {
-                    std::vector<QString> typing;
+                    QStringList typing;
                     typing.reserve(t->content.user_ids.size());
                     for (const auto &user : t->content.user_ids) {
                         if (user != http::client()->user_id().to_string())
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index b2a036c5..e8b5d40e 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -532,6 +532,7 @@ TimelineModel::roleNames() const
       {IsOnlyEmoji, "isOnlyEmoji"},
       {Body, "body"},
       {FormattedBody, "formattedBody"},
+      {FormattedStateEvent, "formattedStateEvent"},
       {IsSender, "isSender"},
       {UserId, "userId"},
       {UserName, "userName"},
@@ -560,6 +561,7 @@ TimelineModel::roleNames() const
       {ReplyTo, "replyTo"},
       {ThreadId, "threadId"},
       {Reactions, "reactions"},
+      {Room, "room"},
       {RoomId, "roomId"},
       {RoomName, "roomName"},
       {RoomTopic, "roomTopic"},
@@ -599,12 +601,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<qlonglong>(mtx::events::state::PowerLevels{
-          cache::client()
-            ->getStateEvent<mtx::events::state::PowerLevels>(room_id_.toStdString())
-            .value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{})
-            .content}
-                                        .user_level(acc::sender(event)));
+        return static_cast<qlonglong>(
+          permissions_.powerlevelEvent().user_level(acc::sender(event)));
     }
 
     case Day: {
@@ -692,8 +690,90 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
             formattedBody_.replace(curImg, imgReplacement);
         }
 
+        if (auto effectMessage =
+              std::get_if<mtx::events::RoomEvent<mtx::events::msg::ElementEffect>>(&event)) {
+            if (effectMessage->content.msgtype == std::string_view("nic.custom.confetti")) {
+                formattedBody_.append(QUtf8StringView(u8"🎊"));
+            } else if (effectMessage->content.msgtype ==
+                       std::string_view("io.element.effect.rainfall")) {
+                formattedBody_.append(QUtf8StringView(u8"🌧️"));
+            }
+        }
+
         return QVariant(utils::replaceEmoji(utils::linkifyMessage(formattedBody_)));
     }
+    case FormattedStateEvent: {
+        if (mtx::accessors::is_state_event(event)) {
+            return std::visit(
+              [this](const auto &e) {
+                  constexpr auto t = mtx::events::state_content_to_type<decltype(e.content)>;
+                  if constexpr (t == mtx::events::EventType::RoomServerAcl)
+                      return tr("%1 changed which servers are allowed in this room.")
+                        .arg(displayName(QString::fromStdString(e.sender)));
+                  else if constexpr (t == mtx::events::EventType::RoomName) {
+                      if (e.content.name.empty())
+                          return tr("%1 removed the room name.")
+                            .arg(displayName(QString::fromStdString(e.sender)));
+                      else
+                          return tr("%1 changed the room name to: %2")
+                            .arg(displayName(QString::fromStdString(e.sender)))
+                            .arg(QString::fromStdString(e.content.name).toHtmlEscaped());
+                  } else if constexpr (t == mtx::events::EventType::RoomTopic) {
+                      if (e.content.topic.empty())
+                          return tr("%1 removed the topic.")
+                            .arg(displayName(QString::fromStdString(e.sender)));
+                      else
+                          return tr("%1 changed the topic to: %2")
+                            .arg(displayName(QString::fromStdString(e.sender)))
+                            .arg(QString::fromStdString(e.content.topic).toHtmlEscaped());
+                  } else if constexpr (t == mtx::events::EventType::RoomAvatar) {
+                      if (e.content.url.starts_with("mxc://"))
+                          return tr("%1 changed the room avatar to: %2")
+                            .arg(displayName(QString::fromStdString(e.sender)))
+                            .arg(QStringLiteral("<img height=\"32\" src=\"%1\">")
+                                   .arg(QUrl::toPercentEncoding(
+                                     QString::fromStdString(e.content.url))));
+                      else
+                          return tr("%1 removed the room avatar.")
+                            .arg(displayName(QString::fromStdString(e.sender)));
+                  } else if constexpr (t == mtx::events::EventType::RoomPinnedEvents)
+                      return tr("%1 changed the pinned messages.")
+                        .arg(displayName(QString::fromStdString(e.sender)));
+                  else if constexpr (t == mtx::events::EventType::ImagePackInRoom)
+                      formatImagePackEvent(e);
+                  else if constexpr (t == mtx::events::EventType::RoomCanonicalAlias)
+                      return tr("%1 changed the addresses for this room.")
+                        .arg(displayName(QString::fromStdString(e.sender)));
+                  else if constexpr (t == mtx::events::EventType::SpaceParent)
+                      return tr("%1 changed the parent communities for this room.")
+                        .arg(displayName(QString::fromStdString(e.sender)));
+                  else if constexpr (t == mtx::events::EventType::RoomCreate)
+                      return tr("%1 created and configured room: %2")
+                        .arg(displayName(QString::fromStdString(e.sender)))
+                        .arg(room_id_);
+                  else if constexpr (t == mtx::events::EventType::RoomPowerLevels)
+                      return formatPowerLevelEvent(e);
+                  else if constexpr (t == mtx::events::EventType::PolicyRuleRoom)
+                      return formatPolicyRule(QString::fromStdString(e.event_id));
+                  else if constexpr (t == mtx::events::EventType::PolicyRuleUser)
+                      return formatPolicyRule(QString::fromStdString(e.event_id));
+                  else if constexpr (t == mtx::events::EventType::PolicyRuleServer)
+                      return formatPolicyRule(QString::fromStdString(e.event_id));
+                  else if constexpr (t == mtx::events::EventType::RoomHistoryVisibility)
+                      return formatHistoryVisibilityEvent(e);
+                  else if constexpr (t == mtx::events::EventType::RoomGuestAccess)
+                      return formatGuestAccessEvent(e);
+                  else if constexpr (t == mtx::events::EventType::RoomMember)
+                      return formatMemberEvent(e);
+
+                  return tr("%1 changed unknown state event %2.")
+                    .arg(displayName(QString::fromStdString(e.sender)))
+                    .arg(QString::fromStdString(to_string(e.type)));
+              },
+              event);
+        }
+        return QString();
+    }
     case Url:
         return QVariant(QString::fromStdString(url(event)));
     case ThumbnailUrl:
@@ -828,6 +908,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
         auto id = relations(event).replaces().value_or(event_id(event));
         return QVariant::fromValue(events.reactions(id));
     }
+    case Room:
+        return QVariant::fromValue(this);
     case RoomId:
         return QVariant(room_id_);
     case RoomName:
@@ -926,6 +1008,26 @@ TimelineModel::multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSp
     }
 }
 
+void
+TimelineModel::multiData(const QString &id,
+                         const QString &relatedTo,
+                         QModelRoleDataSpan roleDataSpan) const
+{
+    if (id.isEmpty())
+        return;
+
+    auto event = events.get(id.toStdString(), relatedTo.toStdString());
+
+    if (!event)
+        return;
+
+    for (QModelRoleData &roleData : roleDataSpan) {
+        int role = roleData.role();
+
+        roleData.setData(data(*event, role));
+    }
+}
+
 QVariant
 TimelineModel::dataById(const QString &id, int role, const QString &relatedTo)
 {
@@ -2196,7 +2298,7 @@ TimelineModel::markSpecialEffectsDone()
 }
 
 QString
-TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor &bg)
+TimelineModel::formatTypingUsers(const QStringList &users, const QColor &bg)
 {
     QString temp =
       tr("%1 and %2 are typing.",
@@ -2243,7 +2345,7 @@ TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor
     };
 
     uidWithoutLast.reserve(static_cast<int>(users.size()));
-    for (size_t i = 0; i + 1 < users.size(); i++) {
+    for (qsizetype i = 0; i + 1 < users.size(); i++) {
         uidWithoutLast.append(formatUser(users[i]));
     }
 
@@ -2288,20 +2390,13 @@ TimelineModel::formatJoinRuleEvent(const QString &id)
 }
 
 QString
-TimelineModel::formatGuestAccessEvent(const QString &id)
+TimelineModel::formatGuestAccessEvent(
+  const mtx::events::StateEvent<mtx::events::state::GuestAccess> &event) const
 {
-    auto e = events.get(id.toStdString(), "");
-    if (!e)
-        return {};
-
-    auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(e);
-    if (!event)
-        return {};
-
-    QString user = QString::fromStdString(event->sender);
+    QString user = QString::fromStdString(event.sender);
     QString name = utils::replaceEmoji(displayName(user));
 
-    switch (event->content.guest_access) {
+    switch (event.content.guest_access) {
     case mtx::events::state::AccessState::CanJoin:
         return tr("%1 made the room open to guests.").arg(name);
     case mtx::events::state::AccessState::Forbidden:
@@ -2312,21 +2407,13 @@ TimelineModel::formatGuestAccessEvent(const QString &id)
 }
 
 QString
-TimelineModel::formatHistoryVisibilityEvent(const QString &id)
+TimelineModel::formatHistoryVisibilityEvent(
+  const mtx::events::StateEvent<mtx::events::state::HistoryVisibility> &event) const
 {
-    auto e = events.get(id.toStdString(), "");
-    if (!e)
-        return {};
-
-    auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(e);
-
-    if (!event)
-        return {};
-
-    QString user = QString::fromStdString(event->sender);
+    QString user = QString::fromStdString(event.sender);
     QString name = utils::replaceEmoji(displayName(user));
 
-    switch (event->content.history_visibility) {
+    switch (event.content.history_visibility) {
     case mtx::events::state::Visibility::WorldReadable:
         return tr("%1 made the room history world readable. Events may be now read by "
                   "non-joined people.")
@@ -2344,32 +2431,25 @@ TimelineModel::formatHistoryVisibilityEvent(const QString &id)
 }
 
 QString
-TimelineModel::formatPowerLevelEvent(const QString &id)
+TimelineModel::formatPowerLevelEvent(
+  const mtx::events::StateEvent<mtx::events::state::PowerLevels> &event) const
 {
-    auto e = events.get(id.toStdString(), "");
-    if (!e)
-        return {};
-
-    auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(e);
-    if (!event)
-        return QString();
-
     mtx::events::StateEvent<mtx::events::state::PowerLevels> const *prevEvent = nullptr;
-    if (!event->unsigned_data.replaces_state.empty()) {
-        auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
+    if (!event.unsigned_data.replaces_state.empty()) {
+        auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id);
         if (tempPrevEvent) {
             prevEvent =
               std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(tempPrevEvent);
         }
     }
 
-    QString user        = QString::fromStdString(event->sender);
+    QString user        = QString::fromStdString(event.sender);
     QString sender_name = utils::replaceEmoji(displayName(user));
     // Get the rooms levels for redactions and powerlevel changes to determine "Administrator" and
     // "Moderator" powerlevels.
-    auto administrator_power_level = event->content.state_level("m.room.power_levels");
-    auto moderator_power_level     = event->content.redact;
-    auto default_powerlevel        = event->content.users_default;
+    auto administrator_power_level = event.content.state_level("m.room.power_levels");
+    auto moderator_power_level     = event.content.redact;
+    auto default_powerlevel        = event.content.users_default;
     if (!prevEvent)
         return tr("%1 has changed the room's permissions.").arg(sender_name);
 
@@ -2379,7 +2459,7 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
         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) {
+        for (auto const &[mxid, currentPowerlevel] : event.content.users) {
             if (currentPowerlevel == newPowerlevelSetting &&
                 prevEvent->content.user_level(mxid) < newPowerlevelSetting) {
                 numberOfAffected++;
@@ -2393,16 +2473,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
 
     QStringList resultingMessage{};
     // These affect only a few people. Therefor we can print who is affected.
-    if (event->content.kick != prevEvent->content.kick) {
+    if (event.content.kick != prevEvent->content.kick) {
         auto default_message = tr("%1 has changed the room's kick powerlevel from %2 to %3.")
                                  .arg(sender_name)
                                  .arg(prevEvent->content.kick)
-                                 .arg(event->content.kick);
+                                 .arg(event.content.kick);
 
         // We only calculate affected users if we change to a level above the default users PL
         // to not accidentally have a DoS vector
-        if (event->content.kick > default_powerlevel) {
-            auto [affected, number_of_affected] = calc_affected(event->content.kick);
+        if (event.content.kick > default_powerlevel) {
+            auto [affected, number_of_affected] = calc_affected(event.content.kick);
 
             if (number_of_affected != 0) {
                 auto true_affected_rest = number_of_affected - affected.size();
@@ -2424,16 +2504,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
         }
     }
 
-    if (event->content.redact != prevEvent->content.redact) {
+    if (event.content.redact != prevEvent->content.redact) {
         auto default_message = tr("%1 has changed the room's redact powerlevel from %2 to %3.")
                                  .arg(sender_name)
                                  .arg(prevEvent->content.redact)
-                                 .arg(event->content.redact);
+                                 .arg(event.content.redact);
 
         // We only calculate affected users if we change to a level above the default users PL
         // to not accidentally have a DoS vector
-        if (event->content.redact > default_powerlevel) {
-            auto [affected, number_of_affected] = calc_affected(event->content.redact);
+        if (event.content.redact > default_powerlevel) {
+            auto [affected, number_of_affected] = calc_affected(event.content.redact);
 
             if (number_of_affected != 0) {
                 auto true_affected_rest = number_of_affected - affected.size();
@@ -2456,16 +2536,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
         }
     }
 
-    if (event->content.ban != prevEvent->content.ban) {
+    if (event.content.ban != prevEvent->content.ban) {
         auto default_message = tr("%1 has changed the room's ban powerlevel from %2 to %3.")
                                  .arg(sender_name)
                                  .arg(prevEvent->content.ban)
-                                 .arg(event->content.ban);
+                                 .arg(event.content.ban);
 
         // We only calculate affected users if we change to a level above the default users PL
         // to not accidentally have a DoS vector
-        if (event->content.ban > default_powerlevel) {
-            auto [affected, number_of_affected] = calc_affected(event->content.ban);
+        if (event.content.ban > default_powerlevel) {
+            auto [affected, number_of_affected] = calc_affected(event.content.ban);
 
             if (number_of_affected != 0) {
                 auto true_affected_rest = number_of_affected - affected.size();
@@ -2487,17 +2567,17 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
         }
     }
 
-    if (event->content.state_default != prevEvent->content.state_default) {
+    if (event.content.state_default != prevEvent->content.state_default) {
         auto default_message =
           tr("%1 has changed the room's state_default powerlevel from %2 to %3.")
             .arg(sender_name)
             .arg(prevEvent->content.state_default)
-            .arg(event->content.state_default);
+            .arg(event.content.state_default);
 
         // We only calculate affected users if we change to a level above the default users PL
         // to not accidentally have a DoS vector
-        if (event->content.state_default > default_powerlevel) {
-            auto [affected, number_of_affected] = calc_affected(event->content.kick);
+        if (event.content.state_default > default_powerlevel) {
+            auto [affected, number_of_affected] = calc_affected(event.content.kick);
 
             if (number_of_affected != 0) {
                 auto true_affected_rest = number_of_affected - affected.size();
@@ -2521,42 +2601,42 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
 
     // These affect potentially the whole room. We there for do not calculate who gets affected
     // by this to prevent huge lists of people.
-    if (event->content.invite != prevEvent->content.invite) {
+    if (event.content.invite != prevEvent->content.invite) {
         resultingMessage.append(tr("%1 has changed the room's invite powerlevel from %2 to %3.")
                                   .arg(sender_name,
                                        QString::number(prevEvent->content.invite),
-                                       QString::number(event->content.invite)));
+                                       QString::number(event.content.invite)));
     }
 
-    if (event->content.events_default != prevEvent->content.events_default) {
-        if ((event->content.events_default > default_powerlevel) &&
+    if (event.content.events_default != prevEvent->content.events_default) {
+        if ((event.content.events_default > default_powerlevel) &&
             prevEvent->content.events_default <= default_powerlevel) {
             resultingMessage.append(
               tr("%1 has changed the room's events_default powerlevel from %2 to %3. New "
                  "users can now not send any events.")
                 .arg(sender_name,
                      QString::number(prevEvent->content.events_default),
-                     QString::number(event->content.events_default)));
-        } else if ((event->content.events_default < prevEvent->content.events_default) &&
-                   (event->content.events_default < default_powerlevel) &&
+                     QString::number(event.content.events_default)));
+        } else if ((event.content.events_default < prevEvent->content.events_default) &&
+                   (event.content.events_default < default_powerlevel) &&
                    (prevEvent->content.events_default > default_powerlevel)) {
             resultingMessage.append(
               tr("%1 has changed the room's events_default powerlevel from %2 to %3. New "
                  "users can now send events that are not otherwise restricted.")
                 .arg(sender_name,
                      QString::number(prevEvent->content.events_default),
-                     QString::number(event->content.events_default)));
+                     QString::number(event.content.events_default)));
         } else {
             resultingMessage.append(
               tr("%1 has changed the room's events_default powerlevel from %2 to %3.")
                 .arg(sender_name,
                      QString::number(prevEvent->content.events_default),
-                     QString::number(event->content.events_default)));
+                     QString::number(event.content.events_default)));
         }
     }
 
     // Compare if a Powerlevel of a user changed
-    for (auto const &[mxid, powerlevel] : event->content.users) {
+    for (auto const &[mxid, powerlevel] : event.content.users) {
         auto nameOfChangedUser = utils::replaceEmoji(displayName(QString::fromStdString(mxid)));
         if (prevEvent->content.user_level(mxid) != powerlevel) {
             if (powerlevel >= administrator_power_level) {
@@ -2581,7 +2661,7 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
     }
 
     // Handle added/removed/changed event type
-    for (auto const &[event_type, powerlevel] : event->content.events) {
+    for (auto const &[event_type, powerlevel] : event.content.events) {
         auto prev_not_present =
           prevEvent->content.events.find(event_type) == prevEvent->content.events.end();
 
@@ -2620,26 +2700,19 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
 }
 
 QString
-TimelineModel::formatImagePackEvent(const QString &id)
+TimelineModel::formatImagePackEvent(
+  const mtx::events::StateEvent<mtx::events::msc2545::ImagePack> &event) const
 {
-    auto e = events.get(id.toStdString(), "");
-    if (!e)
-        return {};
-
-    auto event = std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(e);
-    if (!event)
-        return {};
-
     mtx::events::StateEvent<mtx::events::msc2545::ImagePack> const *prevEvent = nullptr;
-    if (!event->unsigned_data.replaces_state.empty()) {
-        auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
+    if (!event.unsigned_data.replaces_state.empty()) {
+        auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id);
         if (tempPrevEvent) {
             prevEvent =
               std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(tempPrevEvent);
         }
     }
 
-    const auto &newImages = event->content.images;
+    const auto &newImages = event.content.images;
     const auto oldImages  = prevEvent ? prevEvent->content.images : decltype(newImages){};
 
     auto ascent = QFontMetrics(UserSettings::instance()->font()).ascent();
@@ -2662,12 +2735,12 @@ TimelineModel::formatImagePackEvent(const QString &id)
     auto added   = calcChange(newImages, oldImages);
     auto removed = calcChange(oldImages, newImages);
 
-    auto sender       = utils::replaceEmoji(displayName(QString::fromStdString(event->sender)));
+    auto sender       = utils::replaceEmoji(displayName(QString::fromStdString(event.sender)));
     const auto packId = [&event]() -> QString {
-        if (event->content.pack && !event->content.pack->display_name.empty()) {
-            return event->content.pack->display_name.c_str();
-        } else if (!event->state_key.empty()) {
-            return event->state_key.c_str();
+        if (event.content.pack && !event.content.pack->display_name.empty()) {
+            return event.content.pack->display_name.c_str();
+        } else if (!event.state_key.empty()) {
+            return event.state_key.c_str();
         }
         return tr("(empty)");
     }();
@@ -2692,7 +2765,7 @@ TimelineModel::formatImagePackEvent(const QString &id)
 }
 
 QString
-TimelineModel::formatPolicyRule(const QString &id)
+TimelineModel::formatPolicyRule(const QString &id) const
 {
     auto idStr = id.toStdString();
     auto e     = events.get(idStr, "");
@@ -2893,34 +2966,27 @@ TimelineModel::joinReplacementRoom(const QString &id)
 }
 
 QString
-TimelineModel::formatMemberEvent(const QString &id)
+TimelineModel::formatMemberEvent(
+  const mtx::events::StateEvent<mtx::events::state::Member> &event) const
 {
-    auto e = events.get(id.toStdString(), "");
-    if (!e)
-        return {};
-
-    auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e);
-    if (!event)
-        return {};
-
     mtx::events::StateEvent<mtx::events::state::Member> const *prevEvent = nullptr;
-    if (!event->unsigned_data.replaces_state.empty()) {
-        auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
+    if (!event.unsigned_data.replaces_state.empty()) {
+        auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id);
         if (tempPrevEvent) {
             prevEvent =
               std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(tempPrevEvent);
         }
     }
 
-    QString user = QString::fromStdString(event->state_key);
+    QString user = QString::fromStdString(event.state_key);
     QString name = utils::replaceEmoji(displayName(user));
     QString rendered;
-    QString sender     = QString::fromStdString(event->sender);
+    QString sender     = QString::fromStdString(event.sender);
     QString senderName = utils::replaceEmoji(displayName(sender));
 
     // see table https://matrix.org/docs/spec/client_server/latest#m-room-member
     using namespace mtx::events::state;
-    switch (event->content.membership) {
+    switch (event.content.membership) {
     case Membership::Invite:
         rendered = tr("%1 invited %2.").arg(senderName, name);
         break;
@@ -2929,9 +2995,8 @@ TimelineModel::formatMemberEvent(const QString &id)
             QString oldName = utils::replaceEmoji(
               QString::fromStdString(prevEvent->content.display_name).toHtmlEscaped());
 
-            bool displayNameChanged =
-              prevEvent->content.display_name != event->content.display_name;
-            bool avatarChanged = prevEvent->content.avatar_url != event->content.avatar_url;
+            bool displayNameChanged = prevEvent->content.display_name != event.content.display_name;
+            bool avatarChanged      = prevEvent->content.avatar_url != event.content.avatar_url;
 
             if (displayNameChanged && avatarChanged)
                 rendered = tr("%1 has changed their avatar and changed their "
@@ -2946,30 +3011,30 @@ TimelineModel::formatMemberEvent(const QString &id)
             // the case of nothing changed but join follows join shouldn't happen, so
             // just show it as join
         } else {
-            if (event->content.join_authorised_via_users_server.empty())
+            if (event.content.join_authorised_via_users_server.empty())
                 rendered = tr("%1 joined.").arg(name);
             else
                 rendered =
                   tr("%1 joined via authorisation from %2's server.")
                     .arg(name,
-                         QString::fromStdString(event->content.join_authorised_via_users_server));
+                         QString::fromStdString(event.content.join_authorised_via_users_server));
         }
         break;
     case Membership::Leave:
         if (!prevEvent || prevEvent->content.membership == Membership::Join) {
-            if (event->state_key == event->sender)
+            if (event.state_key == event.sender)
                 rendered = tr("%1 left the room.").arg(name);
             else
                 rendered = tr("%2 kicked %1.").arg(name, senderName);
         } else if (prevEvent->content.membership == Membership::Invite) {
-            if (event->state_key == event->sender)
+            if (event.state_key == event.sender)
                 rendered = tr("%1 rejected their invite.").arg(name);
             else
                 rendered = tr("%2 revoked the invite to %1.").arg(name, senderName);
         } else if (prevEvent->content.membership == Membership::Ban) {
             rendered = tr("%2 unbanned %1.").arg(name, senderName);
         } else if (prevEvent->content.membership == Membership::Knock) {
-            if (event->state_key == event->sender)
+            if (event.state_key == event.sender)
                 rendered = tr("%1 redacted their knock.").arg(name);
             else
                 rendered = tr("%2 rejected the knock from %1.").arg(name, senderName);
@@ -2988,8 +3053,8 @@ TimelineModel::formatMemberEvent(const QString &id)
         break;
     }
 
-    if (event->content.reason != "") {
-        rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event->content.reason));
+    if (event.content.reason != "") {
+        rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event.content.reason));
     }
 
     return rendered;
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index fccc99eb..23c3c802 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -199,8 +199,8 @@ class TimelineModel final : public QAbstractListModel
     QML_UNCREATABLE("")
 
     Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged)
-    Q_PROPERTY(std::vector<QString> typingUsers READ typingUsers WRITE updateTypingUsers NOTIFY
-                 typingUsersChanged)
+    Q_PROPERTY(
+      QStringList typingUsers READ typingUsers WRITE updateTypingUsers NOTIFY typingUsersChanged)
     Q_PROPERTY(QString scrollTarget READ scrollTarget NOTIFY scrollTargetChanged)
     Q_PROPERTY(QString reply READ reply WRITE setReply NOTIFY replyChanged RESET resetReply)
     Q_PROPERTY(QString edit READ edit WRITE setEdit NOTIFY editChanged RESET resetEdit)
@@ -238,6 +238,7 @@ public:
         IsOnlyEmoji,
         Body,
         FormattedBody,
+        FormattedStateEvent,
         IsSender,
         UserId,
         UserName,
@@ -266,6 +267,7 @@ public:
         ReplyTo,
         ThreadId,
         Reactions,
+        Room,
         RoomId,
         RoomName,
         RoomTopic,
@@ -286,6 +288,8 @@ public:
     int rowCount(const QModelIndex &parent = QModelIndex()) const override;
     QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
     void multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSpan) const override;
+    void
+    multiData(const QString &id, const QString &relatedTo, QModelRoleDataSpan roleDataSpan) const;
     QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const;
     Q_INVOKABLE QVariant dataById(const QString &id, int role, const QString &relatedTo);
     Q_INVOKABLE QVariant dataByIndex(int i, int role = Qt::DisplayRole) const
@@ -302,17 +306,22 @@ public:
     Q_INVOKABLE QString displayName(const QString &id) const;
     Q_INVOKABLE QString avatarUrl(const QString &id) const;
     Q_INVOKABLE QString formatDateSeparator(QDate date) const;
-    Q_INVOKABLE QString formatTypingUsers(const std::vector<QString> &users, const QColor &bg);
+    Q_INVOKABLE QString formatTypingUsers(const QStringList &users, const QColor &bg);
     Q_INVOKABLE bool showAcceptKnockButton(const QString &id);
     Q_INVOKABLE void acceptKnock(const QString &id);
     Q_INVOKABLE void joinReplacementRoom(const QString &id);
-    Q_INVOKABLE QString formatMemberEvent(const QString &id);
+    Q_INVOKABLE QString
+    formatMemberEvent(const mtx::events::StateEvent<mtx::events::state::Member> &event) const;
     Q_INVOKABLE QString formatJoinRuleEvent(const QString &id);
-    Q_INVOKABLE QString formatHistoryVisibilityEvent(const QString &id);
-    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);
+    QString formatHistoryVisibilityEvent(
+      const mtx::events::StateEvent<mtx::events::state::HistoryVisibility> &event) const;
+    QString
+    formatGuestAccessEvent(const mtx::events::StateEvent<mtx::events::state::GuestAccess> &) const;
+    QString formatPowerLevelEvent(
+      const mtx::events::StateEvent<mtx::events::state::PowerLevels> &event) const;
+    QString formatImagePackEvent(
+      const mtx::events::StateEvent<mtx::events::msc2545::ImagePack> &event) const;
+    Q_INVOKABLE QString formatPolicyRule(const QString &id) const;
     Q_INVOKABLE QVariantMap formatRedactedEvent(const QString &id);
 
     Q_INVOKABLE void viewRawMessage(const QString &id);
@@ -396,14 +405,14 @@ public slots:
     void lastReadIdOnWindowFocus();
     void checkAfterFetch();
     QVariantMap getDump(const QString &eventId, const QString &relatedTo) const;
-    void updateTypingUsers(const std::vector<QString> &users)
+    void updateTypingUsers(const QStringList &users)
     {
         if (this->typingUsers_ != users) {
             this->typingUsers_ = users;
             emit typingUsersChanged(typingUsers_);
         }
     }
-    std::vector<QString> typingUsers() const { return typingUsers_; }
+    QStringList typingUsers() const { return typingUsers_; }
     bool paginationInProgress() const { return m_paginationInProgress; }
     QString reply() const { return reply_; }
     void setReply(const QString &newReply);
@@ -461,7 +470,7 @@ signals:
     void redactionFailed(QString id);
     void mediaCached(QString mxcUrl, QString cacheUrl);
     void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
-    void typingUsersChanged(std::vector<QString> users);
+    void typingUsersChanged(QStringList users);
     void replyChanged(QString reply);
     void editChanged(QString reply);
     void threadChanged(QString id);
@@ -519,7 +528,7 @@ private:
     QString currentId, currentReadId;
     QString reply_, edit_, thread_;
     QString textBeforeEdit, replyBeforeEdit;
-    std::vector<QString> typingUsers_;
+    QStringList typingUsers_;
 
     TimelineViewManager *manager_;