summary refs log tree commit diff
diff options
context:
space:
mode:
authorDeepBlueV7.X <nicolas.werner@hotmail.de>2023-04-10 23:19:44 +0000
committerGitHub <noreply@github.com>2023-04-10 23:19:44 +0000
commit7973fbce8c0db3eb82eafb4cc0f776ba10ec8a79 (patch)
treec5cb9c8eccb061747919ac9c4574f207c62a8b42
parentTranslated using Weblate (German) (diff)
parentUpdate mtxclient commit hash (diff)
downloadnheko-7973fbce8c0db3eb82eafb4cc0f776ba10ec8a79.tar.xz
Merge pull request #1407 from Nheko-Reborn/ducktyping
Implement unknown msgtype functionality
-rw-r--r--CMakeLists.txt2
-rw-r--r--io.github.NhekoReborn.Nheko.yaml2
-rw-r--r--resources/qml/TimelineView.qml78
-rw-r--r--resources/qml/delegates/MessageDelegate.qml18
-rw-r--r--resources/qml/ui/TimelineEffects.qml112
-rw-r--r--resources/res.qrc1
-rw-r--r--src/CommandCompleter.cpp12
-rw-r--r--src/CommandCompleter.h2
-rw-r--r--src/Utils.cpp33
-rw-r--r--src/Utils.h38
-rw-r--r--src/timeline/InputBar.cpp59
-rw-r--r--src/timeline/InputBar.h2
-rw-r--r--src/timeline/TimelineModel.cpp80
-rw-r--r--src/timeline/TimelineModel.h18
14 files changed, 345 insertions, 112 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 06af4bbe..20fcb029 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -602,7 +602,7 @@ if(USE_BUNDLED_MTXCLIENT)
 	FetchContent_Declare(
 		MatrixClient
                 GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
-                GIT_TAG        c8849cd033bb59bee39f3fb2eaca953853731eb2
+                GIT_TAG        dd2bdbd104ae8f70f82da9ff7b4b60007fc105c3
 		)
 	set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "")
 	set(BUILD_LIB_TESTS OFF CACHE INTERNAL "")
diff --git a/io.github.NhekoReborn.Nheko.yaml b/io.github.NhekoReborn.Nheko.yaml
index 3bcfdf52..97d0b770 100644
--- a/io.github.NhekoReborn.Nheko.yaml
+++ b/io.github.NhekoReborn.Nheko.yaml
@@ -213,7 +213,7 @@ modules:
     buildsystem: cmake-ninja
     name: mtxclient
     sources:
-      - commit: c8849cd033bb59bee39f3fb2eaca953853731eb2
+      - commit: dd2bdbd104ae8f70f82da9ff7b4b60007fc105c3
         #tag: v0.9.2
         type: git
         url: https://github.com/Nheko-Reborn/mtxclient.git
diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index a146a991..30ad9292 100644
--- a/resources/qml/TimelineView.qml
+++ b/resources/qml/TimelineView.qml
@@ -364,60 +364,10 @@ Item {
         onClicked: Rooms.resetCurrentRoom()
     }
 
