From 7e238843e921f5f3e023f5a2a324e4f77c6bfd5d Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Thu, 5 Dec 2024 22:14:49 +0100 Subject: [PATCH 01/17] feat(plugins): add basic message construction --- src/CMakeLists.txt | 2 + src/controllers/plugins/LuaUtilities.hpp | 11 +- src/controllers/plugins/PluginController.cpp | 20 ++ src/controllers/plugins/api/ChannelRef.cpp | 27 +++ src/controllers/plugins/api/ChannelRef.hpp | 10 + src/controllers/plugins/api/Message.cpp | 218 ++++++++++++++++++ src/controllers/plugins/api/Message.hpp | 13 ++ tests/snapshots/PluginMessageCtor/empty.json | 23 ++ .../PluginMessageCtor/linebreak-element.json | 70 ++++++ .../PluginMessageCtor/mention-element.json | 102 ++++++++ .../PluginMessageCtor/properties.json | 58 +++++ .../reply-curve-element.json | 70 ++++++ .../single-line-text-element.json | 187 +++++++++++++++ .../PluginMessageCtor/text-element.json | 187 +++++++++++++++ .../PluginMessageCtor/timestamp-element.json | 138 +++++++++++ .../PluginMessageCtor/twitch-moderation.json | 70 ++++++ tests/src/Plugins.cpp | 163 ++++++++++++- tests/src/lib/Snapshot.hpp | 4 +- 18 files changed, 1364 insertions(+), 9 deletions(-) create mode 100644 src/controllers/plugins/api/Message.cpp create mode 100644 src/controllers/plugins/api/Message.hpp create mode 100644 tests/snapshots/PluginMessageCtor/empty.json create mode 100644 tests/snapshots/PluginMessageCtor/linebreak-element.json create mode 100644 tests/snapshots/PluginMessageCtor/mention-element.json create mode 100644 tests/snapshots/PluginMessageCtor/properties.json create mode 100644 tests/snapshots/PluginMessageCtor/reply-curve-element.json create mode 100644 tests/snapshots/PluginMessageCtor/single-line-text-element.json create mode 100644 tests/snapshots/PluginMessageCtor/text-element.json create mode 100644 tests/snapshots/PluginMessageCtor/timestamp-element.json create mode 100644 tests/snapshots/PluginMessageCtor/twitch-moderation.json diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e6385e2d161..5222f2da976 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -237,6 +237,8 @@ set(SOURCE_FILES controllers/plugins/api/HTTPResponse.hpp controllers/plugins/api/IOWrapper.cpp controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/Message.cpp + controllers/plugins/api/Message.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp controllers/plugins/LuaUtilities.cpp diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index 0f7bdc53f7e..0fd0d4760f4 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -111,19 +111,18 @@ class StackGuard * * @returns Sol reference to the table */ -template +template requires std::is_enum_v sol::table createEnumTable(sol::state_view &lua) { constexpr auto values = magic_enum::enum_values(); - auto out = lua.create_table(0, values.size()); + auto out = lua.create_table(0, values.size() + sizeof...(Additional)); for (const T v : values) { - std::string_view name = magic_enum::enum_name(v); - std::string str(name); - - out.raw_set(str, v); + out.raw_set(magic_enum::enum_name(v), v); } + (out.raw_set(magic_enum::enum_name(), Additional), ...); + return out; } diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 1a2bc3a1042..16dfdc70e66 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -11,6 +11,7 @@ # include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/IOWrapper.hpp" +# include "controllers/plugins/api/Message.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/SolTypes.hpp" @@ -220,10 +221,29 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) lua::api::ChannelRef::createUserType(c2); lua::api::HTTPResponse::createUserType(c2); lua::api::HTTPRequest::createUserType(c2); + lua::api::message::createUserType(c2); c2["ChannelType"] = lua::createEnumTable(lua); c2["HTTPMethod"] = lua::createEnumTable(lua); c2["EventType"] = lua::createEnumTable(lua); c2["LogLevel"] = lua::createEnumTable(lua); + c2["MessageFlag"] = + lua::createEnumTable(lua); + c2["MessageElementFlag"] = + lua::createEnumTable(lua); + c2["FontStyle"] = lua::createEnumTable(lua); + c2["MessageContext"] = lua::createEnumTable(lua); sol::table io = g["io"]; io.set_function( diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index b9bced3a0cf..1f1662475f1 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -88,6 +88,32 @@ void ChannelRef::add_system_message(QString text) this->strong()->addSystemMessage(text); } +void ChannelRef::add_message(std::shared_ptr &msg, + sol::variadic_args va) +{ + MessageContext ctx = [&] { + if (va.size() >= 1) + { + return va.get(); + } + return MessageContext::Original; + }(); + auto overrideFlags = [&]() -> std::optional { + if (va.size() >= 2) + { + auto flags = va.get>(1); + if (flags) + { + return MessageFlags{*flags}; + } + return {}; + } + return {}; + }(); + + this->strong()->addMessage(msg, ctx, overrideFlags); +} + bool ChannelRef::is_twitch_channel() { return this->strong()->isTwitchChannel(); @@ -168,6 +194,7 @@ void ChannelRef::createUserType(sol::table &c2) "get_display_name", &ChannelRef::get_display_name, "send_message", &ChannelRef::send_message, "add_system_message", &ChannelRef::add_system_message, + "add_message", &ChannelRef::add_message, "is_twitch_channel", &ChannelRef::is_twitch_channel, // TwitchChannel diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp index 9d4455739a5..a174ef046ee 100644 --- a/src/controllers/plugins/api/ChannelRef.hpp +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -71,6 +71,16 @@ struct ChannelRef { */ void add_system_message(QString text); + /** + * Adds a message client-side + * + * @lua@param message c2.Message + * @lua@param context? c2.MessageContext The context of the message being added + * @lua@param override_flags? c2.MessageFlag|nil Flags to override the message's flags (some splits might filter for this) + * @exposed c2.Channel:add_message + */ + void add_message(std::shared_ptr &message, sol::variadic_args va); + /** * Returns true for twitch channels. * Compares the channel Type. Note that enum values aren't guaranteed, just diff --git a/src/controllers/plugins/api/Message.cpp b/src/controllers/plugins/api/Message.cpp new file mode 100644 index 00000000000..02e7d3ba3fd --- /dev/null +++ b/src/controllers/plugins/api/Message.cpp @@ -0,0 +1,218 @@ +#include "controllers/plugins/api/Message.hpp" + +#include "messages/MessageElement.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "controllers/plugins/SolTypes.hpp" +# include "messages/Message.hpp" + +# include + +namespace { + +using namespace chatterino; + +MessageColor tryMakeMessageColor(const QString &name, + MessageColor fallback = MessageColor::Text) +{ + if (name.isEmpty()) + { + return fallback; + } + if (name == u"text") + { + return MessageColor::Text; + } + if (name == u"link") + { + return MessageColor::Link; + } + if (name == u"system") + { + return MessageColor::System; + } + // custom + return QColor(name); +} + +std::unique_ptr textElementFromTable(const sol::table &tbl) +{ + return std::make_unique( + tbl["text"], tbl.get_or("flags", MessageElementFlag::Text), + tryMakeMessageColor(tbl.get_or("color", QString{})), + tbl.get_or("style", FontStyle::ChatMedium)); +} + +std::unique_ptr singleLineTextElementFromTable( + const sol::table &tbl) +{ + return std::make_unique( + tbl["text"], tbl.get_or("flags", MessageElementFlag::Text), + tryMakeMessageColor(tbl.get_or("color", QString{})), + tbl.get_or("style", FontStyle::ChatMedium)); +} + +std::unique_ptr mentionElementFromTable(const sol::table &tbl) +{ + // no flags! + return std::make_unique( + tbl.get("display_name"), tbl.get("login_name"), + tryMakeMessageColor(tbl.get("fallback_color")), + tryMakeMessageColor(tbl.get("user_color"))); +} + +std::unique_ptr timestampElementFromTable( + const sol::table &tbl) +{ + // no flags! + auto time = tbl.get>("time"); + if (time) + { + return std::make_unique( + QDateTime::fromMSecsSinceEpoch(*time).time()); + } + return std::make_unique(); +} + +std::unique_ptr twitchModerationElementFromTable() +{ + // no flags! + return std::make_unique(); +} + +std::unique_ptr linebreakElementFromTable( + const sol::table &tbl) +{ + return std::make_unique( + tbl.get_or("flags", MessageElementFlag::None)); +} + +std::unique_ptr replyCurveElementFromTable() +{ + // no flags! + return std::make_unique(); +} + +std::unique_ptr elementFromTable(const sol::table &tbl) +{ + QString type = tbl["type"]; + std::unique_ptr el; + if (type == u"text") + { + el = textElementFromTable(tbl); + } + else if (type == u"single-line-text") + { + el = singleLineTextElementFromTable(tbl); + } + else if (type == u"mention") + { + el = mentionElementFromTable(tbl); + } + else if (type == u"timestamp") + { + el = timestampElementFromTable(tbl); + } + else if (type == u"twitch-moderation") + { + el = twitchModerationElementFromTable(); + } + else if (type == u"linebreak") + { + el = linebreakElementFromTable(tbl); + } + else if (type == u"reply-curve") + { + el = replyCurveElementFromTable(); + } + else + { + throw std::runtime_error("Invalid message type"); + } + assert(el); + + el->setTrailingSpace(tbl.get_or("trailing_space", true)); + el->setTooltip(tbl.get_or("tooltip", QString{})); + + return el; +} + +std::shared_ptr messageFromTable(const sol::table &tbl) +{ + auto msg = std::make_shared(); + msg->flags = tbl.get_or("flags", MessageFlag::None); + + // This takes a UTC offset (not the milliseconds since the start of the day) + auto parseTime = tbl.get>("parse_time"); + if (parseTime) + { + msg->parseTime = QDateTime::fromMSecsSinceEpoch(*parseTime).time(); + } + + msg->id = tbl.get_or("id", QString{}); + msg->searchText = tbl.get_or("search_text", QString{}); + msg->messageText = tbl.get_or("message_text", QString{}); + msg->loginName = tbl.get_or("login_name", QString{}); + msg->displayName = tbl.get_or("display_name", QString{}); + msg->localizedName = tbl.get_or("localized_name", QString{}); + // missing: timeoutUser + msg->channelName = tbl.get_or("channel_name", QString{}); + + auto usernameColor = tbl.get_or("username_color", QString{}); + if (!usernameColor.isEmpty()) + { + msg->usernameColor = QColor(usernameColor); + } + + auto serverReceivedTime = + tbl.get>("server_received_time"); + if (serverReceivedTime) + { + msg->serverReceivedTime = + QDateTime::fromMSecsSinceEpoch(*serverReceivedTime); + } + + // missing: badges + // missing: badgeInfos + + // we construct a color on the fly here + auto highlightColor = tbl.get_or("highlight_color", QString{}); + if (!highlightColor.isEmpty()) + { + msg->highlightColor = std::make_shared(highlightColor); + } + + // missing: replyThread + // missing: replyParent + // missing: count + + auto elements = tbl.get>("elements"); + if (elements) + { + auto size = elements->size(); + for (size_t i = 1; i <= size; i++) + { + msg->elements.emplace_back( + elementFromTable(elements->get(i))); + } + } + + // missing: reward + return msg; +} + +} // namespace + +namespace chatterino::lua::api::message { + +void createUserType(sol::table &c2) +{ + c2.new_usertype("Message", sol::factories([](sol::table tbl) { + return messageFromTable(tbl); + })); +} + +} // namespace chatterino::lua::api::message + +#endif diff --git a/src/controllers/plugins/api/Message.hpp b/src/controllers/plugins/api/Message.hpp new file mode 100644 index 00000000000..f5dc5573dc1 --- /dev/null +++ b/src/controllers/plugins/api/Message.hpp @@ -0,0 +1,13 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "messages/Message.hpp" + +# include + +namespace chatterino::lua::api::message { + +void createUserType(sol::table &c2); + +} // namespace chatterino::lua::api::message + +#endif diff --git a/tests/snapshots/PluginMessageCtor/empty.json b/tests/snapshots/PluginMessageCtor/empty.json new file mode 100644 index 00000000000..cbf25c8e218 --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/empty.json @@ -0,0 +1,23 @@ +{ + "input": "msg = {}", + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/linebreak-element.json b/tests/snapshots/PluginMessageCtor/linebreak-element.json new file mode 100644 index 00000000000..7113cd0e33f --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/linebreak-element.json @@ -0,0 +1,70 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'linebreak' },", + " { type = 'linebreak', flags = c2.MessageElementFlag.BttvEmote },", + " { type = 'linebreak', tooltip = 't' },", + " { type = 'linebreak', trailing_space = false },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "flags": "", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": true, + "type": "LinebreakElement" + }, + { + "flags": "BttvEmoteImage|BttvEmoteText", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": true, + "type": "LinebreakElement" + }, + { + "flags": "", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "t", + "trailingSpace": true, + "type": "LinebreakElement" + }, + { + "flags": "", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": false, + "type": "LinebreakElement" + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/mention-element.json b/tests/snapshots/PluginMessageCtor/mention-element.json new file mode 100644 index 00000000000..492f8649813 --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/mention-element.json @@ -0,0 +1,102 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green' },", + " { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green', flags = c2.MessageElementFlag.BttvEmote },", + " { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green', tooltip = 't' },", + " { type = 'mention', display_name = 'd', login_name = 'l', fallback_color = 'red', user_color = 'green', trailing_space = false },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "color": "Text", + "fallbackColor": "#ffff0000", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "#ff008000", + "userLoginName": "l", + "words": [ + "d" + ] + }, + { + "color": "Text", + "fallbackColor": "#ffff0000", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "#ff008000", + "userLoginName": "l", + "words": [ + "d" + ] + }, + { + "color": "Text", + "fallbackColor": "#ffff0000", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "t", + "trailingSpace": true, + "type": "MentionElement", + "userColor": "#ff008000", + "userLoginName": "l", + "words": [ + "d" + ] + }, + { + "color": "Text", + "fallbackColor": "#ffff0000", + "flags": "Text|Mention", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": false, + "type": "MentionElement", + "userColor": "#ff008000", + "userLoginName": "l", + "words": [ + "d" + ] + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/properties.json b/tests/snapshots/PluginMessageCtor/properties.json new file mode 100644 index 00000000000..fb8ec0a2a5f --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/properties.json @@ -0,0 +1,58 @@ +{ + "input": [ + "msg = {", + " flags = c2.MessageFlag.System | c2.MessageFlag.Disabled,", + " id = 'foo-bar',", + " parse_time = 420000,", + " search_text = 'search',", + " message_text = 'message',", + " login_name = 'login',", + " display_name = 'display',", + " localized_name = 'local',", + " username_color = 'blue',", + " server_received_time = 1230000,", + " highlight_color = '#12345678',", + " channel_name = 'channel',", + " elements = {", + " { type = 'text', text = 'aliens walking' }", + " }", + "}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "channel", + "count": 1, + "displayName": "display", + "elements": [ + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "aliens", + "walking" + ] + } + ], + "flags": "System|Disabled", + "highlightColor": "#12345678", + "id": "foo-bar", + "localizedName": "local", + "loginName": "login", + "messageText": "message", + "searchText": "search", + "serverReceivedTime": "1970-01-01T01:20:30", + "timeoutUser": "", + "usernameColor": "#ff0000ff" + } +} diff --git a/tests/snapshots/PluginMessageCtor/reply-curve-element.json b/tests/snapshots/PluginMessageCtor/reply-curve-element.json new file mode 100644 index 00000000000..73479fb2eb2 --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/reply-curve-element.json @@ -0,0 +1,70 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'reply-curve' },", + " { type = 'reply-curve', flags = c2.MessageElementFlag.BttvEmote },", + " { type = 'reply-curve', tooltip = 't' },", + " { type = 'reply-curve', trailing_space = false },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "flags": "RepliedMessage", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": true, + "type": "ReplyCurveElement" + }, + { + "flags": "RepliedMessage", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": true, + "type": "ReplyCurveElement" + }, + { + "flags": "RepliedMessage", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "t", + "trailingSpace": true, + "type": "ReplyCurveElement" + }, + { + "flags": "RepliedMessage", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": false, + "type": "ReplyCurveElement" + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/single-line-text-element.json b/tests/snapshots/PluginMessageCtor/single-line-text-element.json new file mode 100644 index 00000000000..8e46f30166d --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/single-line-text-element.json @@ -0,0 +1,187 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'single-line-text', text = '' },", + " { type = 'single-line-text', text = 'foo' },", + " { type = 'single-line-text', text = 'foo bar' },", + " { type = 'single-line-text', text = 'foo\\nbar' },", + " { type = 'single-line-text', text = 'foo', flags = c2.MessageElementFlag.Text | c2.MessageElementFlag.Timestamp },", + " { type = 'single-line-text', text = 'foo', color = 'text' },", + " { type = 'single-line-text', text = 'foo', color = 'system' },", + " { type = 'single-line-text', text = 'foo', color = 'link' },", + " { type = 'single-line-text', text = 'foo', color = 'green' },", + " { type = 'single-line-text', text = 'foo', style = c2.FontStyle.ChatSmall },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo", + "bar" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo\nbar" + ] + }, + { + "color": "Text", + "flags": "Text|Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + }, + { + "color": "Link", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + }, + { + "color": "#ff008000", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatSmall", + "tooltip": "", + "trailingSpace": true, + "type": "SingleLineTextElement", + "words": [ + "foo" + ] + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/text-element.json b/tests/snapshots/PluginMessageCtor/text-element.json new file mode 100644 index 00000000000..67cce345cd2 --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/text-element.json @@ -0,0 +1,187 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'text', text = '' },", + " { type = 'text', text = 'foo' },", + " { type = 'text', text = 'foo bar' },", + " { type = 'text', text = 'foo\\nbar' },", + " { type = 'text', text = 'foo', flags = c2.MessageElementFlag.Text | c2.MessageElementFlag.Timestamp },", + " { type = 'text', text = 'foo', color = 'text' },", + " { type = 'text', text = 'foo', color = 'system' },", + " { type = 'text', text = 'foo', color = 'link' },", + " { type = 'text', text = 'foo', color = 'green' },", + " { type = 'text', text = 'foo', style = c2.FontStyle.ChatSmall },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo", + "bar" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo\nbar" + ] + }, + { + "color": "Text", + "flags": "Text|Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + }, + { + "color": "System", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + }, + { + "color": "Link", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + }, + { + "color": "#ff008000", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + }, + { + "color": "Text", + "flags": "Text", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatSmall", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "foo" + ] + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/timestamp-element.json b/tests/snapshots/PluginMessageCtor/timestamp-element.json new file mode 100644 index 00000000000..76b12ac8f6e --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/timestamp-element.json @@ -0,0 +1,138 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'timestamp' },", + " { type = 'timestamp', time = 1230000, flags = c2.MessageElementFlag.BttvEmote },", + " { type = 'timestamp', tooltip = 't' },", + " { type = 'timestamp', trailing_space = false },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "element": { + "color": "System", + "flags": "Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "0:00" + ] + }, + "flags": "Timestamp", + "format": "", + "link": { + "type": "None", + "value": "" + }, + "time": "00:00:00", + "tooltip": "", + "trailingSpace": true, + "type": "TimestampElement" + }, + { + "element": { + "color": "System", + "flags": "Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "1:20" + ] + }, + "flags": "Timestamp", + "format": "", + "link": { + "type": "None", + "value": "" + }, + "time": "01:20:30", + "tooltip": "", + "trailingSpace": true, + "type": "TimestampElement" + }, + { + "element": { + "color": "System", + "flags": "Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "0:00" + ] + }, + "flags": "Timestamp", + "format": "", + "link": { + "type": "None", + "value": "" + }, + "time": "00:00:00", + "tooltip": "t", + "trailingSpace": true, + "type": "TimestampElement" + }, + { + "element": { + "color": "System", + "flags": "Timestamp", + "link": { + "type": "None", + "value": "" + }, + "style": "ChatMedium", + "tooltip": "", + "trailingSpace": true, + "type": "TextElement", + "words": [ + "0:00" + ] + }, + "flags": "Timestamp", + "format": "", + "link": { + "type": "None", + "value": "" + }, + "time": "00:00:00", + "tooltip": "", + "trailingSpace": false, + "type": "TimestampElement" + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/snapshots/PluginMessageCtor/twitch-moderation.json b/tests/snapshots/PluginMessageCtor/twitch-moderation.json new file mode 100644 index 00000000000..334eac757fb --- /dev/null +++ b/tests/snapshots/PluginMessageCtor/twitch-moderation.json @@ -0,0 +1,70 @@ +{ + "input": [ + "msg = {elements={", + " { type = 'twitch-moderation' },", + " { type = 'twitch-moderation', flags = c2.MessageElementFlag.BttvEmote },", + " { type = 'twitch-moderation', tooltip = 't' },", + " { type = 'twitch-moderation', trailing_space = false },", + "}}" + ], + "output": { + "badgeInfos": { + }, + "badges": [ + ], + "channelName": "", + "count": 1, + "displayName": "", + "elements": [ + { + "flags": "ModeratorTools", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": true, + "type": "TwitchModerationElement" + }, + { + "flags": "ModeratorTools", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": true, + "type": "TwitchModerationElement" + }, + { + "flags": "ModeratorTools", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "t", + "trailingSpace": true, + "type": "TwitchModerationElement" + }, + { + "flags": "ModeratorTools", + "link": { + "type": "None", + "value": "" + }, + "tooltip": "", + "trailingSpace": false, + "type": "TwitchModerationElement" + } + ], + "flags": "", + "id": "", + "localizedName": "", + "loginName": "", + "messageText": "", + "searchText": "", + "serverReceivedTime": "", + "timeoutUser": "", + "usernameColor": "#ff000000" + } +} diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index 588d3c2fffe..63e6c94782b 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -9,6 +9,8 @@ # include "controllers/plugins/PluginController.hpp" # include "controllers/plugins/PluginPermission.hpp" # include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep +# include "lib/Snapshot.hpp" +# include "messages/Message.hpp" # include "mocks/BaseApplication.hpp" # include "mocks/Channel.hpp" # include "mocks/Emotes.hpp" @@ -31,6 +33,8 @@ using chatterino::mock::MockChannel; namespace { +constexpr bool UPDATE_SNAPSHOTS = false; + const QString TEST_SETTINGS = R"( { "plugins": { @@ -103,7 +107,7 @@ class MockApplication : public mock::BaseApplication } PluginController plugins; - mock::EmptyLogging logging; + mock::Logging logging; CommandController commands; mock::Emotes emotes; MockTwitch twitch; @@ -638,4 +642,161 @@ TEST_F(PluginTest, tryCallTest) } } +TEST_F(PluginTest, MessageElementFlag) +{ + configure(); + lua->script(R"lua( + values = {} + for k, v in pairs(c2.MessageElementFlag) do + table.insert(values, ("%s=0x%x"):format(k, v)) + end + table.sort(values) + out = table.concat(values, ",") + )lua"); + + const char *VALUES = "AlwaysShow=0x2000000," + "BadgeChannelAuthority=0x8000," + "BadgeChatterino=0x40000," + "BadgeFfz=0x80000," + "BadgeGlobalAuthority=0x2000," + "BadgePredictions=0x4000," + "BadgeSevenTV=0x1000000000," + "BadgeSharedChannel=0x2000000000," + "BadgeSubscription=0x10000," + "BadgeVanity=0x20000," + "Badges=0x30000fe000," + "BitsAmount=0x200000," + "BitsAnimated=0x1000," + "BitsStatic=0x800," + "BttvEmote=0xc0," + "BttvEmoteImage=0x40," + "BttvEmoteText=0x80," + "ChannelName=0x100000," + "ChannelPointReward=0x100," + "ChannelPointRewardImage=0x110," + "Collapsed=0x4000000," + "Default=0x34022fea5e," + "EmojiAll=0x1800000," + "EmojiImage=0x800000," + "EmojiText=0x1000000," + "EmoteImages=0x400000250," + "EmoteText=0x8000004a0," + "FfzEmote=0x600," + "FfzEmoteImage=0x200," + "FfzEmoteText=0x400," + "LowercaseLinks=0x20000000," + "Mention=0x8000000," + "Misc=0x1," + "ModeratorTools=0x400000," + "None=0x0," + "RepliedMessage=0x100000000," + "ReplyButton=0x200000000," + "SevenTVEmote=0xc00000000," + "SevenTVEmoteImage=0x400000000," + "SevenTVEmoteText=0x800000000," + "Text=0x2," + "Timestamp=0x8," + "TwitchEmote=0x30," + "TwitchEmoteImage=0x10," + "TwitchEmoteText=0x20," + "Username=0x4"; + + std::string got = (*lua)["out"]; + ASSERT_EQ(got, VALUES); +} + +TEST_F(PluginTest, ChannelAddMessage) +{ + configure(); + lua->script(R"lua( + function do_it(chan) + local Repost = c2.MessageContext.Repost + local Original = c2.MessageContext.Original + chan:add_message(c2.Message.new({ id = "1" })) + chan:add_message(c2.Message.new({ id = "2" }), Repost) + chan:add_message(c2.Message.new({ id = "3" }), Original, nil) + chan:add_message(c2.Message.new({ id = "4" }), Repost, c2.MessageFlag.DoNotLog) + chan:add_message(c2.Message.new({ id = "5" }), Original, c2.MessageFlag.DoNotLog) + chan:add_message(c2.Message.new({ id = "6" }), Original, c2.MessageFlag.System) + end + )lua"); + + auto chan = std::make_shared("mock"); + + std::vector logged; + EXPECT_CALL(this->app->logging, addMessage) + .Times(3) + .WillRepeatedly( + [&](const auto &, const auto &msg, const auto &, const auto &) { + logged.emplace_back(msg); + }); + + std::vector>> added; + std::ignore = chan->messageAppended.connect([&](auto &&...args) { + added.emplace_back(std::forward(args)...); + }); + + (*lua)["do_it"](lua::api::ChannelRef(chan)); + + ASSERT_EQ(added.size(), 6); + ASSERT_EQ(added[0].first->id, "1"); + ASSERT_FALSE(added[0].second.has_value()); + ASSERT_EQ(added[1].first->id, "2"); + ASSERT_FALSE(added[1].second.has_value()); + ASSERT_EQ(added[2].first->id, "3"); + ASSERT_FALSE(added[2].second.has_value()); + ASSERT_EQ(added[3].first->id, "4"); + ASSERT_EQ(added[3].second, MessageFlags{MessageFlag::DoNotLog}); + ASSERT_EQ(added[4].first->id, "5"); + ASSERT_EQ(added[4].second, MessageFlags{MessageFlag::DoNotLog}); + ASSERT_EQ(added[5].first->id, "6"); + ASSERT_EQ(added[5].second, MessageFlags{MessageFlag::System}); + + ASSERT_EQ(logged.size(), 3); + ASSERT_EQ(added[0].first, logged[0]); + ASSERT_EQ(added[2].first, logged[1]); + ASSERT_EQ(added[5].first, logged[2]); +} + +class PluginMessageConstructionTest + : public PluginTest, + public ::testing::WithParamInterface +{ +}; +TEST_P(PluginMessageConstructionTest, Run) +{ + auto fixture = testlib::Snapshot::read("PluginMessageCtor", GetParam()); + + configure(); + std::string script; + if (fixture->input().isArray()) + { + for (auto line : fixture->input().toArray()) + { + script += line.toString().toStdString() + '\n'; + } + } + else + { + script = fixture->inputString().toStdString() + '\n'; + } + + script += "out = c2.Message.new(msg)"; + lua->script(script); + + Message *got = (*lua)["out"]; + + ASSERT_TRUE(fixture->run(got->toJson(), UPDATE_SNAPSHOTS)); +} + +INSTANTIATE_TEST_SUITE_P( + PluginMessageConstruction, PluginMessageConstructionTest, + testing::ValuesIn(testlib::Snapshot::discover("PluginMessageCtor"))); + +// verify that all snapshots are included +TEST(PluginMessageConstructionTest, Integrity) +{ + ASSERT_FALSE(UPDATE_SNAPSHOTS); // make sure fixtures are actually tested +} + #endif diff --git a/tests/src/lib/Snapshot.hpp b/tests/src/lib/Snapshot.hpp index 39f663eaf2c..9408c2e1ede 100644 --- a/tests/src/lib/Snapshot.hpp +++ b/tests/src/lib/Snapshot.hpp @@ -41,9 +41,9 @@ namespace chatterino::testlib { /// /// TEST_P(ExampleTest, Run) { /// auto fixture = testlib::Snapshot::read("category", GetParam()); -/// auto output = functionToTest(fixture.input()); // or input{String,Utf8} +/// auto output = functionToTest(fixture->input()); // or input{String,Utf8} /// // if snapshots are supposed to be updated, this will write the output -/// ASSERT_TRUE(fixture.run(output, UPDATE_SNAPSHOTS)); +/// ASSERT_TRUE(fixture->run(output, UPDATE_SNAPSHOTS)); /// } /// /// INSTANTIATE_TEST_SUITE_P(ExampleInstance, ExampleTest, From 9a02517e88f304fbf2c4b92d23b76eb74d05b76a Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 8 Dec 2024 17:39:14 +0100 Subject: [PATCH 02/17] feat: add types --- docs/plugin-meta.lua | 222 ++++++++++++++++++++++++ scripts/make_luals_meta.py | 117 ++++++++++--- src/common/enums/MessageContext.hpp | 2 + src/controllers/plugins/LuaAPI.hpp | 1 + src/controllers/plugins/api/Message.hpp | 87 ++++++++++ src/messages/MessageElement.hpp | 1 + src/messages/MessageFlag.hpp | 1 + src/singletons/Fonts.hpp | 1 + 8 files changed, 408 insertions(+), 24 deletions(-) diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index 27cdf87868c..9cadca66e87 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -110,6 +110,13 @@ function c2.Channel:send_message(message, execute_commands) end ---@param message string function c2.Channel:add_system_message(message) end +--- Adds a message client-side +--- +---@param message c2.Message +---@param context? c2.MessageContext The context of the message being added +---@param override_flags? c2.MessageFlag|nil Flags to override the message's flags (some splits might filter for this) +function c2.Channel:add_message(message, context, override_flags) end + --- Returns true for twitch channels. --- Compares the channel Type. Note that enum values aren't guaranteed, just --- that they are equal to the exposed enum. @@ -255,6 +262,221 @@ function c2.HTTPRequest.create(method, url) end -- End src/controllers/plugins/api/HTTPRequest.hpp +-- Begin src/controllers/plugins/api/Message.hpp + + +---A chat message +---@class c2.Message +c2.Message = {} + +---A table to initialize a new message +---@class MessageInit +---@field flags? c2.MessageFlag Message flags (see `c2.MessageFlags`) +---@field id? string The (ideally unique) message ID +---@field parse_time? number Time the message was parsed (in milliseconds since epoch) +---@field search_text? string Text to that is compared when searching for messages +---@field message_text? string The message text (used for filters for example) +---@field login_name? string The login name of the sender +---@field display_name? string The display name of the sender +---@field localized_name? string The localized name of the sender (this is used for CJK names, otherwise it's empty) +---@field channel_name? string The name of the channel this message appeared in +---@field username_color? string The color of the username +---@field server_received_time? number The time the server received the message (in milliseconds since epoch) +---@field highlight_color? string|nil The color of the highlight (if any) +---@field elements? MessageElementInit[] The elements of the message + +---A base table to initialize a new message element +---@class MessageElementInitBase +---@field tooltip? string Tooltip text +---@field trailing_space? boolean Whether to add a trailing space after the element (default: true) + +---@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text" and "system" are special values that take the current theme into account + +---A table to initialize a new message text element +---@class TextElementInit : MessageElementInitBase +---@field type "text" The type of the element +---@field text string The text of this element +---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`) +---@field color? MessageColor The color of the text +---@field style? c2.FontStyle The font style of the text + +---A table to initialize a new message single-line text element +---@class SingleLineTextElementInit : MessageElementInitBase +---@field type "single-line-text" The type of the element +---@field text string The text of this element +---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`) +---@field color? MessageColor The color of the text +---@field style? c2.FontStyle The font style of the text + +---A table to initialize a new mention element +---@class MentionElementInit : MessageElementInitBase +---@field type "mention" The type of the element +---@field display_name string The display name of the mentioned user +---@field login_name string The login name of the mentioned user +---@field fallback_color MessageColor The color of the element in case the "Colorize @usernames" is disabled +---@field user_color MessageColor The color of the element in case the "Colorize @usernames" is enabled + +---A table to initialize a new timestamp element +---@class TimestampElementInit : MessageElementInitBase +---@field type "timestamp" The type of the element +---@field time number? The time of the timestamp (in milliseconds since epoch). If not provided, the current time is used. + +---A table to initialize a new Twitch moderation element (all the custom moderation buttons) +---@class TwitchModerationElementInit : MessageElementInitBase +---@field type "twitch-moderation" The type of the element + +---A table to initialize a new linebreak element +---@class LinebreakElementInit : MessageElementInitBase +---@field type "linebreak" The type of the element +---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`) + +---A table to initialize a new reply curve element +---@class ReplyCurveElementInit : MessageElementInitBase +---@field type "reply-curve" The type of the element + +---@alias MessageElementInit TextElementInit|SingleLineTextElementInit|MentionElementInit|TimestampElementInit|TwitchModerationElementInit|LinebreakElementInit|ReplyCurveElementInit + +--- Creates a new message +--- +---@param init MessageInit The message initialization table +---@return c2.Message msg The new message +function c2.Message.new(init) end +-- Begin src/singletons/Fonts.hpp + +---@enum c2.FontStyle +c2.FontStyle = { + Tiny = {}, ---@type c2.FontStyle.Tiny + ChatSmall = {}, ---@type c2.FontStyle.ChatSmall + ChatMediumSmall = {}, ---@type c2.FontStyle.ChatMediumSmall + ChatMedium = {}, ---@type c2.FontStyle.ChatMedium + ChatMediumBold = {}, ---@type c2.FontStyle.ChatMediumBold + ChatMediumItalic = {}, ---@type c2.FontStyle.ChatMediumItalic + ChatLarge = {}, ---@type c2.FontStyle.ChatLarge + ChatVeryLarge = {}, ---@type c2.FontStyle.ChatVeryLarge + UiMedium = {}, ---@type c2.FontStyle.UiMedium + UiMediumBold = {}, ---@type c2.FontStyle.UiMediumBold + UiTabs = {}, ---@type c2.FontStyle.UiTabs + EndType = {}, ---@type c2.FontStyle.EndType + ChatStart = {}, ---@type c2.FontStyle.ChatStart + ChatEnd = {}, ---@type c2.FontStyle.ChatEnd +} + +-- End src/singletons/Fonts.hpp + +-- Begin src/messages/MessageElement.hpp + +---@enum c2.MessageElementFlag +c2.MessageElementFlag = { + None = 0, + Misc = 0, + Text = 0, + Username = 0, + Timestamp = 0, + TwitchEmoteImage = 0, + TwitchEmoteText = 0, + TwitchEmote = 0, + BttvEmoteImage = 0, + BttvEmoteText = 0, + BttvEmote = 0, + ChannelPointReward = 0, + ChannelPointRewardImage = 0, + FfzEmoteImage = 0, + FfzEmoteText = 0, + FfzEmote = 0, + SevenTVEmoteImage = 0, + SevenTVEmoteText = 0, + SevenTVEmote = 0, + EmoteImages = 0, + EmoteText = 0, + BitsStatic = 0, + BitsAnimated = 0, + BadgeSharedChannel = 0, + BadgeGlobalAuthority = 0, + BadgePredictions = 0, + BadgeChannelAuthority = 0, + BadgeSubscription = 0, + BadgeVanity = 0, + BadgeChatterino = 0, + BadgeSevenTV = 0, + BadgeFfz = 0, + Badges = 0, + ChannelName = 0, + BitsAmount = 0, + ModeratorTools = 0, + EmojiImage = 0, + EmojiText = 0, + EmojiAll = 0, + AlwaysShow = 0, + Collapsed = 0, + Mention = 0, + LowercaseLinks = 0, + RepliedMessage = 0, + ReplyButton = 0, + Default = 0, +} + +-- End src/messages/MessageElement.hpp + +-- Begin src/messages/MessageFlag.hpp + +---@enum c2.MessageFlag +c2.MessageFlag = { + None = 0, + System = 0, + Timeout = 0, + Highlighted = 0, + DoNotTriggerNotification = 0, + Centered = 0, + Disabled = 0, + DisableCompactEmotes = 0, + Collapsed = 0, + ConnectedMessage = 0, + DisconnectedMessage = 0, + Untimeout = 0, + PubSub = 0, + Subscription = 0, + DoNotLog = 0, + AutoMod = 0, + RecentMessage = 0, + Whisper = 0, + HighlightedWhisper = 0, + Debug = 0, + Similar = 0, + RedeemedHighlight = 0, + RedeemedChannelPointReward = 0, + ShowInMentions = 0, + FirstMessage = 0, + ReplyMessage = 0, + ElevatedMessage = 0, + SubscribedThread = 0, + CheerMessage = 0, + LiveUpdatesAdd = 0, + LiveUpdatesRemove = 0, + LiveUpdatesUpdate = 0, + AutoModOffendingMessageHeader = 0, + AutoModOffendingMessage = 0, + LowTrustUsers = 0, + RestrictedMessage = 0, + MonitoredMessage = 0, + Action = 0, + SharedMessage = 0, + AutoModBlockedTerm = 0, +} + +-- End src/messages/MessageFlag.hpp + +-- Begin src/common/enums/MessageContext.hpp + +---@enum c2.MessageContext +c2.MessageContext = { + Original = {}, ---@type c2.MessageContext.Original + Repost = {}, ---@type c2.MessageContext.Repost +} + +-- End src/common/enums/MessageContext.hpp + +-- End src/controllers/plugins/api/Message.hpp + -- Begin src/common/network/NetworkCommon.hpp ---@enum c2.HTTPMethod diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index e1dafe496c1..2b0076da481 100755 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -27,6 +27,13 @@ @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines Non-command lines of comments are written with a space after '---' + +To insert larger fragments of documentation, comments like +/* @lua-fragment +---@class foo +... +*/ +will be inserted as-is. """ from io import TextIOWrapper @@ -55,10 +62,18 @@ def strip_line(line: str): return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip() +def strip_comments(line: str): + return re.sub(r"//.*|/\*.*?\*/", "", line).strip() + + def is_comment_start(line: str): return line.startswith("/**") +def is_fragment_start(line: str): + return line.startswith("/* @lua-fragment") + + def is_enum_class(line: str): return line.startswith("enum class") @@ -96,20 +111,26 @@ def next_line(self) -> Optional[str]: return self.lines[self.line_idx - 1].strip() return None - def next_doc_comment(self) -> Optional[list[str]]: - """Reads a documentation comment (/** ... */) and advances the cursor""" - lines = [] + def next_item(self) -> list[str] | str | None: + """Finds the next documentation comment or fragment and advances the cursor""" # find the start - while (line := self.next_line()) is not None and not is_comment_start(line): - pass - if line is None: - return None + while (line := self.next_line()) is not None: + if is_comment_start(line): + return self._read_doc_comment(line) + elif is_fragment_start(line): + return self._read_fragment() + return None + + def _read_doc_comment(self, starting_line: str) -> Optional[list[str]]: + """Reads a documentation comment (/** ... */) and advances the cursor""" + lines = [] + line = starting_line stripped = strip_line(line) if stripped: lines.append(stripped) - if stripped.endswith("*/"): + if line.endswith("*/"): return lines if lines else None while (line := self.next_line()) is not None: @@ -131,6 +152,17 @@ def next_doc_comment(self) -> Optional[list[str]]: return lines if lines else None + def _read_fragment(self) -> Optional[str]: + """Reads an inline fragment comment (/* @lua-fragment ... */) and advances the cursor""" + + body = "" + while (line := self.next_line()) is not None: + if line.endswith("*/"): + break + body += "\n" + line + + return body + def read_class_body(self) -> list[list[str]]: """The reader must be at the first line of the class/struct body. All comments inside the class are returned.""" items = [] @@ -143,7 +175,8 @@ def read_class_body(self) -> list[list[str]]: nesting += line.count("{") - line.count("}") self.next_line() continue - doc = self.next_doc_comment() + doc = self.next_item() + assert not isinstance(doc, str), "Fragment inside class body found" if not doc: break items.append(doc) @@ -153,6 +186,7 @@ def read_enum_variants(self) -> list[str]: """The reader must be before an enum class definition (possibly with some comments before). It returns all variants.""" items = [] is_comment = False + waiting_for_end = False while (line := self.peek_line()) is not None and not line.startswith("};"): self.next_line() if is_comment: @@ -175,7 +209,16 @@ def read_enum_variants(self) -> list[str]: if line.startswith("enum class"): continue - items.append(line.rstrip(",")) + if waiting_for_end: + if line.endswith(","): + waiting_for_end = False + continue + m = re.match(r"^([\w_]+)", line) + if not m: + continue + + items.append(m.group(1)) + waiting_for_end = not strip_comments(line).endswith(",") return items @@ -214,6 +257,25 @@ def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): out.write(f"function {name}({lua_params}) end\n\n") +def write_enum( + name: str, + description: str | None, + variants: list[str], + is_flags: bool, + out: TextIOWrapper, +): + if description: + out.write(f"--- {description}\n") + out.write(f"---@enum {name}\n") + out.write(f"{name} = {{\n") + + def value(variant): + return "0," if is_flags else f"{{}}, ---@type {name}.{variant}" + + out.write("\n".join([f" {variant} = {value(variant)}" for variant in variants])) + out.write("\n}\n\n") + + def read_file(path: Path, out: TextIOWrapper): print("Reading", path.relative_to(repo_root)) with path.open("r") as f: @@ -221,9 +283,14 @@ def read_file(path: Path, out: TextIOWrapper): reader = Reader(lines) while reader.has_next(): - doc_comment = reader.next_doc_comment() + doc_comment = reader.next_item() if not doc_comment: break + elif isinstance(doc_comment, str): + out.write(doc_comment) + out.write("\n") + continue + header_comment = None if not doc_comment[0].startswith("@"): if len(doc_comment) == 1: @@ -241,21 +308,23 @@ def read_file(path: Path, out: TextIOWrapper): reader.line_no(), f"Invalid enum exposure - one command expected, got {len(header)}", ) - name = header[0].split(" ", 1)[1] - printmsg(path, reader.line_no(), f"enum {name}") - if header_comment: - out.write(f"--- {header_comment}\n") - out.write(f"---@enum {name}\n") - out.write(f"{name} = {{\n") - out.write( - "\n".join( - [ - f" {variant} = {{}}, ---@type {name}.{variant}" - for variant in reader.read_enum_variants() - ] + args = header[0].split(" ")[1:] + if len(args) < 1 or len(args) > 2: + panic( + path, + reader.line_no(), + f"Invalid @exposeenum - expected 2 arguments, got {len(args)}", ) + name = args[0] + is_flags = len(args) >= 2 and args[1] == "[flags]" + printmsg(path, reader.line_no(), f"enum {name}") + write_enum( + name, + header_comment, + reader.read_enum_variants(), + is_flags, + out, ) - out.write("\n}\n\n") continue # class diff --git a/src/common/enums/MessageContext.hpp b/src/common/enums/MessageContext.hpp index 669e5531511..5eaa7aed6ec 100644 --- a/src/common/enums/MessageContext.hpp +++ b/src/common/enums/MessageContext.hpp @@ -2,6 +2,8 @@ namespace chatterino { +/** @exposeenum c2.MessageContext */ + /// Context of the message being added to a channel enum class MessageContext { /// This message is the original diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index bd83dee5aff..cd05af505b6 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -85,6 +85,7 @@ sol::table toTable(lua_State *L, const CompletionEvent &ev); * @includefile controllers/plugins/api/ChannelRef.hpp * @includefile controllers/plugins/api/HTTPResponse.hpp * @includefile controllers/plugins/api/HTTPRequest.hpp + * @includefile controllers/plugins/api/Message.hpp * @includefile common/network/NetworkCommon.hpp */ diff --git a/src/controllers/plugins/api/Message.hpp b/src/controllers/plugins/api/Message.hpp index f5dc5573dc1..85dd03e1c4d 100644 --- a/src/controllers/plugins/api/Message.hpp +++ b/src/controllers/plugins/api/Message.hpp @@ -6,6 +6,93 @@ namespace chatterino::lua::api::message { +/* @lua-fragment +---A chat message +---@class c2.Message +c2.Message = {} + +---A table to initialize a new message +---@class MessageInit +---@field flags? c2.MessageFlag Message flags (see `c2.MessageFlags`) +---@field id? string The (ideally unique) message ID +---@field parse_time? number Time the message was parsed (in milliseconds since epoch) +---@field search_text? string Text to that is compared when searching for messages +---@field message_text? string The message text (used for filters for example) +---@field login_name? string The login name of the sender +---@field display_name? string The display name of the sender +---@field localized_name? string The localized name of the sender (this is used for CJK names, otherwise it's empty) +---@field channel_name? string The name of the channel this message appeared in +---@field username_color? string The color of the username +---@field server_received_time? number The time the server received the message (in milliseconds since epoch) +---@field highlight_color? string|nil The color of the highlight (if any) +---@field elements? MessageElementInit[] The elements of the message + +---A base table to initialize a new message element +---@class MessageElementInitBase +---@field tooltip? string Tooltip text +---@field trailing_space? boolean Whether to add a trailing space after the element (default: true) + +---@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text" and "system" are special values that take the current theme into account + +---A table to initialize a new message text element +---@class TextElementInit : MessageElementInitBase +---@field type "text" The type of the element +---@field text string The text of this element +---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`) +---@field color? MessageColor The color of the text +---@field style? c2.FontStyle The font style of the text + +---A table to initialize a new message single-line text element +---@class SingleLineTextElementInit : MessageElementInitBase +---@field type "single-line-text" The type of the element +---@field text string The text of this element +---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`) +---@field color? MessageColor The color of the text +---@field style? c2.FontStyle The font style of the text + +---A table to initialize a new mention element +---@class MentionElementInit : MessageElementInitBase +---@field type "mention" The type of the element +---@field display_name string The display name of the mentioned user +---@field login_name string The login name of the mentioned user +---@field fallback_color MessageColor The color of the element in case the "Colorize @usernames" is disabled +---@field user_color MessageColor The color of the element in case the "Colorize @usernames" is enabled + +---A table to initialize a new timestamp element +---@class TimestampElementInit : MessageElementInitBase +---@field type "timestamp" The type of the element +---@field time number? The time of the timestamp (in milliseconds since epoch). If not provided, the current time is used. + +---A table to initialize a new Twitch moderation element (all the custom moderation buttons) +---@class TwitchModerationElementInit : MessageElementInitBase +---@field type "twitch-moderation" The type of the element + +---A table to initialize a new linebreak element +---@class LinebreakElementInit : MessageElementInitBase +---@field type "linebreak" The type of the element +---@field flags? c2.MessageElementFlag Message element flags (see `c2.MessageElementFlags`) + +---A table to initialize a new reply curve element +---@class ReplyCurveElementInit : MessageElementInitBase +---@field type "reply-curve" The type of the element + +---@alias MessageElementInit TextElementInit|SingleLineTextElementInit|MentionElementInit|TimestampElementInit|TwitchModerationElementInit|LinebreakElementInit|ReplyCurveElementInit + +--- Creates a new message +--- +---@param init MessageInit The message initialization table +---@return c2.Message msg The new message +function c2.Message.new(init) end +*/ + +/** + * @includefile singletons/Fonts.hpp + * @includefile messages/MessageElement.hpp + * @includefile messages/MessageFlag.hpp + * @includefile common/enums/MessageContext.hpp + */ + +/// Creates the c2.Message user type void createUserType(sol::table &c2); } // namespace chatterino::lua::api::message diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index c19ed5c0c8a..951bc34ff74 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -31,6 +31,7 @@ using ImagePtr = std::shared_ptr; struct Emote; using EmotePtr = std::shared_ptr; +/** @exposeenum c2.MessageElementFlag [flags] */ enum class MessageElementFlag : int64_t { None = 0LL, Misc = (1LL << 0), diff --git a/src/messages/MessageFlag.hpp b/src/messages/MessageFlag.hpp index 306587a0982..29a8fa92420 100644 --- a/src/messages/MessageFlag.hpp +++ b/src/messages/MessageFlag.hpp @@ -6,6 +6,7 @@ namespace chatterino { +/** @exposeenum c2.MessageFlag [flags] */ enum class MessageFlag : std::int64_t { None = 0LL, System = (1LL << 0), diff --git a/src/singletons/Fonts.hpp b/src/singletons/Fonts.hpp index e6ea324a04a..26e6be3395a 100644 --- a/src/singletons/Fonts.hpp +++ b/src/singletons/Fonts.hpp @@ -14,6 +14,7 @@ namespace chatterino { class Settings; class Paths; +/** @exposeenum c2.FontStyle */ enum class FontStyle : uint8_t { Tiny, ChatSmall, From 5dee992fcd3bdde99321eee8ff80f3a81b75c5ba Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 8 Dec 2024 18:05:53 +0100 Subject: [PATCH 03/17] fix: tests --- src/controllers/plugins/api/Message.cpp | 22 +++++++++++++++---- .../PluginMessageCtor/properties.json | 2 +- .../PluginMessageCtor/timestamp-element.json | 4 ++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/controllers/plugins/api/Message.cpp b/src/controllers/plugins/api/Message.cpp index 02e7d3ba3fd..dd0ae7a87fe 100644 --- a/src/controllers/plugins/api/Message.cpp +++ b/src/controllers/plugins/api/Message.cpp @@ -1,5 +1,6 @@ #include "controllers/plugins/api/Message.hpp" +#include "Application.hpp" #include "messages/MessageElement.hpp" #ifdef CHATTERINO_HAVE_PLUGINS @@ -13,6 +14,20 @@ namespace { using namespace chatterino; +QDateTime datetimeFromOffset(qint64 offset) +{ + auto dt = QDateTime::fromMSecsSinceEpoch(offset); + +# ifdef CHATTERINO_WITH_TESTS + if (getApp()->isTest()) + { + return dt.toUTC(); + } +# endif + + return dt; +} + MessageColor tryMakeMessageColor(const QString &name, MessageColor fallback = MessageColor::Text) { @@ -70,7 +85,7 @@ std::unique_ptr timestampElementFromTable( if (time) { return std::make_unique( - QDateTime::fromMSecsSinceEpoch(*time).time()); + datetimeFromOffset(*time).time()); } return std::make_unique(); } @@ -147,7 +162,7 @@ std::shared_ptr messageFromTable(const sol::table &tbl) auto parseTime = tbl.get>("parse_time"); if (parseTime) { - msg->parseTime = QDateTime::fromMSecsSinceEpoch(*parseTime).time(); + msg->parseTime = datetimeFromOffset(*parseTime).time(); } msg->id = tbl.get_or("id", QString{}); @@ -169,8 +184,7 @@ std::shared_ptr messageFromTable(const sol::table &tbl) tbl.get>("server_received_time"); if (serverReceivedTime) { - msg->serverReceivedTime = - QDateTime::fromMSecsSinceEpoch(*serverReceivedTime); + msg->serverReceivedTime = datetimeFromOffset(*serverReceivedTime); } // missing: badges diff --git a/tests/snapshots/PluginMessageCtor/properties.json b/tests/snapshots/PluginMessageCtor/properties.json index fb8ec0a2a5f..33f56755d77 100644 --- a/tests/snapshots/PluginMessageCtor/properties.json +++ b/tests/snapshots/PluginMessageCtor/properties.json @@ -51,7 +51,7 @@ "loginName": "login", "messageText": "message", "searchText": "search", - "serverReceivedTime": "1970-01-01T01:20:30", + "serverReceivedTime": "1970-01-01T00:20:30Z", "timeoutUser": "", "usernameColor": "#ff0000ff" } diff --git a/tests/snapshots/PluginMessageCtor/timestamp-element.json b/tests/snapshots/PluginMessageCtor/timestamp-element.json index 76b12ac8f6e..4d37a94216b 100644 --- a/tests/snapshots/PluginMessageCtor/timestamp-element.json +++ b/tests/snapshots/PluginMessageCtor/timestamp-element.json @@ -56,7 +56,7 @@ "trailingSpace": true, "type": "TextElement", "words": [ - "1:20" + "0:20" ] }, "flags": "Timestamp", @@ -65,7 +65,7 @@ "type": "None", "value": "" }, - "time": "01:20:30", + "time": "00:20:30", "tooltip": "", "trailingSpace": true, "type": "TimestampElement" From 311ac91944f382887c8c0c4158bdfaadbd7f240e Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 8 Dec 2024 18:32:20 +0100 Subject: [PATCH 04/17] why does ubuntu 22.04 compare case insensitively? --- tests/src/Plugins.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index 63e6c94782b..b49fe25e3f9 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -650,7 +650,7 @@ TEST_F(PluginTest, MessageElementFlag) for k, v in pairs(c2.MessageElementFlag) do table.insert(values, ("%s=0x%x"):format(k, v)) end - table.sort(values) + table.sort(values, function(a, b) return a:lower() > b:lower() end) out = table.concat(values, ",") )lua"); @@ -660,11 +660,11 @@ TEST_F(PluginTest, MessageElementFlag) "BadgeFfz=0x80000," "BadgeGlobalAuthority=0x2000," "BadgePredictions=0x4000," + "Badges=0x30000fe000," "BadgeSevenTV=0x1000000000," "BadgeSharedChannel=0x2000000000," "BadgeSubscription=0x10000," "BadgeVanity=0x20000," - "Badges=0x30000fe000," "BitsAmount=0x200000," "BitsAnimated=0x1000," "BitsStatic=0x800," From 41f44ebf7ef78ae6da6ea5009c12911773f3e6fa Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 8 Dec 2024 18:33:13 +0100 Subject: [PATCH 05/17] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad89b6710a6..94358712efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ - Minor: Added a setting to hide the scrollbar highlights. (#5732) - Minor: The window layout is now backed up like the other settings. (#5647) - Minor: Added `flags.similar` filter variable, allowing you to filter messages filtered by the R9K feature. (#5747) +- Minor: Added basic message API to plugins. (#5754) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426, #5612) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Bugfix: Fixed restricted users usernames not being clickable. (#5405) From f223c5169e49ae6e0a66b79ac81f5ec851d1d6ad Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 8 Dec 2024 18:51:45 +0100 Subject: [PATCH 06/17] silly --- tests/src/Plugins.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index b49fe25e3f9..ee468c1fafa 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -650,7 +650,7 @@ TEST_F(PluginTest, MessageElementFlag) for k, v in pairs(c2.MessageElementFlag) do table.insert(values, ("%s=0x%x"):format(k, v)) end - table.sort(values, function(a, b) return a:lower() > b:lower() end) + table.sort(values, function(a, b) return a:lower() < b:lower() end) out = table.concat(values, ",") )lua"); From ffd9f706d72165f718a22369cc4bdc4c947b897c Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Wed, 12 Mar 2025 15:40:12 +0100 Subject: [PATCH 07/17] chore: update to changes in message element --- docs/plugin-meta.lua | 5 +++++ src/controllers/plugins/api/Message.cpp | 4 +++- src/controllers/plugins/api/Message.hpp | 1 + tests/snapshots/PluginMessageCtor/empty.json | 1 + tests/snapshots/PluginMessageCtor/linebreak-element.json | 1 + tests/snapshots/PluginMessageCtor/mention-element.json | 1 + tests/snapshots/PluginMessageCtor/properties.json | 2 ++ .../snapshots/PluginMessageCtor/reply-curve-element.json | 1 + .../PluginMessageCtor/single-line-text-element.json | 1 + tests/snapshots/PluginMessageCtor/text-element.json | 1 + tests/snapshots/PluginMessageCtor/timestamp-element.json | 9 +++++---- tests/snapshots/PluginMessageCtor/twitch-moderation.json | 1 + 12 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index 4d9e6da2fea..59c2fc3c452 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -281,6 +281,7 @@ c2.Message = {} ---@field login_name? string The login name of the sender ---@field display_name? string The display name of the sender ---@field localized_name? string The localized name of the sender (this is used for CJK names, otherwise it's empty) +---@field user_id? string The ID of the user who sent the message ---@field channel_name? string The name of the channel this message appeared in ---@field username_color? string The color of the username ---@field server_received_time? number The time the server received the message (in milliseconds since epoch) @@ -355,6 +356,7 @@ c2.FontStyle = { ChatMediumItalic = {}, ---@type c2.FontStyle.ChatMediumItalic ChatLarge = {}, ---@type c2.FontStyle.ChatLarge ChatVeryLarge = {}, ---@type c2.FontStyle.ChatVeryLarge + TimestampMedium = {}, ---@type c2.FontStyle.TimestampMedium UiMedium = {}, ---@type c2.FontStyle.UiMedium UiMediumBold = {}, ---@type c2.FontStyle.UiMediumBold UiTabs = {}, ---@type c2.FontStyle.UiTabs @@ -463,6 +465,9 @@ c2.MessageFlag = { Action = 0, SharedMessage = 0, AutoModBlockedTerm = 0, + ClearChat = 0, + EventSub = 0, + ModerationAction = 0, } -- End src/messages/MessageFlag.hpp diff --git a/src/controllers/plugins/api/Message.cpp b/src/controllers/plugins/api/Message.cpp index dd0ae7a87fe..f905fad8c9e 100644 --- a/src/controllers/plugins/api/Message.cpp +++ b/src/controllers/plugins/api/Message.cpp @@ -171,6 +171,7 @@ std::shared_ptr messageFromTable(const sol::table &tbl) msg->loginName = tbl.get_or("login_name", QString{}); msg->displayName = tbl.get_or("display_name", QString{}); msg->localizedName = tbl.get_or("localized_name", QString{}); + msg->userID = tbl.get_or("user_id", QString{}); // missing: timeoutUser msg->channelName = tbl.get_or("channel_name", QString{}); @@ -222,7 +223,8 @@ namespace chatterino::lua::api::message { void createUserType(sol::table &c2) { - c2.new_usertype("Message", sol::factories([](sol::table tbl) { + c2.new_usertype("Message", + sol::factories([](const sol::table &tbl) { return messageFromTable(tbl); })); } diff --git a/src/controllers/plugins/api/Message.hpp b/src/controllers/plugins/api/Message.hpp index 85dd03e1c4d..f2493b86ae3 100644 --- a/src/controllers/plugins/api/Message.hpp +++ b/src/controllers/plugins/api/Message.hpp @@ -21,6 +21,7 @@ c2.Message = {} ---@field login_name? string The login name of the sender ---@field display_name? string The display name of the sender ---@field localized_name? string The localized name of the sender (this is used for CJK names, otherwise it's empty) +---@field user_id? string The ID of the user who sent the message ---@field channel_name? string The name of the channel this message appeared in ---@field username_color? string The color of the username ---@field server_received_time? number The time the server received the message (in milliseconds since epoch) diff --git a/tests/snapshots/PluginMessageCtor/empty.json b/tests/snapshots/PluginMessageCtor/empty.json index cbf25c8e218..87beab7c142 100644 --- a/tests/snapshots/PluginMessageCtor/empty.json +++ b/tests/snapshots/PluginMessageCtor/empty.json @@ -18,6 +18,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/linebreak-element.json b/tests/snapshots/PluginMessageCtor/linebreak-element.json index 7113cd0e33f..2159ad9c5e1 100644 --- a/tests/snapshots/PluginMessageCtor/linebreak-element.json +++ b/tests/snapshots/PluginMessageCtor/linebreak-element.json @@ -65,6 +65,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/mention-element.json b/tests/snapshots/PluginMessageCtor/mention-element.json index 492f8649813..b86ce325cf9 100644 --- a/tests/snapshots/PluginMessageCtor/mention-element.json +++ b/tests/snapshots/PluginMessageCtor/mention-element.json @@ -97,6 +97,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/properties.json b/tests/snapshots/PluginMessageCtor/properties.json index 33f56755d77..9583d4bb606 100644 --- a/tests/snapshots/PluginMessageCtor/properties.json +++ b/tests/snapshots/PluginMessageCtor/properties.json @@ -9,6 +9,7 @@ " login_name = 'login',", " display_name = 'display',", " localized_name = 'local',", + " user_id = 'user-id',", " username_color = 'blue',", " server_received_time = 1230000,", " highlight_color = '#12345678',", @@ -53,6 +54,7 @@ "searchText": "search", "serverReceivedTime": "1970-01-01T00:20:30Z", "timeoutUser": "", + "userID": "user-id", "usernameColor": "#ff0000ff" } } diff --git a/tests/snapshots/PluginMessageCtor/reply-curve-element.json b/tests/snapshots/PluginMessageCtor/reply-curve-element.json index 73479fb2eb2..1e5cb5a7102 100644 --- a/tests/snapshots/PluginMessageCtor/reply-curve-element.json +++ b/tests/snapshots/PluginMessageCtor/reply-curve-element.json @@ -65,6 +65,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/single-line-text-element.json b/tests/snapshots/PluginMessageCtor/single-line-text-element.json index 8e46f30166d..635a0625b0e 100644 --- a/tests/snapshots/PluginMessageCtor/single-line-text-element.json +++ b/tests/snapshots/PluginMessageCtor/single-line-text-element.json @@ -182,6 +182,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/text-element.json b/tests/snapshots/PluginMessageCtor/text-element.json index 67cce345cd2..91da4e3e971 100644 --- a/tests/snapshots/PluginMessageCtor/text-element.json +++ b/tests/snapshots/PluginMessageCtor/text-element.json @@ -182,6 +182,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/timestamp-element.json b/tests/snapshots/PluginMessageCtor/timestamp-element.json index 4d37a94216b..8e42723b218 100644 --- a/tests/snapshots/PluginMessageCtor/timestamp-element.json +++ b/tests/snapshots/PluginMessageCtor/timestamp-element.json @@ -24,7 +24,7 @@ "type": "None", "value": "" }, - "style": "ChatMedium", + "style": "TimestampMedium", "tooltip": "", "trailingSpace": true, "type": "TextElement", @@ -51,7 +51,7 @@ "type": "None", "value": "" }, - "style": "ChatMedium", + "style": "TimestampMedium", "tooltip": "", "trailingSpace": true, "type": "TextElement", @@ -78,7 +78,7 @@ "type": "None", "value": "" }, - "style": "ChatMedium", + "style": "TimestampMedium", "tooltip": "", "trailingSpace": true, "type": "TextElement", @@ -105,7 +105,7 @@ "type": "None", "value": "" }, - "style": "ChatMedium", + "style": "TimestampMedium", "tooltip": "", "trailingSpace": true, "type": "TextElement", @@ -133,6 +133,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } diff --git a/tests/snapshots/PluginMessageCtor/twitch-moderation.json b/tests/snapshots/PluginMessageCtor/twitch-moderation.json index 334eac757fb..2e62e19d558 100644 --- a/tests/snapshots/PluginMessageCtor/twitch-moderation.json +++ b/tests/snapshots/PluginMessageCtor/twitch-moderation.json @@ -65,6 +65,7 @@ "searchText": "", "serverReceivedTime": "", "timeoutUser": "", + "userID": "", "usernameColor": "#ff000000" } } From c16cecfe0290134753ed6ed1127cf1c41589db3b Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Wed, 12 Mar 2025 15:41:48 +0100 Subject: [PATCH 08/17] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a311d2f4c..6c6155c98a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Minor: The font weight of chat messages can now be changed. (#6037) - Minor: Messages from restricted users can now be hidden when in streamer mode. This is enabled by default. (#6042, #6049) - Minor: Added a tab style option allowing you to make your tabs more compact. (#5858) +- Minor: Added basic message API to plugins. (#5754) - Bugfix: Fixed a potential way to escape the Lua Plugin sandbox. (#5846) - Bugfix: Fixed a crash relating to Lua HTTP. (#5800) - Bugfix: Fixed a crash that could occur on Linux and macOS when clicking "Install" from the update prompt. (#5818) @@ -132,7 +133,6 @@ - Minor: Added a setting to hide the scrollbar highlights. (#5732) - Minor: The window layout is now backed up like the other settings. (#5647) - Minor: Added `flags.similar` filter variable, allowing you to filter messages filtered by the R9K feature. (#5747) -- Minor: Added basic message API to plugins. (#5754) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426, #5612) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Bugfix: Fixed restricted users usernames not being clickable. (#5405) From 01aced24d433494ab7788ad6b3db4f687fb2d277 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sat, 10 May 2025 18:52:18 +0200 Subject: [PATCH 09/17] fix: no combined values --- src/controllers/plugins/PluginController.cpp | 16 ++-------------- .../PluginMessageCtor/linebreak-element.json | 4 ++-- tests/src/Plugins.cpp | 11 ----------- 3 files changed, 4 insertions(+), 27 deletions(-) diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 103e0e4b011..8261e950781 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -17,6 +17,7 @@ # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/SolTypes.hpp" # include "messages/MessageBuilder.hpp" +# include "messages/MessageElement.hpp" # include "singletons/Paths.hpp" # include "singletons/Settings.hpp" @@ -230,20 +231,7 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) c2["LogLevel"] = lua::createEnumTable(lua); c2["MessageFlag"] = lua::createEnumTable(lua); - c2["MessageElementFlag"] = - lua::createEnumTable(lua); + c2["MessageElementFlag"] = lua::createEnumTable(lua); c2["FontStyle"] = lua::createEnumTable(lua); c2["MessageContext"] = lua::createEnumTable(lua); diff --git a/tests/snapshots/PluginMessageCtor/linebreak-element.json b/tests/snapshots/PluginMessageCtor/linebreak-element.json index 2159ad9c5e1..536aa436348 100644 --- a/tests/snapshots/PluginMessageCtor/linebreak-element.json +++ b/tests/snapshots/PluginMessageCtor/linebreak-element.json @@ -2,7 +2,7 @@ "input": [ "msg = {elements={", " { type = 'linebreak' },", - " { type = 'linebreak', flags = c2.MessageElementFlag.BttvEmote },", + " { type = 'linebreak', flags = c2.MessageElementFlag.BttvEmoteImage },", " { type = 'linebreak', tooltip = 't' },", " { type = 'linebreak', trailing_space = false },", "}}" @@ -27,7 +27,7 @@ "type": "LinebreakElement" }, { - "flags": "BttvEmoteImage|BttvEmoteText", + "flags": "BttvEmoteImage", "link": { "type": "None", "value": "" diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index e86d2342c32..23a888085ca 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -838,7 +838,6 @@ TEST_F(PluginTest, MessageElementFlag) "BadgeFfz=0x80000," "BadgeGlobalAuthority=0x2000," "BadgePredictions=0x4000," - "Badges=0x30000fe000," "BadgeSevenTV=0x1000000000," "BadgeSharedChannel=0x2000000000," "BadgeSubscription=0x10000," @@ -846,35 +845,25 @@ TEST_F(PluginTest, MessageElementFlag) "BitsAmount=0x200000," "BitsAnimated=0x1000," "BitsStatic=0x800," - "BttvEmote=0xc0," "BttvEmoteImage=0x40," "BttvEmoteText=0x80," "ChannelName=0x100000," "ChannelPointReward=0x100," - "ChannelPointRewardImage=0x110," "Collapsed=0x4000000," - "Default=0x34022fea5e," - "EmojiAll=0x1800000," "EmojiImage=0x800000," "EmojiText=0x1000000," - "EmoteImages=0x400000250," - "EmoteText=0x8000004a0," - "FfzEmote=0x600," "FfzEmoteImage=0x200," "FfzEmoteText=0x400," "LowercaseLinks=0x20000000," "Mention=0x8000000," "Misc=0x1," "ModeratorTools=0x400000," - "None=0x0," "RepliedMessage=0x100000000," "ReplyButton=0x200000000," - "SevenTVEmote=0xc00000000," "SevenTVEmoteImage=0x400000000," "SevenTVEmoteText=0x800000000," "Text=0x2," "Timestamp=0x8," - "TwitchEmote=0x30," "TwitchEmoteImage=0x10," "TwitchEmoteText=0x20," "Username=0x4"; From 11a5fb5ee1163334897e75adaf78327eb28bb12d Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sat, 10 May 2025 18:55:27 +0200 Subject: [PATCH 10/17] fix: tidy --- src/controllers/plugins/api/ChannelRef.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index 1f1662475f1..254b01b7432 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -88,7 +88,7 @@ void ChannelRef::add_system_message(QString text) this->strong()->addSystemMessage(text); } -void ChannelRef::add_message(std::shared_ptr &msg, +void ChannelRef::add_message(std::shared_ptr &message, sol::variadic_args va) { MessageContext ctx = [&] { @@ -111,7 +111,7 @@ void ChannelRef::add_message(std::shared_ptr &msg, return {}; }(); - this->strong()->addMessage(msg, ctx, overrideFlags); + this->strong()->addMessage(message, ctx, overrideFlags); } bool ChannelRef::is_twitch_channel() From 908b82217cbe05ea2b272916530012a89947de7c Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 20 Jul 2025 17:01:06 +0200 Subject: [PATCH 11/17] feat: add optional improved text wrapping (#6265) See the cmake flag `CHATTERINO_ALLOW_PRIVATE_QT_API` for details on how to test this. --- .clang-tidy | 1 + CHANGELOG.md | 1 + CMakeLists.txt | 1 + src/CMakeLists.txt | 6 +++ src/messages/MessageElement.cpp | 91 +++++++++++++++++++++++++++++++-- 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 7bee4c0b5f9..f06e4e798c4 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -19,6 +19,7 @@ Checks: "-*, -cppcoreguidelines-owning-memory, -cppcoreguidelines-avoid-magic-numbers, -cppcoreguidelines-avoid-const-or-ref-data-members, + -cppcoreguidelines-avoid-do-while, -readability-magic-numbers, -performance-noexcept-move-constructor, -misc-non-private-member-variables-in-classes, diff --git a/CHANGELOG.md b/CHANGELOG.md index acfcd501e0b..cfb6cde32c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ - Dev: Emoji style / set is now stored lowercase (and matched case-insensitively). Changing emoji style from this point on and then running an old version might mean you will use the Twitter emoji style by default. (#6300) - Dev: Refactored `OnceFlag`. (#6237, #6316) - Dev: Bumped clang-format requirement to 19. (#6236) +- Dev: Added optional improved text wrapping through private Qt APIs. (#6265) - Dev: Factored out AUMID to `Version`. (#6321) - Dev: Silenced some warnings when compiling with clang-cl. (#6331) - Dev: Added some commands for forcing a relayout (and related things) in channel views. (#6342) diff --git a/CMakeLists.txt b/CMakeLists.txt index e00856bdb7a..95039fd6551 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,7 @@ option(BUILD_SHARED_LIBS "" OFF) option(CHATTERINO_LTO "Enable LTO for all targets" OFF) option(CHATTERINO_PLUGINS "Enable ALPHA plugin support in Chatterino" ON) option(CHATTERINO_USE_GDI_FONTENGINE "Use the legacy GDI fontengine instead of the new DirectWrite one on Windows (Qt 6.8.0 and later)" ON) +option(CHATTERINO_ALLOW_PRIVATE_QT_API "Allow uses of Qt's private API - when enabling this, Chatterino must use the EXACT Qt version it was compiled against" OFF) option(CHATTERINO_UPDATER "Enable update checks" ON) mark_as_advanced(CHATTERINO_UPDATER) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a278c05b5ab..e547a9c8d58 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -874,6 +874,12 @@ if (CHATTERINO_PLUGINS) target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua sol2::sol2) endif() +if (CHATTERINO_ALLOW_PRIVATE_QT_API) + target_link_libraries(${LIBRARY_PROJECT} PUBLIC Qt${MAJOR_QT_VERSION}::GuiPrivate) + target_compile_definitions(${LIBRARY_PROJECT} PUBLIC + CHATTERINO_WITH_PRIVATE_QT_API) +endif() + if (BUILD_WITH_QTKEYCHAIN) target_link_libraries(${LIBRARY_PROJECT} PUBLIC diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 835754b6947..26d12dccc2e 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -20,6 +20,10 @@ #include #include +#ifdef CHATTERINO_WITH_PRIVATE_QT_API +# include +#endif + namespace chatterino { using namespace literals; @@ -621,13 +625,93 @@ void TextElement::addToContainer(MessageLayoutContainer &container, } } - // we done goofed, we need to wrap the text + // We done goofed, we need to wrap the text. + // If we allow the use of private Qt APIs, we can use Qt's text + // engine to accurately calculate the width of the text. Otherwise, + // we have to fall back to using horizontalAdvance which has some + // corner cases when processing whole words (see #5944). +#ifdef CHATTERINO_WITH_PRIVATE_QT_API + auto font = + app->getFonts()->getFont(this->style_, container.getScale()); + + // This code is similar to the one from QTextEngine::elidedText in + // the mode Qt::ElideRight (because that's essentially what we're + // doing here): https://github.com/qt/qtbase/blob/560bf5a07720eaa8cc589f424743db8ed1f1d902/src/gui/text/qtextengine.cpp#L3145 + // A difference is that, once we detected EOL, we start again. + + // The start of the current line in `word` + qsizetype actualStart = 0; + // This is treated like a view (from `actualStart`) over the word. + // It's a QString because QStackTextEngine doesn't support + // QStringViews as arguments. + QString view = word; + + // This is essentially a loop over every line of text. + do + { + QStackTextEngine engine(view, font); + engine.validate(); // initialize the internal state + + int pos = 0; + int nextBreak = 0; + QFixed currentWidth = 0; + int to = static_cast(view.size()); + bool needsBreak = false; + + // Find the next grapheme boundary (`nextBreak`) at which we + // need to break because the text wouldn't fit into the + // container anymore. + do + { + pos = nextBreak; + + ++nextBreak; + while (nextBreak < engine.layoutData->string.size() && + !engine.attributes()[nextBreak].graphemeBoundary) + { + ++nextBreak; + } + + auto nextWidth = + currentWidth + engine.width(pos, nextBreak - pos); + if (!container.fitsInLine(nextWidth.toReal())) + { + needsBreak = true; + if (pos == 0) + { + // Make sure that we consume at least one glyph. + // So this element will overflow + currentWidth = nextWidth; + } + else + { + // We didn't consume the glyph, it's for the next line + nextBreak = pos; + } + break; + } + currentWidth = nextWidth; + } while (nextBreak < to); + // Now we either processed the whole text or we need to break + container.addElementNoLineBreak(getTextLayoutElement( + word.sliced(actualStart, nextBreak), currentWidth.toReal(), + !needsBreak && this->hasTrailingSpace())); + if (needsBreak) + { + container.breakLine(); + } + + actualStart += nextBreak; + // Update the view + view = QString::fromRawData(word.constData() + actualStart, + word.size() - actualStart); + assert(needsBreak || view.isEmpty()); + } while (!view.isEmpty()); +#else auto textLength = word.length(); int wordStart = 0; width = 0; - // QChar::isHighSurrogate(text[0].unicode()) ? 2 : 1 - for (int i = 0; i < textLength; i++) { auto isSurrogate = word.size() > i + 1 && @@ -663,6 +747,7 @@ void TextElement::addToContainer(MessageLayoutContainer &container, //add the final piece of wrapped text container.addElementNoLineBreak(getTextLayoutElement( word.mid(wordStart), width, this->hasTrailingSpace())); +#endif } } } From 0025fa936d56d02b781dc40ec485fb8ebdd07ad8 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 25 Jul 2025 20:20:32 +0200 Subject: [PATCH 12/17] fix: link is also themeable --- docs/plugin-meta.lua | 3 ++- src/controllers/plugins/api/Message.hpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index cb7f234fe5b..f6dede6d078 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -293,7 +293,7 @@ c2.Message = {} ---@field tooltip? string Tooltip text ---@field trailing_space? boolean Whether to add a trailing space after the element (default: true) ----@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text" and "system" are special values that take the current theme into account +---@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text", "link", and "system" are special values that take the current theme into account ---A table to initialize a new message text element ---@class TextElementInit : MessageElementInitBase @@ -468,6 +468,7 @@ c2.MessageFlag = { ClearChat = 0, EventSub = 0, ModerationAction = 0, + InvalidReplyTarget = 0, } -- End src/messages/MessageFlag.hpp diff --git a/src/controllers/plugins/api/Message.hpp b/src/controllers/plugins/api/Message.hpp index f2493b86ae3..119a6b5238b 100644 --- a/src/controllers/plugins/api/Message.hpp +++ b/src/controllers/plugins/api/Message.hpp @@ -33,7 +33,7 @@ c2.Message = {} ---@field tooltip? string Tooltip text ---@field trailing_space? boolean Whether to add a trailing space after the element (default: true) ----@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text" and "system" are special values that take the current theme into account +---@alias MessageColor "text"|"link"|"system"|string A color for a text element - "text", "link", and "system" are special values that take the current theme into account ---A table to initialize a new message text element ---@class TextElementInit : MessageElementInitBase From 2e8c22369cf4bb9abd7323dbae53ffa2d592178e Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 25 Jul 2025 20:21:02 +0200 Subject: [PATCH 13/17] fix: allow tooltips for all elements --- src/widgets/helper/ChannelView.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 9e4999cd034..b8205036cbf 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -2131,10 +2131,9 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) element->getTooltip(), getTooltipScale(scale))); } } - else + else if (auto *linkElement = dynamic_cast(element)) { auto thumbnailSize = getSettings()->thumbnailSize; - auto *linkElement = dynamic_cast(element); if (linkElement) { if (linkElement->linkInfo()->isPending()) @@ -2145,6 +2144,13 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) this->setLinkInfoTooltip(linkElement->linkInfo()); } } + else + { + this->tooltipWidget_->setOne(TooltipEntry{ + .image = nullptr, + .text = element->getTooltip(), + }); + } this->tooltipWidget_->moveTo( event->globalPosition().toPoint() + QPoint(16, 16), From f6aa00f7034f133549ad8e1972c3153f9655db1e Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 25 Jul 2025 20:21:29 +0200 Subject: [PATCH 14/17] docs: expand on `lua-fragment` --- scripts/make_luals_meta.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index 2b0076da481..14f8a2b2c19 100755 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -33,7 +33,8 @@ ---@class foo ... */ -will be inserted as-is. +will be inserted as-is. These only work in the top-level scope. They don't work +for class bodies. """ from io import TextIOWrapper @@ -391,7 +392,7 @@ def inline_command(path: Path, line: int, comment: str, out: TextIOWrapper): "Unexpected @lua@class command. @lua@class must be placed at the start of the comment block!", ) elif comment.startswith("@lua@"): - out.write(f'---{comment.replace("@lua", "", 1)}\n') + out.write(f"---{comment.replace('@lua', '', 1)}\n") if __name__ == "__main__": From 43b64d7eb63784e4e68d317e86721331236476f3 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 25 Jul 2025 20:21:49 +0200 Subject: [PATCH 15/17] fix: use a helper to access required fields --- src/controllers/plugins/api/Message.cpp | 27 +++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/controllers/plugins/api/Message.cpp b/src/controllers/plugins/api/Message.cpp index f905fad8c9e..3d63f9d344a 100644 --- a/src/controllers/plugins/api/Message.cpp +++ b/src/controllers/plugins/api/Message.cpp @@ -51,10 +51,23 @@ MessageColor tryMakeMessageColor(const QString &name, return QColor(name); } +template +T requiredGet(const sol::table &tbl, auto &&key) +{ + auto v = tbl.get>(std::forward(key)); + if (!v) + { + throw std::runtime_error(std::string{"Missing required property: "} + + key); + } + return *std::move(v); +} + std::unique_ptr textElementFromTable(const sol::table &tbl) { return std::make_unique( - tbl["text"], tbl.get_or("flags", MessageElementFlag::Text), + requiredGet(tbl, "text"), + tbl.get_or("flags", MessageElementFlag::Text), tryMakeMessageColor(tbl.get_or("color", QString{})), tbl.get_or("style", FontStyle::ChatMedium)); } @@ -63,7 +76,8 @@ std::unique_ptr singleLineTextElementFromTable( const sol::table &tbl) { return std::make_unique( - tbl["text"], tbl.get_or("flags", MessageElementFlag::Text), + requiredGet(tbl, "text"), + tbl.get_or("flags", MessageElementFlag::Text), tryMakeMessageColor(tbl.get_or("color", QString{})), tbl.get_or("style", FontStyle::ChatMedium)); } @@ -72,9 +86,10 @@ std::unique_ptr mentionElementFromTable(const sol::table &tbl) { // no flags! return std::make_unique( - tbl.get("display_name"), tbl.get("login_name"), - tryMakeMessageColor(tbl.get("fallback_color")), - tryMakeMessageColor(tbl.get("user_color"))); + requiredGet(tbl, "display_name"), + requiredGet(tbl, "login_name"), + tryMakeMessageColor(requiredGet(tbl, "fallback_color")), + tryMakeMessageColor(requiredGet(tbl, "user_color"))); } std::unique_ptr timestampElementFromTable( @@ -111,7 +126,7 @@ std::unique_ptr replyCurveElementFromTable() std::unique_ptr elementFromTable(const sol::table &tbl) { - QString type = tbl["type"]; + auto type = requiredGet(tbl, "type"); std::unique_ptr el; if (type == u"text") { From 6a280cf342e9aa97f981fb0fddfcd2bc2246daac Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 25 Jul 2025 20:22:06 +0200 Subject: [PATCH 16/17] docs: add TS types --- docs/chatterino.d.ts | 208 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 3c7690fd25a..77e47072cbd 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -57,6 +57,12 @@ declare namespace c2 { add_system_message(message: string): void; + add_message( + message: Message, + context?: MessageContext, + override_flags?: MessageFlag | null + ): void; + is_twitch_channel(): boolean; get_room_modes(): RoomModes; @@ -152,4 +158,206 @@ declare namespace c2 { ) => WebSocket; } var WebSocket: WebSocketConstructor; + + interface Message { + __dummy: void; // avoid being an empty interface + } + interface MessageConstructor { + new: (this: void, init: MessageInit) => Message; + } + var Message: MessageConstructor; + + interface MessageInit { + flags?: MessageFlag; + id?: string; + parse_time?: number; + search_text?: string; + message_text?: string; + login_name?: string; + display_name?: string; + localized_name?: string; + user_id?: string; + channel_name?: string; + username_color?: string; + server_received_time?: number; + highlight_color?: string | null; + elements?: MessageElementInit[]; + } + + interface MessageElementInitBase { + tooltip?: string; + trailing_space?: boolean; + } + + type MessageColor = "text" | "link" | "system" | string; + + type MessageElementInit = + | TextElementInit + | SingleLineTextElementInit + | MentionElementInit + | TimestampElementInit + | TwitchModerationElementInit + | LinebreakElementInit + | ReplyCurveElementInit; + + interface TextElementInit extends MessageElementInitBase { + type: "text"; + text: string; + flags?: MessageElementFlag; + color?: MessageColor; + style?: FontStyle; + } + + interface SingleLineTextElementInit extends MessageElementInitBase { + type: "single-line-text"; + text: string; + flags?: MessageElementFlag; + color?: MessageColor; + style?: FontStyle; + } + + interface MentionElementInit extends MessageElementInitBase { + type: "mention"; + display_name: string; + login_name: string; + fallback_color: MessageColor; + user_color: MessageColor; + } + + interface TimestampElementInit extends MessageElementInitBase { + type: "timestamp"; + time?: number; + } + + interface TwitchModerationElementInit extends MessageElementInitBase { + type: "twitch-moderation"; + } + + interface LinebreakElementInit extends MessageElementInitBase { + type: "linebreak"; + flags?: MessageElementFlag; + } + + interface ReplyCurveElementInit extends MessageElementInitBase { + type: "reply-curve"; + } + + enum MessageFlag { + None = 0, + System = 0, + Timeout = 0, + Highlighted = 0, + DoNotTriggerNotification = 0, + Centered = 0, + Disabled = 0, + DisableCompactEmotes = 0, + Collapsed = 0, + ConnectedMessage = 0, + DisconnectedMessage = 0, + Untimeout = 0, + PubSub = 0, + Subscription = 0, + DoNotLog = 0, + AutoMod = 0, + RecentMessage = 0, + Whisper = 0, + HighlightedWhisper = 0, + Debug = 0, + Similar = 0, + RedeemedHighlight = 0, + RedeemedChannelPointReward = 0, + ShowInMentions = 0, + FirstMessage = 0, + ReplyMessage = 0, + ElevatedMessage = 0, + SubscribedThread = 0, + CheerMessage = 0, + LiveUpdatesAdd = 0, + LiveUpdatesRemove = 0, + LiveUpdatesUpdate = 0, + AutoModOffendingMessageHeader = 0, + AutoModOffendingMessage = 0, + LowTrustUsers = 0, + RestrictedMessage = 0, + MonitoredMessage = 0, + Action = 0, + SharedMessage = 0, + AutoModBlockedTerm = 0, + ClearChat = 0, + EventSub = 0, + ModerationAction = 0, + InvalidReplyTarget = 0, + } + + enum MessageElementFlag { + None = 0, + Misc = 0, + Text = 0, + Username = 0, + Timestamp = 0, + TwitchEmoteImage = 0, + TwitchEmoteText = 0, + TwitchEmote = 0, + BttvEmoteImage = 0, + BttvEmoteText = 0, + BttvEmote = 0, + ChannelPointReward = 0, + ChannelPointRewardImage = 0, + FfzEmoteImage = 0, + FfzEmoteText = 0, + FfzEmote = 0, + SevenTVEmoteImage = 0, + SevenTVEmoteText = 0, + SevenTVEmote = 0, + EmoteImages = 0, + EmoteText = 0, + BitsStatic = 0, + BitsAnimated = 0, + BadgeSharedChannel = 0, + BadgeGlobalAuthority = 0, + BadgePredictions = 0, + BadgeChannelAuthority = 0, + BadgeSubscription = 0, + BadgeVanity = 0, + BadgeChatterino = 0, + BadgeSevenTV = 0, + BadgeFfz = 0, + Badges = 0, + ChannelName = 0, + BitsAmount = 0, + ModeratorTools = 0, + EmojiImage = 0, + EmojiText = 0, + EmojiAll = 0, + AlwaysShow = 0, + Collapsed = 0, + Mention = 0, + LowercaseLinks = 0, + RepliedMessage = 0, + ReplyButton = 0, + Default = 0, + } + + enum FontStyle { + Tiny, + ChatSmall, + ChatMediumSmall, + ChatMedium, + ChatMediumBold, + ChatMediumItalic, + ChatLarge, + ChatVeryLarge, + TimestampMedium, + UiMedium, + UiMediumBold, + UiTabs, + EndType, + ChatStart, + ChatEnd, + } + + enum MessageContext { + Original, + Repost, + } } From 7347175b6ca9508c757153960d80522c0dc883fc Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 25 Jul 2025 20:22:15 +0200 Subject: [PATCH 17/17] docs: add prose docs --- docs/wip-plugins.md | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 777036b3e83..664ce377c99 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -350,6 +350,24 @@ Example: pajladas:add_system_message("Hello, world!") ``` +#### `Channel:add_message(message[, context[, override_flags]])` + +Add a rich message to a channel. The message can be created with [`Message.new`](#messagenewdata). + +Example: + +```lua +channel:add_message(c2.Message.new({ + id = "myplugin-1234", + elements = { + { + type = "text", + text = "Hello, World!", + } + } +})) +``` + ##### `Channel:is_twitch_channel()` Returns `true` if the channel is a Twitch channel, that is its type name has @@ -503,6 +521,42 @@ request:execute() -- ConnectionRefusedError ``` +#### `Message` + +Allows creation of rich chat messages. This is currently limited but is expected to be expanded soon. + +##### `Message.new(data)` + +Creates a new message from a table. The message can be added to a channel using +[`Channel:add_message`](#channeladd_messagemessage-context-override_flags): + +```lua +c2.register_command("/testing", function(ctx) + ctx.channel:add_message(c2.Message.new({ + id = "myplugin-1234", + highlight_color = "#80ff0000", + flags = c2.MessageFlag.Highlighted, + elements = { + { + type = "text", + color = "link", + text = "Hover me!", + tooltip = "

This is text from my plugin

" + }, + { + type = "mention", + display_name = "@User", + login_name = "twitchdev", + fallback_color = "text", + user_color = "blue", + } + } + })) +end) +``` + +The full range of options can be found in the typing files ([LuaLS](./plugin-meta.lua), [TypeScript](./chatterino.d.ts)). + ### Input/Output API These functions are wrappers for Lua's I/O library. Functions on file pointer