-    ParticleSystem { id: confettiParticleSystem 
-        Component.onCompleted: pause();
-        paused: !shouldEffectsRun
-    }
-
-    Emitter {
-        id: confettiEmitter
-
-        width: parent.width * 3/4
-        enabled: false
-        anchors.horizontalCenter: parent.horizontalCenter
-        y: parent.height
-        emitRate: Math.min(400 * Math.sqrt(parent.width * parent.height) / 870, 1000)
-        lifeSpan: 15000
-        system: confettiParticleSystem
-        maximumEmitted: 500
-        velocityFromMovement: 8
-        size: 16
-        sizeVariation: 4
-        velocity: PointDirection {
-            x: 0
-            y: -Math.min(450 * parent.height / 700, 1000)
-            xVariation: Math.min(4 * parent.width / 7, 450)
-            yVariation: 250
-        }
-    }
+    TimelineEffects {
+        id: timelineEffects
 
-    ImageParticle {
-        system: confettiParticleSystem
-        source: "qrc:/confettiparticle.svg"
-        rotationVelocity: 0
-        rotationVelocityVariation: 360
-        colorVariation: 1
-        color: "white"
-        entryEffect: ImageParticle.None
-        xVector: PointDirection {
-            x: 1
-            y: 0
-            xVariation: 0.2
-            yVariation: 0.2
-        }
-        yVector: PointDirection {
-            x: 0
-            y: 0.5
-            xVariation: 0.2
-            yVariation: 0.2
-        }
-    }
-
-    Gravity {
-        system: confettiParticleSystem
         anchors.fill: parent
-        magnitude: 350
-        angle: 90
     }
 
     NhekoDropArea {
@@ -428,7 +378,7 @@ Item {
     Timer {
         id: effectsTimer
         onTriggered: shouldEffectsRun = false;
-        interval: confettiEmitter.lifeSpan
+        interval: timelineEffects.maxLifespan
         repeat: false
         running: false
     }
@@ -462,7 +412,7 @@ Item {
                 return
 
             shouldEffectsRun = true;
-            confettiEmitter.pulse(parent.height * 2)
+            timelineEffects.pulseConfetti()
             room.markSpecialEffectsDone()
         }
 
@@ -471,7 +421,25 @@ Item {
             if (!Settings.fancyEffects)
                 return
 
-            effectsTimer.start();
+            effectsTimer.restart();
+        }
+
+        function onRainfall()
+        {
+            if (!Settings.fancyEffects)
+                return
+
+            shouldEffectsRun = true;
+            timelineEffects.pulseRainfall()
+            room.markSpecialEffectsDone()
+        }
+
+        function onRainfallDone()
+        {
+            if (!Settings.fancyEffects)
+                return
+
+            effectsTimer.restart();
         }
 
         target: room
diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml
index d298fa4e..c0bcec0d 100644
--- a/resources/qml/delegates/MessageDelegate.qml
+++ b/resources/qml/delegates/MessageDelegate.qml
@@ -51,7 +51,7 @@ Item {
         width: parent.width? parent.width: 0 // this should get rid of "cannot read property 'width' of null"
 
         DelegateChoice {
-            roleValue: MtxEvent.UnknownMessage
+            roleValue: MtxEvent.UnknownEvent
 
             Placeholder {
                 typeString: d.typeString
@@ -102,7 +102,21 @@ Item {
         }
 
         DelegateChoice {
-            roleValue: MtxEvent.ConfettiMessage
+            roleValue: MtxEvent.UnknownMessage
+
+            TextMessage {
+                formatted: d.formattedBody
+                body: d.body
+                isOnlyEmoji: d.isOnlyEmoji
+                isReply: d.isReply
+                keepFullText: d.keepFullText
+                metadataWidth: d.metadataWidth
+            }
+
+        }
+
+        DelegateChoice {
+            roleValue: MtxEvent.ElementEffectMessage
 
             TextMessage {
                 formatted: d.formattedBody
diff --git a/resources/qml/ui/TimelineEffects.qml b/resources/qml/ui/TimelineEffects.qml
new file mode 100644
index 00000000..aaff04a0
--- /dev/null
+++ b/resources/qml/ui/TimelineEffects.qml
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import QtQuick 2.15
+import QtQuick.Particles 2.15
+
+Item {
+    readonly property int maxLifespan: Math.max(confettiEmitter.lifeSpan, rainfallEmitter.lifeSpan)
+
+    function pulseConfetti()
+    {
+        confettiEmitter.pulse(parent.height * 2)
+    }
+
+    function pulseRainfall()
+    {
+        rainfallEmitter.pulse(parent.height * 3.3)
+    }
+
+    ParticleSystem {
+        id: particleSystem
+
+        Component.onCompleted: pause();
+        paused: !shouldEffectsRun
+    }
+
+    Emitter {
+        id: confettiEmitter
+
+        group: "confetti"
+        width: parent.width * 3/4
+        enabled: false
+        anchors.horizontalCenter: parent.horizontalCenter
+        y: parent.height
+        emitRate: Math.min(400 * Math.sqrt(parent.width * parent.height) / 870, 1000)
+        lifeSpan: 15000
+        system: particleSystem
+        maximumEmitted: 500
+        velocityFromMovement: 8
+        size: 16
+        sizeVariation: 4
+        velocity: PointDirection {
+            x: 0
+            y: -Math.min(450 * parent.height / 700, 1000)
+            xVariation: Math.min(4 * parent.width / 7, 450)
+            yVariation: 250
+        }
+    }
+
+    ImageParticle {
+        system: particleSystem
+        groups: ["confetti"]
+        source: "qrc:/confettiparticle.svg"
+        rotationVelocity: 0
+        rotationVelocityVariation: 360
+        colorVariation: 1
+        color: "white"
+        entryEffect: ImageParticle.None
+        xVector: PointDirection {
+            x: 1
+            y: 0
+            xVariation: 0.2
+            yVariation: 0.2
+        }
+        yVector: PointDirection {
+            x: 0
+            y: 0.5
+            xVariation: 0.2
+            yVariation: 0.2
+        }
+    }
+
+    Gravity {
+        system: particleSystem
+        groups: ["confetti"]
+        anchors.fill: parent
+        magnitude: 350
+        angle: 90
+    }
+
+    Emitter {
+        id: rainfallEmitter
+
+        group: "rain"
+        width: parent.width
+        enabled: false
+        anchors.horizontalCenter: parent.horizontalCenter
+        y: -60
+        emitRate: parent.width / 50
+        lifeSpan: 10000
+        system: particleSystem
+        velocity: PointDirection {
+            x: 0
+            y: 300
+            xVariation: 0
+            yVariation: 75
+        }
+
+        ItemParticle {
+            system: particleSystem
+            groups: ["rain"]
+            fade: false
+            delegate: Rectangle {
+                width: 2
+                height: 30 + 30 * Math.random()
+                radius: 2
+                color: "#0099ff"
+            }
+        }
+    }
+}
diff --git a/resources/res.qrc b/resources/res.qrc
index faa90495..3f1b2b65 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -197,6 +197,7 @@
         <file>qml/voip/VideoCall.qml</file>
         <file>confettiparticle.svg</file>
         <file>qml/delegates/EncryptionEnabled.qml</file>
+        <file>qml/ui/TimelineEffects.qml</file>
     </qresource>
     <qresource prefix="/media">
         <file>media/ring.ogg</file>
diff --git a/src/CommandCompleter.cpp b/src/CommandCompleter.cpp
index 2ec427d6..8123b8e6 100644
--- a/src/CommandCompleter.cpp
+++ b/src/CommandCompleter.cpp
@@ -87,6 +87,10 @@ CommandCompleter::data(const QModelIndex &index, int role) const
                 return QStringLiteral("/confetti ");
             case RainbowConfetti:
                 return QStringLiteral("/rainbowconfetti ");
+            case Rainfall:
+                return QStringLiteral("/rainfall ");
+            case Msgtype:
+                return QStringLiteral("/msgtype ");
             case Goto:
                 return QStringLiteral("/goto ");
             case ConvertToDm:
@@ -156,6 +160,10 @@ CommandCompleter::data(const QModelIndex &index, int role) const
                 return tr("/confetti [message]");
             case RainbowConfetti:
                 return tr("/rainbowconfetti [message]");
+            case Rainfall:
+                return tr("/rainfall [message]");
+            case Msgtype:
+                return tr("/msgtype <msgtype> [message]");
             case Goto:
                 return tr("/goto <message reference>");
             case ConvertToDm:
@@ -225,6 +233,10 @@ CommandCompleter::data(const QModelIndex &index, int role) const
                 return tr("Send a message with confetti.");
             case RainbowConfetti:
                 return tr("Send a message in rainbow colors with confetti.");
+            case Rainfall:
+                return tr("Send a message with rain.");
+            case Msgtype:
+                return tr("Send a message with a custom message type.");
             case Goto:
                 return tr("Go to a specific message using an event id, index or matrix: link");
             case ConvertToDm:
diff --git a/src/CommandCompleter.h b/src/CommandCompleter.h
index fcbbe3e5..4f27fe29 100644
--- a/src/CommandCompleter.h
+++ b/src/CommandCompleter.h
@@ -46,6 +46,8 @@ public:
         RainbowNotice,
         Confetti,
         RainbowConfetti,
+        Rainfall,
+        Msgtype,
         Goto,
         ConvertToDm,
         ConvertToRoom,
diff --git a/src/Utils.cpp b/src/Utils.cpp
index c5b2abd1..2bf8eb3b 100644
--- a/src/Utils.cpp
+++ b/src/Utils.cpp
@@ -215,19 +215,20 @@ utils::getMessageDescription(const TimelineEvent &event,
                              const QString &localUser,
                              const QString &displayName)
 {
-    using Audio      = mtx::events::RoomEvent<mtx::events::msg::Audio>;
-    using Emote      = mtx::events::RoomEvent<mtx::events::msg::Emote>;
-    using File       = mtx::events::RoomEvent<mtx::events::msg::File>;
-    using Image      = mtx::events::RoomEvent<mtx::events::msg::Image>;
-    using Notice     = mtx::events::RoomEvent<mtx::events::msg::Notice>;
-    using Text       = mtx::events::RoomEvent<mtx::events::msg::Text>;
-    using Video      = mtx::events::RoomEvent<mtx::events::msg::Video>;
-    using Confetti   = mtx::events::RoomEvent<mtx::events::msg::Confetti>;
-    using CallInvite = mtx::events::RoomEvent<mtx::events::voip::CallInvite>;
-    using CallAnswer = mtx::events::RoomEvent<mtx::events::voip::CallAnswer>;
-    using CallHangUp = mtx::events::RoomEvent<mtx::events::voip::CallHangUp>;
-    using CallReject = mtx::events::RoomEvent<mtx::events::voip::CallReject>;
-    using Encrypted  = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
+    using Audio         = mtx::events::RoomEvent<mtx::events::msg::Audio>;
+    using Emote         = mtx::events::RoomEvent<mtx::events::msg::Emote>;
+    using File          = mtx::events::RoomEvent<mtx::events::msg::File>;
+    using Image         = mtx::events::RoomEvent<mtx::events::msg::Image>;
+    using Notice        = mtx::events::RoomEvent<mtx::events::msg::Notice>;
+    using Text          = mtx::events::RoomEvent<mtx::events::msg::Text>;
+    using Unknown       = mtx::events::RoomEvent<mtx::events::msg::Unknown>;
+    using Video         = mtx::events::RoomEvent<mtx::events::msg::Video>;
+    using ElementEffect = mtx::events::RoomEvent<mtx::events::msg::ElementEffect>;
+    using CallInvite    = mtx::events::RoomEvent<mtx::events::voip::CallInvite>;
+    using CallAnswer    = mtx::events::RoomEvent<mtx::events::voip::CallAnswer>;
+    using CallHangUp    = mtx::events::RoomEvent<mtx::events::voip::CallHangUp>;
+    using CallReject    = mtx::events::RoomEvent<mtx::events::voip::CallReject>;
+    using Encrypted     = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
 
     if (std::holds_alternative<Audio>(event)) {
         return createDescriptionInfo<Audio>(event, localUser, displayName);
@@ -241,10 +242,12 @@ utils::getMessageDescription(const TimelineEvent &event,
         return createDescriptionInfo<Notice>(event, localUser, displayName);
     } else if (std::holds_alternative<Text>(event)) {
         return createDescriptionInfo<Text>(event, localUser, displayName);
+    } else if (std::holds_alternative<Unknown>(event)) {
+        return createDescriptionInfo<Unknown>(event, localUser, displayName);
     } else if (std::holds_alternative<Video>(event)) {
         return createDescriptionInfo<Video>(event, localUser, displayName);
-    } else if (std::holds_alternative<Confetti>(event)) {
-        return createDescriptionInfo<Confetti>(event, localUser, displayName);
+    } else if (std::holds_alternative<ElementEffect>(event)) {
+        return createDescriptionInfo<ElementEffect>(event, localUser, displayName);
     } else if (std::holds_alternative<CallInvite>(event)) {
         return createDescriptionInfo<CallInvite>(event, localUser, displayName);
     } else if (std::holds_alternative<CallAnswer>(event)) {
diff --git a/src/Utils.h b/src/Utils.h
index 2bf01f84..2c8988e5 100644
--- a/src/Utils.h
+++ b/src/Utils.h
@@ -95,20 +95,21 @@ messageDescription(const QString &username = QString(),
                    const QString &body     = QString(),
                    const bool isLocal      = false)
 {
-    using Audio      = mtx::events::RoomEvent<mtx::events::msg::Audio>;
-    using Emote      = mtx::events::RoomEvent<mtx::events::msg::Emote>;
-    using File       = mtx::events::RoomEvent<mtx::events::msg::File>;
-    using Image      = mtx::events::RoomEvent<mtx::events::msg::Image>;
-    using Notice     = mtx::events::RoomEvent<mtx::events::msg::Notice>;
-    using Sticker    = mtx::events::Sticker;
-    using Text       = mtx::events::RoomEvent<mtx::events::msg::Text>;
-    using Video      = mtx::events::RoomEvent<mtx::events::msg::Video>;
-    using Confetti   = mtx::events::RoomEvent<mtx::events::msg::Confetti>;
-    using CallInvite = mtx::events::RoomEvent<mtx::events::voip::CallInvite>;
-    using CallAnswer = mtx::events::RoomEvent<mtx::events::voip::CallAnswer>;
-    using CallHangUp = mtx::events::RoomEvent<mtx::events::voip::CallHangUp>;
-    using CallReject = mtx::events::RoomEvent<mtx::events::voip::CallReject>;
-    using Encrypted  = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
+    using Audio         = mtx::events::RoomEvent<mtx::events::msg::Audio>;
+    using Emote         = mtx::events::RoomEvent<mtx::events::msg::Emote>;
+    using File          = mtx::events::RoomEvent<mtx::events::msg::File>;
+    using Image         = mtx::events::RoomEvent<mtx::events::msg::Image>;
+    using Notice        = mtx::events::RoomEvent<mtx::events::msg::Notice>;
+    using Sticker       = mtx::events::Sticker;
+    using Text          = mtx::events::RoomEvent<mtx::events::msg::Text>;
+    using Unknown       = mtx::events::RoomEvent<mtx::events::msg::Unknown>;
+    using Video         = mtx::events::RoomEvent<mtx::events::msg::Video>;
+    using ElementEffect = mtx::events::RoomEvent<mtx::events::msg::ElementEffect>;
+    using CallInvite    = mtx::events::RoomEvent<mtx::events::voip::CallInvite>;
+    using CallAnswer    = mtx::events::RoomEvent<mtx::events::voip::CallAnswer>;
+    using CallHangUp    = mtx::events::RoomEvent<mtx::events::voip::CallHangUp>;
+    using CallReject    = mtx::events::RoomEvent<mtx::events::voip::CallReject>;
+    using Encrypted     = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
 
     if (std::is_same<T, Audio>::value) {
         if (isLocal)
@@ -149,20 +150,21 @@ messageDescription(const QString &username = QString(),
             return QCoreApplication::translate("message-description sent:",
                                                "%1 sent a notification")
               .arg(username);
-    } else if (std::is_same<T, Text>::value) {
+    } else if (std::is_same<T, Text>::value || std::is_same<T, Unknown>::value) {
         if (isLocal)
             return QCoreApplication::translate("message-description sent:", "You: %1").arg(body);
         else
             return QCoreApplication::translate("message-description sent:", "%1: %2")
               .arg(username, body);
-    } else if (std::is_same<T, Confetti>::value) {
+    } else if (std::is_same<T, ElementEffect>::value) {
         if (body.isEmpty()) {
+            // TODO: what is the best way to handle this?
             if (isLocal)
                 return QCoreApplication::translate("message-description sent:",
-                                                   "You sent some confetti");
+                                                   "You sent a chat effect");
             else
                 return QCoreApplication::translate("message-description sent:",
-                                                   "%1 sent some confetti")
+                                                   "%1 sent a chat effect")
                   .arg(username);
         } else {
             if (isLocal)
diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index b27128e0..fe8b8e48 100644
--- a/src/timeline/InputBar.cpp
+++ b/src/timeline/InputBar.cpp
@@ -281,6 +281,8 @@ InputBar::updateTextContentProperties(const QString &t)
                                              QStringLiteral("rainbownotice"),
                                              QStringLiteral("confetti"),
                                              QStringLiteral("rainbowconfetti"),
+                                             QStringLiteral("rainfall"),
+                                             QStringLiteral("msgtype"),
                                              QStringLiteral("goto"),
                                              QStringLiteral("converttodm"),
                                              QStringLiteral("converttoroom")};
@@ -607,8 +609,9 @@ InputBar::confetti(const QString &body, bool rainbowify)
 {
     auto html = utils::markdownToHtml(body, rainbowify);
 
-    mtx::events::msg::Confetti confetti;
-    confetti.body = body.trimmed().toStdString();
+    mtx::events::msg::ElementEffect confetti;
+    confetti.msgtype = "nic.custom.confetti";
+    confetti.body    = body.trimmed().toStdString();
 
     if (html != body.trimmed().toHtmlEscaped() &&
         ChatPage::instance()->userSettings()->markdown()) {
@@ -624,6 +627,54 @@ InputBar::confetti(const QString &body, bool rainbowify)
 }
 
 void
+InputBar::rainfall(const QString &body)
+{
+    auto html = utils::markdownToHtml(body);
+
+    mtx::events::msg::Unknown rain;
+    rain.msgtype = "io.element.effect.rainfall";
+    rain.body    = body.trimmed().toStdString();
+
+    if (html != body.trimmed().toHtmlEscaped() &&
+        ChatPage::instance()->userSettings()->markdown()) {
+        nlohmann::json j;
+        j["formatted_body"] = html.toStdString();
+        j["format"]         = "org.matrix.custom.html";
+        rain.content        = j.dump();
+        // Remove markdown links by completer
+        rain.body = replaceMatrixToMarkdownLink(body.trimmed()).toStdString();
+    }
+
+    rain.relations = generateRelations();
+
+    room->sendMessageEvent(rain, mtx::events::EventType::RoomMessage);
+}
+
+void
+InputBar::customMsgtype(const QString &msgtype, const QString &body)
+{
+    auto html = utils::markdownToHtml(body);
+
+    mtx::events::msg::Unknown msg;
+    msg.msgtype = msgtype.toStdString();
+    msg.body    = body.trimmed().toStdString();
+
+    if (html != body.trimmed().toHtmlEscaped() &&
+        ChatPage::instance()->userSettings()->markdown()) {
+        nlohmann::json j;
+        j["formatted_body"] = html.toStdString();
+        j["format"]         = "org.matrix.custom.html";
+        msg.content         = j.dump();
+        // Remove markdown links by completer
+        msg.body = replaceMatrixToMarkdownLink(body.trimmed()).toStdString();
+    }
+
+    msg.relations = generateRelations();
+
+    room->sendMessageEvent(msg, mtx::events::EventType::RoomMessage);
+}
+
+void
 InputBar::image(const QString &filename,
                 const std::optional<mtx::crypto::EncryptedFile> &file,
                 const QString &url,
@@ -890,6 +941,10 @@ InputBar::command(const QString &command, QString args)
         confetti(args, false);
     } else if (command == QLatin1String("rainbowconfetti")) {
         confetti(args, true);
+    } else if (command == QLatin1String("rainfall")) {
+        rainfall(args);
+    } else if (command == QLatin1String("msgtype")) {
+        customMsgtype(args.section(' ', 0, 0), args.section(' ', 1, -1));
     } else if (command == QLatin1String("goto")) {
         // Goto has three different modes:
         // 1 - Going directly to a given event ID
diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h
index acafd964..b2db377f 100644
--- a/src/timeline/InputBar.h
+++ b/src/timeline/InputBar.h
@@ -242,6 +242,8 @@ private:
     void emote(const QString &body, bool rainbowify);
     void notice(const QString &body, bool rainbowify);
     void confetti(const QString &body, bool rainbowify);
+    void rainfall(const QString &body);
+    void customMsgtype(const QString &msgtype, const QString &body);
     bool command(const QString &name, QString args);
     void image(const QString &filename,
                const std::optional<mtx::crypto::EncryptedFile> &file,
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index cb9fb7fa..918d1c0b 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -54,9 +54,9 @@ struct RoomEventType
         return qml_mtx_events::EventType::AudioMessage;
     }
     constexpr qml_mtx_events::EventType
-    operator()(const mtx::events::Event<mtx::events::msg::Confetti> &)
+    operator()(const mtx::events::Event<mtx::events::msg::ElementEffect> &)
     {
-        return qml_mtx_events::EventType::ConfettiMessage;
+        return qml_mtx_events::EventType::ElementEffectMessage;
     }
     constexpr qml_mtx_events::EventType
     operator()(const mtx::events::Event<mtx::events::msg::Emote> &)
@@ -84,6 +84,11 @@ struct RoomEventType
         return qml_mtx_events::EventType::TextMessage;
     }
     constexpr qml_mtx_events::EventType
+    operator()(const mtx::events::Event<mtx::events::msg::Unknown> &)
+    {
+        return qml_mtx_events::EventType::UnknownMessage;
+    }
+    constexpr qml_mtx_events::EventType
     operator()(const mtx::events::Event<mtx::events::msg::Video> &)
     {
         return qml_mtx_events::EventType::VideoMessage;
@@ -203,7 +208,7 @@ qml_mtx_events::toRoomEventType(mtx::events::EventType e)
     case EventType::RoomMember:
         return qml_mtx_events::EventType::Member;
     case EventType::RoomMessage:
-        return qml_mtx_events::EventType::UnknownMessage;
+        return qml_mtx_events::EventType::UnknownEvent;
     case EventType::RoomName:
         return qml_mtx_events::EventType::Name;
     case EventType::RoomPowerLevels:
@@ -239,7 +244,7 @@ qml_mtx_events::toRoomEventType(mtx::events::EventType e)
     case EventType::Unsupported:
         return qml_mtx_events::EventType::Unsupported;
     default:
-        return qml_mtx_events::EventType::UnknownMessage;
+        return qml_mtx_events::EventType::UnknownEvent;
     }
 }
 
@@ -362,16 +367,17 @@ qml_mtx_events::fromRoomEventType(qml_mtx_events::EventType t)
         return mtx::events::EventType::SpaceChild;
     /// m.room.message
     case qml_mtx_events::AudioMessage:
-    case qml_mtx_events::ConfettiMessage:
+    case qml_mtx_events::ElementEffectMessage:
     case qml_mtx_events::EmoteMessage:
     case qml_mtx_events::FileMessage:
     case qml_mtx_events::ImageMessage:
     case qml_mtx_events::LocationMessage:
     case qml_mtx_events::NoticeMessage:
     case qml_mtx_events::TextMessage:
+    case qml_mtx_events::UnknownMessage:
     case qml_mtx_events::VideoMessage:
     case qml_mtx_events::Redacted:
-    case qml_mtx_events::UnknownMessage:
+    case qml_mtx_events::UnknownEvent:
     case qml_mtx_events::KeyVerificationRequest:
     case qml_mtx_events::KeyVerificationStart:
     case qml_mtx_events::KeyVerificationMac:
@@ -1075,14 +1081,32 @@ TimelineModel::addEvents(const mtx::responses::Timeline &timeline)
         } else if (std::holds_alternative<RoomEvent<mtx::events::msg::Text>>(e)) {
             if (auto msg = QString::fromStdString(
                   std::get<RoomEvent<mtx::events::msg::Text>>(e).content.body);
-                msg.contains("🎉") || msg.contains("🎊"))
+                msg.contains("🎉") || msg.contains("🎊")) {
+                needsSpecialEffects_ = true;
+                specialEffects_.setFlag(Confetti);
+            }
+        } else if (std::holds_alternative<RoomEvent<mtx::events::msg::Unknown>>(e)) {
+            if (auto msg = QString::fromStdString(
+                  std::get<RoomEvent<mtx::events::msg::Unknown>>(e).content.body);
+                msg.contains("🎉") || msg.contains("🎊")) {
+                needsSpecialEffects_ = true;
+                specialEffects_.setFlag(Confetti);
+            }
+        } else if (std::holds_alternative<RoomEvent<mtx::events::msg::ElementEffect>>(e)) {
+            if (auto msgtype =
+                  std::get<RoomEvent<mtx::events::msg::ElementEffect>>(e).content.msgtype;
+                msgtype == "nic.custom.confetti") {
                 needsSpecialEffects_ = true;
-        } else if (std::holds_alternative<RoomEvent<mtx::events::msg::Confetti>>(e))
-            needsSpecialEffects_ = true;
+                specialEffects_.setFlag(Confetti);
+            } else if (msgtype == "io.element.effect.rainfall") {
+                needsSpecialEffects_ = true;
+                specialEffects_.setFlag(Rainfall);
+            }
+        }
     }
 
     if (needsSpecialEffects_)
-        emit confetti();
+        triggerSpecialEffects();
 
     if (avatarChanged)
         emit roomAvatarUrlChanged();
@@ -2045,7 +2069,14 @@ TimelineModel::triggerSpecialEffects()
 {
     if (needsSpecialEffects_) {
         // Note (Loren): Without the timer, this apparently emits before QML is ready
-        QTimer::singleShot(1, this, [this] { emit confetti(); });
+        if (specialEffects_.testFlag(Confetti)) {
+            QTimer::singleShot(1, this, [this] { emit confetti(); });
+            specialEffects_.setFlag(Confetti, false);
+        }
+        if (specialEffects_.testFlag(Rainfall)) {
+            QTimer::singleShot(1, this, [this] { emit rainfall(); });
+            specialEffects_.setFlag(Rainfall, false);
+        }
         needsSpecialEffects_ = false;
     }
 }
@@ -2055,6 +2086,10 @@ TimelineModel::markSpecialEffectsDone()
 {
     needsSpecialEffects_ = false;
     emit confettiDone();
+    emit rainfallDone();
+
+    specialEffects_.setFlag(Confetti, false);
+    specialEffects_.setFlag(Rainfall, false);
 }
 
 QString
@@ -2917,7 +2952,8 @@ TimelineModel::setEdit(const QString &newEdit)
             if (msgType == mtx::events::MessageType::Text ||
                 msgType == mtx::events::MessageType::Notice ||
                 msgType == mtx::events::MessageType::Emote ||
-                msgType == mtx::events::MessageType::Confetti) {
+                msgType == mtx::events::MessageType::ElementEffect ||
+                msgType == mtx::events::MessageType::Unknown) {
                 auto relInfo  = relatedInfo(newEdit);
                 auto editText = relInfo.quoted_body;
 
@@ -2938,9 +2974,23 @@ TimelineModel::setEdit(const QString &newEdit)
 
                 if (msgType == mtx::events::MessageType::Emote)
                     input()->setText("/me " + editText);
-                else if (msgType == mtx::events::MessageType::Confetti)
-                    input()->setText("/confetti" + editText);
-                else
+                else if (msgType == mtx::events::MessageType::ElementEffect) {
+                    auto u =
+                      std::get_if<mtx::events::RoomEvent<mtx::events::msg::ElementEffect>>(&e);
+                    auto msgtypeString = u ? u->content.msgtype : "";
+                    if (msgtypeString == "io.element.effect.rainfall")
+                        input()->setText("/rainfall " + editText);
+                    else if (msgtypeString == "nic.custom.confetti")
+                        input()->setText("/confetti " + editText);
+                    else
+                        input()->setText("/msgtype " + QString::fromStdString(msgtypeString) + " " +
+                                         editText);
+                } else if (msgType == mtx::events::MessageType::Unknown) {
+                    auto u = std::get_if<mtx::events::RoomEvent<mtx::events::msg::Unknown>>(&e);
+                    input()->setText("/msgtype " +
+                                     (u ? QString::fromStdString(u->content.msgtype) : "") + " " +
+                                     editText);
+                } else
                     input()->setText(editText);
             } else {
                 input()->setText(QLatin1String(""));
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index 03cb9ecb..ef845bb5 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -99,16 +99,17 @@ enum EventType
     Widget,
     /// m.room.message
     AudioMessage,
-    ConfettiMessage,
+    ElementEffectMessage,
     EmoteMessage,
     FileMessage,
     ImageMessage,
     LocationMessage,
     NoticeMessage,
     TextMessage,
+    UnknownMessage,
     VideoMessage,
     Redacted,
-    UnknownMessage,
+    UnknownEvent,
     KeyVerificationRequest,
     KeyVerificationStart,
     KeyVerificationMac,
@@ -266,6 +267,13 @@ public:
     };
     Q_ENUM(Roles);
 
+    enum SpecialEffect
+    {
+        Confetti,
+        Rainfall,
+    };
+    Q_DECLARE_FLAGS(SpecialEffects, SpecialEffect)
+
     QHash<int, QByteArray> roleNames() const override;
     int rowCount(const QModelIndex &parent = QModelIndex()) const override;
     QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
@@ -450,6 +458,8 @@ signals:
     void scrollToIndex(int index);
     void confetti();
     void confettiDone();
+    void rainfall();
+    void rainfallDone();
 
     void lastMessageChanged();
     void notificationsChanged();
@@ -521,8 +531,8 @@ private:
     std::string last_event_id;
     std::string fullyReadEventId_;
 
-    // TODO (Loren): This should hopefully handle more than just confetti in the future
     bool needsSpecialEffects_ = false;
+    QFlags<SpecialEffect> specialEffects_;
 
     std::unique_ptr<RoomSummary, DeleteLaterDeleter> parentSummary = nullptr;
     bool parentChecked                                             = false;
@@ -530,6 +540,8 @@ private:
     friend void EventStore::refetchOnlineKeyBackupKeys(TimelineModel *room);
 };
 
+Q_DECLARE_OPERATORS_FOR_FLAGS(TimelineModel::SpecialEffects)
+
 template<class T>
 void
 TimelineModel::sendMessageEvent(const T &content, mtx::events::EventType eventType)