diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c7786a6d8..79a24d8c194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Minor: Badges now link to their home page like emotes in the context menu. (#6437) - Minor: Fixed usercard resizing improperly without recent messages. (#6496) - Minor: Added setting for character limit of deleted messages. (#6491) +- Minor: Added message introspection and manipulation. (#6353) - Minor: Added link support to plugin message API. (#6386, #6527) - Minor: Added a description for the logging option under moderation tab. (#6514) - Minor: Added a JSON API for plugins (`require('chatterino.json')`). (#6420) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 9385d55f9d8..e5af040167c 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -160,8 +160,24 @@ declare namespace c2 { var WebSocket: WebSocketConstructor; interface Message { - __dummy: void; // avoid being an empty interface + 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; + frozen: boolean; + elements(): MessageElement[]; + append_element(init: MessageElementInit): void; } + interface MessageConstructor { new: (this: void, init: MessageInit) => Message; } @@ -184,6 +200,14 @@ declare namespace c2 { elements?: MessageElementInit[]; } + interface MessageElementBase { + flags: MessageElementFlag; + tooltip: string; + trailing_space: boolean; + link: Link; + add_flags(flags: MessageElementFlag): void; + } + interface MessageElementInitBase { tooltip?: string; trailing_space?: boolean; @@ -192,6 +216,25 @@ declare namespace c2 { type MessageColor = "text" | "link" | "system" | string; + type MessageElement = + | TextElement + | SingleLineTextElement + | MentionElement + | TimestampElement + | TwitchModerationElement + | LinebreakElement + | ReplyCurveElement + | LinkElement + | EmoteElement + | LayeredEmoteElement + | ImageElement + | CircularImageElement + | ScalingImageElement + | BadgeElement + | ModBadgeElement + | VipBadgeElement + | FfzBadgeElement; + type MessageElementInit = | TextElementInit | SingleLineTextElementInit @@ -201,6 +244,13 @@ declare namespace c2 { | LinebreakElementInit | ReplyCurveElementInit; + interface TextElement extends MessageElementBase { + type: "text"; + words: string[]; + color: string; + style: c2.FontStyle; + } + interface TextElementInit extends MessageElementInitBase { type: "text"; text: string; @@ -209,6 +259,13 @@ declare namespace c2 { style?: FontStyle; } + interface SingleLineTextElement extends MessageElementBase { + type: "single-line-text"; + words: string[]; + color: string; + style: c2.FontStyle; + } + interface SingleLineTextElementInit extends MessageElementInitBase { type: "single-line-text"; text: string; @@ -217,6 +274,14 @@ declare namespace c2 { style?: FontStyle; } + interface MentionElement extends Omit { + type: "mention"; + display_name: string; + login_name: string; + fallback_color: string; + user_color: string; + } + interface MentionElementInit extends MessageElementInitBase { type: "mention"; display_name: string; @@ -225,24 +290,83 @@ declare namespace c2 { user_color: MessageColor; } + interface TimestampElement extends MessageElementBase { + type: "timestamp"; + time: number; + } + interface TimestampElementInit extends MessageElementInitBase { type: "timestamp"; time?: number; } + interface TwitchModerationElement extends MessageElementBase { + type: "twitch-moderation"; + } + interface TwitchModerationElementInit extends MessageElementInitBase { type: "twitch-moderation"; } + interface LinebreakElement extends MessageElementBase { + type: "linebreak"; + } + interface LinebreakElementInit extends MessageElementInitBase { type: "linebreak"; flags?: MessageElementFlag; } + interface ReplyCurveElement extends MessageElementBase { + type: "reply-curve"; + } + interface ReplyCurveElementInit extends MessageElementInitBase { type: "reply-curve"; } + interface LinkElement extends Omit { + type: "link"; + lowercase: string; + original: string; + } + + interface EmoteElement extends MessageElementBase { + type: "emote"; + } + + interface LayeredEmoteElement extends MessageElementBase { + type: "layered-emote"; + } + + interface ImageElement extends MessageElementBase { + type: "image"; + } + + interface CircularImageElement extends MessageElementBase { + type: "circular-image"; + } + + interface ScalingImageElement extends MessageElementBase { + type: "scaling-image"; + } + + interface BadgeElement extends MessageElementBase { + type: "badge"; + } + + interface ModBadgeElement extends Omit { + type: "mod-badge"; + } + + interface VipBadgeElement extends Omit { + type: "ffz-badge"; + } + + interface FfzBadgeElement extends Omit { + type: "ffz-badge"; + } + interface Link { type: LinkType; value: string; diff --git a/docs/lua-meta/globals.lua b/docs/lua-meta/globals.lua index 599342f2c71..d9ef5a1a1d5 100644 --- a/docs/lua-meta/globals.lua +++ b/docs/lua-meta/globals.lua @@ -299,26 +299,19 @@ function c2.HTTPRequest.create(method, url) end -- 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 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) ----@field highlight_color? string|nil The color of the highlight (if any) ----@field elements? MessageElementInit[] The elements of the message +---@class c2.MessageElementBase +---@field flags c2.MessageElementFlag The element's flags +---@field tooltip string The tooltip (if any) +---@field trailing_space boolean Whether to add a trailing space after the element +---@field link c2.Link An action when clicking on this element. Mention and Link elements don't support this. They manage the link themselves. +c2.MessageElementBase = {} +-- ^^^ this is kinda fake - this table doesn't exist in Lua, we only declare it to add methods + +--- Add flags to this element +--- +---@param flags c2.MessageElementFlag +function c2.MessageElementBase:add_flags(flags) end ---A base table to initialize a new message element ---@class MessageElementInitBase @@ -326,7 +319,11 @@ c2.Message = {} ---@field trailing_space? boolean Whether to add a trailing space after the element (default: true) ---@field link? c2.Link An action when clicking on this element. Mention and Link elements don't support this. They manage the link themselves. ----@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 +---@class c2.TextElement : c2.MessageElementBase +---@field type "text" +---@field words string[] The words of this element +---@field color string The color of the text +---@field style c2.FontStyle The font style of the text ---A table to initialize a new message text element ---@class TextElementInit : MessageElementInitBase @@ -336,6 +333,12 @@ c2.Message = {} ---@field color? MessageColor The color of the text ---@field style? c2.FontStyle The font style of the text +---@class c2.SingleLineTextElement : c2.MessageElementBase +---@field type "single-line-text" +---@field words string[] The words of this element +---@field color string 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 @@ -344,6 +347,12 @@ c2.Message = {} ---@field color? MessageColor The color of the text ---@field style? c2.FontStyle The font style of the text +---@class c2.MentionElement : c2.TextElement +---@field type "mention" +---@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 mention element ---@class MentionElementInit : MessageElementInitBase ---@field type "mention" The type of the element @@ -352,26 +361,117 @@ c2.Message = {} ---@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 +---@class c2.TimestampElement : c2.MessageElementBase +---@field type "timestamp" +---@field time number The time of the timestamp (in milliseconds since epoch). + ---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. +---@class c2.TwitchModerationElement : c2.MessageElementBase +---@field type "twitch-moderation" + ---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 +---@class c2.LinebreakElement : c2.MessageElementBase +---@field type "linebreak" + ---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`) +---@class c2.ReplyCurveElement : c2.MessageElementBase +---@field type "reply-curve" + ---A table to initialize a new reply curve element ---@class ReplyCurveElementInit : MessageElementInitBase ---@field type "reply-curve" The type of the element +---@class c2.LinkElement : c2.TextElement +---@field type "link" + +---@class c2.EmoteElement : c2.MessageElementBase +---@field type "emote" + +---@class c2.LayeredEmoteElement : c2.MessageElementBase +---@field type "layered-emote" + +---@class c2.ImageElement : c2.MessageElementBase +---@field type "image" + +---@class c2.CircularImageElement : c2.MessageElementBase +---@field type "circular-image" + +---@class c2.ScalingImageElement : c2.MessageElementBase +---@field type "scaling-image" + +---@class c2.BadgeElement : c2.MessageElementBase +---@field type "badge" + +---@class c2.ModBadgeElement : c2.BadgeElement +---@field type "mod-badge" + +---@class c2.VipBadgeElement : c2.BadgeElement +---@field type "vip-badge" + +---@class c2.FfzBadgeElement : c2.BadgeElement +---@field type "ffz-badge" + +---@alias MessageElement c2.TextElement|c2.SingleLineTextElement|c2.MentionElement|c2.TimestampElement|c2.TwitchModerationElement|c2.LinebreakElement|c2.ReplyCurveElement|c2.LinkElement|c2.EmoteElement|c2.LayeredEmoteElement|c2.ImageElement|c2.CircularImageElement|c2.ScalingImageElement|c2.BadgeElement|c2.ModBadgeElement|c2.VipBadgeElement|c2.FfzBadgeElement ---@alias MessageElementInit TextElementInit|SingleLineTextElementInit|MentionElementInit|TimestampElementInit|TwitchModerationElementInit|LinebreakElementInit|ReplyCurveElementInit +---A chat message +---@class c2.Message +---@field flags c2.MessageFlag The message's flags +---@field parse_time number Time the message was parsed (in milliseconds since epoch) +---@field id string The message ID +---@field search_text string Text to check when searching for messages +---@field message_text string Text content of this message (used for filters for example) +---@field login_name string The login name of the sender +---@field display_name string The dispay 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 sender +---@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 The color of the highlight or empty +---@field frozen boolean If this is set, Lua plugins can't modify this message (as it's visible to the user). +c2.Message = {} + +--- The elements this message is made up of +--- +---@return MessageElement[] elements +function c2.Message:elements() end + +--- Add an element to this message. +--- +---@param init MessageElementInit The element to add +function c2.Message:append_element(init) end + +---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 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) +---@field highlight_color? string|nil The color of the highlight (if any) +---@field elements? MessageElementInit[] The elements of the message + +---@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 + --- Creates a new message --- ---@param init MessageInit The message initialization table diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 17b8091b296..00bfb74f2f9 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -620,7 +620,26 @@ Closes the WebSocket connection. #### `Message` -Allows creation of rich chat messages. This is currently limited but is expected to be expanded soon. +Allows creation and modification of rich chat messages. A `Message` is +Chatterino's representation of a chat message or any system message. +The interface to Lua is currently limited but is expected to be expanded soon. + +Messages can be added to a [`Channel`](#channel). Once a message is added to a +channel, it can't be modified anymore (except for its `flags`). These messages +are termed "frozen" (immutable). You can check for this using the `frozen` +property: + +```lua +local my_msg = c2.Message.new({ id = "foobar" }) +assert(not my_msg.frozen) +my_channel:add_message(my_msg) +assert(my_msg.frozen) +``` + +A message has the same properties that it takes in its constructor (e.g. +`msg.id` or `msg.flags`). If the message is not frozen (mutable), these +properties can be modified. New elements can be added with +[`append_element`](#messageappend_elementdata). ##### `Message.new(data)` @@ -654,6 +673,58 @@ end) The full range of options can be found in the typing files ([LuaLS](./lua-meta/globals.lua), [TypeScript](./chatterino.d.ts)). +##### `Message:elements()` + +Gets a reference to the message's elements. This can be indexed or iterated +though. Through the return value, you can also remove elements. Note that +currently, new elements can't be added here (use `Message:append_element`). + +The return value can be thought of as a table of `MessageElement`s. + +```lua +local my_msg = c2.Message.new({ + id = "foo", + elements = { + { type = "text", text = "foo" }, + { type = "text", text = "bar" }, + { type = "text", text = "baz" }, + } +}) +local elements = my_msg:elements() +assert(#elements == 3) +assert(elements[1].words[1] == "foo") +assert(elements[2].words[1] == "bar") + +elements:erase(2) -- erase "bar" +assert(#elements == 2) +assert(elements[1].words[1] == "foo") +assert(elements[2].words[1] == "baz") +``` + +##### `Message:append_element(data)` + +Creates a message element from a table (`data`) and appends it to the message. +The structure of the table matches the one taken in `Message.new`'s `elements`. + +```lua +local my_msg = c2.Message.new({ id = "foo" }) +my_msg:append_element({ + type = "text", + text = "My text element", +}) +``` + +#### `MessageElement` + +A reference to an element inside a `Message` (essentially an in index into the +elements). Note that modifications to the elements of the parent `Message` might +change the actual element this is referring to. + +To distinguish different types of elements, check the `type` property. This +takes the same values as `element.type` in the `Message` constructor. + +The full range of properties can be found in the typing files ([LuaLS](./lua-meta/globals.lua), [TypeScript](./chatterino.d.ts)). + #### `LinkType` enum This table describes links available to plugins. diff --git a/src/controllers/plugins/api/Message.cpp b/src/controllers/plugins/api/Message.cpp index b739da8d909..e7200314c45 100644 --- a/src/controllers/plugins/api/Message.cpp +++ b/src/controllers/plugins/api/Message.cpp @@ -27,29 +27,6 @@ QDateTime datetimeFromOffset(qint64 offset) return dt; } -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); -} - template T requiredGet(const sol::table &tbl, auto &&key) { @@ -67,7 +44,7 @@ std::unique_ptr textElementFromTable(const sol::table &tbl) return std::make_unique( requiredGet(tbl, "text"), tbl.get_or("flags", MessageElementFlag::Text), - tryMakeMessageColor(tbl.get_or("color", QString{})), + MessageColor::fromLua(tbl.get_or("color", QString{})), tbl.get_or("style", FontStyle::ChatMedium)); } @@ -77,7 +54,7 @@ std::unique_ptr singleLineTextElementFromTable( return std::make_unique( requiredGet(tbl, "text"), tbl.get_or("flags", MessageElementFlag::Text), - tryMakeMessageColor(tbl.get_or("color", QString{})), + MessageColor::fromLua(tbl.get_or("color", QString{})), tbl.get_or("style", FontStyle::ChatMedium)); } @@ -87,8 +64,8 @@ std::unique_ptr mentionElementFromTable(const sol::table &tbl) return std::make_unique( requiredGet(tbl, "display_name"), requiredGet(tbl, "login_name"), - tryMakeMessageColor(requiredGet(tbl, "fallback_color")), - tryMakeMessageColor(requiredGet(tbl, "user_color"))); + MessageColor::fromLua(requiredGet(tbl, "fallback_color")), + MessageColor::fromLua(requiredGet(tbl, "user_color"))); } std::unique_ptr timestampElementFromTable( @@ -123,37 +100,75 @@ std::unique_ptr replyCurveElementFromTable() return std::make_unique(); } +void setLinkOn(MessageElement *el, const Link &link) +{ + el->setLink(link); + QString tooltip; + + switch (link.type) + { + case Link::Url: + tooltip = QString("URL: %1").arg(link.value); + break; + case Link::UserAction: + tooltip = QString("Command: %1").arg(link.value); + break; + case Link::CopyToClipboard: + tooltip = "Copy to clipboard"; + break; + + // these links should be safe to click as they don't have any immediate action associated with them + case Link::InsertText: + case Link::JumpToChannel: + case Link::JumpToMessage: + case Link::UserInfo: + case Link::UserWhisper: + case Link::ReplyToMessage: + break; + + // these types are not exposed to plugins + case Link::None: + case Link::AutoModAllow: + case Link::AutoModDeny: + case Link::OpenAccountsPage: + case Link::Reconnect: + case Link::ViewThread: + throw std::runtime_error("Invalid link type. How'd this happen?"); + } + el->setTooltip(tooltip); +} + std::unique_ptr elementFromTable(const sol::table &tbl) { - auto type = requiredGet(tbl, "type"); + auto type = requiredGet(tbl, "type"); std::unique_ptr el; bool linksAllowed = true; - if (type == u"text") + if (type == TextElement::TYPE) { el = textElementFromTable(tbl); } - else if (type == u"single-line-text") + else if (type == SingleLineTextElement::TYPE) { el = singleLineTextElementFromTable(tbl); } - else if (type == u"mention") + else if (type == MentionElement::TYPE) { el = mentionElementFromTable(tbl); linksAllowed = false; } - else if (type == u"timestamp") + else if (type == TimestampElement::TYPE) { el = timestampElementFromTable(tbl); } - else if (type == u"twitch-moderation") + else if (type == TwitchModerationElement::TYPE) { el = twitchModerationElementFromTable(); } - else if (type == u"linebreak") + else if (type == LinebreakElement::TYPE) { el = linebreakElementFromTable(tbl); } - else if (type == u"reply-curve") + else if (type == ReplyCurveElement::TYPE) { el = replyCurveElementFromTable(); linksAllowed = false; @@ -171,44 +186,10 @@ std::unique_ptr elementFromTable(const sol::table &tbl) { if (!linksAllowed) { - throw std::runtime_error("'link' not supported on type='" + - type.toStdString() + '\''); + throw std::runtime_error("'link' not supported on type='" + type + + '\''); } - el->setLink(*link); - QString tooltip; - - switch (link->type) - { - case Link::Url: - tooltip = QString("URL: %1").arg(link->value); - break; - case Link::UserAction: - tooltip = QString("Command: %1").arg(link->value); - break; - case Link::CopyToClipboard: - tooltip = "Copy to clipboard"; - break; - - // these links should be safe to click as they don't have any immediate action associated with them - case Link::InsertText: - case Link::JumpToChannel: - case Link::JumpToMessage: - case Link::UserInfo: - case Link::UserWhisper: - case Link::ReplyToMessage: - break; - - // these types are not exposed to plugins - case Link::None: - case Link::AutoModAllow: - case Link::AutoModDeny: - case Link::OpenAccountsPage: - case Link::ViewThread: - case Link::Reconnect: - throw std::runtime_error( - "Invalid link type. How'd this happen?"); - } - el->setTooltip(tooltip); + setLinkOn(el.get(), *link); } else { @@ -282,16 +263,555 @@ std::shared_ptr messageFromTable(const sol::table &tbl) return msg; } +void checkWritable(Message *msg) +{ + if (msg->frozen) + { + throw std::runtime_error("Message is frozen"); + } +} + +template +struct MemberPtrTraits; + +template +struct MemberPtrTraits { + using Type = T; + using Object = C; +}; + +template +decltype(auto) memberAccessor() +{ + return sol::property( + [](Message *msg) { + return msg->*T; + }, + [](Message *msg, typename MemberPtrTraits::Type v) { + checkWritable(msg); + msg->*T = std::forward(v); + }); +} + } // namespace namespace chatterino::lua::api::message { +struct ElementRef { + ElementRef() = default; + ElementRef(std::shared_ptr msg, size_t index) + : msg(std::move(msg)) + , index(index) + { + } + + MessageElement *element() const + { + if (!this->msg || this->index >= this->msg->elements.size()) + { + return nullptr; + } + checkWritable(this->msg.get()); + return this->msg->elements[this->index].get(); + } + + const MessageElement *constElement() const + { + if (!this->msg || this->index >= this->msg->elements.size()) + { + return nullptr; + } + return this->msg->elements[this->index].get(); + } + + MessageElement &ref() const + { + auto *el = this->element(); + if (!el) + { + throw std::runtime_error("Element does not exist or expired"); + } + return *el; + } + + const MessageElement &cref() const + { + const auto *el = this->constElement(); + if (!el) + { + throw std::runtime_error("Element does not exist or expired"); + } + return *el; + } + + /// Cast this element to `T`. Otherwise nullopt is returned. + /// Use `.map()` to access the content. + template + sol::optional as() const + { + // using ref() to error if the reference is invalid + auto *el = dynamic_cast(&this->ref()); + if (!el) + { + return sol::nullopt; + } + return *el; + } + + /// Cast this element to `const T`. Otherwise nullopt is returned. + /// Use `.map()` to access the content. + template + sol::optional asConst() const + { + // using cref() to error if the reference is invalid + const auto *el = dynamic_cast(&this->cref()); + if (!el) + { + return sol::nullopt; + } + return *el; + } + + template + bool is() const + { + return dynamic_cast(&this->cref()) != nullptr; + } + + /// Visit this element by dynamic casting + template + auto visit(auto &&...cb) const + { + static_assert(sizeof...(T) == sizeof...(cb) && sizeof...(T) > 0); + + // infer the returned type inside the optional + using Cb0 = std::tuple_element_t<0, std::tuple>; + using T0 = std::tuple_element_t<0, std::tuple>; + using TReturn = std::invoke_result_t; + + return this->visitOne(std::forward(cb)...); + } + + bool operator==(const ElementRef &rhs) const + { + return this->msg.get() == rhs.msg.get() && this->index == rhs.index; + } + + std::shared_ptr msg; + size_t index = 0; + +private: + template + decltype(auto) maybeConstElement() const + { + if constexpr (Const) + { + return this->constElement(); + } + else + { + return this->element(); + } + } + + /// Run one callback + /// + /// This is called recursively. + /// If the callback returns something, we return an `optional` otherwise + /// we return `void`. + template + auto visitOne(auto &&cb, auto &&...rest) const + -> std::conditional_t, void, + sol::optional> + { + auto *el = + dynamic_cast(this->maybeConstElement>()); + if (!el) + { + if constexpr (sizeof...(rest) == 0) + { + if constexpr (std::is_void_v< + std::invoke_result_t>) + { + return; + } + else + { + return sol::nullopt; + } + } + else + { + return visitOne( + std::forward(rest)...); + } + } + return std::invoke(cb, *el); + } +}; + +struct ElementIterator { + using difference_type = std::ptrdiff_t; + using value_type = ElementRef; + + ElementIterator() = default; + ElementIterator(std::shared_ptr msg, size_t index) + : current(std::move(msg), index) + { + } + + ElementRef operator*() const + { + return this->current; + } + ElementRef operator[](size_t i) const + { + return {this->current.msg, this->current.index + i}; + } + + ElementIterator &operator+=(difference_type diff) + { + this->current.index += diff; + return *this; + } + ElementIterator &operator++() + { + return *this += 1; + } + ElementIterator operator++(int) // postfix increment + { + auto tmp = *this; + ++*this; + return tmp; + } + friend ElementIterator operator+(difference_type diff, + const ElementIterator &it) + { + return {it.current.msg, it.current.index + diff}; + } + friend ElementIterator operator+(const ElementIterator &it, + difference_type diff) + { + return {it.current.msg, it.current.index + diff}; + } + + ElementIterator &operator-=(difference_type diff) + { + this->current.index -= diff; + return *this; + } + ElementIterator &operator--() + { + return *this -= 1; + } + ElementIterator operator--(int) // postfix decrement + { + auto tmp = *this; + --*this; + return tmp; + } + + friend difference_type operator-(const ElementIterator &lhs, + const ElementIterator &rhs) + { + return static_cast(lhs.current.index) - + static_cast(rhs.current.index); + } + friend ElementIterator operator-(difference_type diff, + const ElementIterator &it) + { + return {it.current.msg, it.current.index - diff}; + } + friend ElementIterator operator-(const ElementIterator &it, + difference_type diff) + { + return {it.current.msg, it.current.index - diff}; + } + + bool operator==(const ElementIterator &rhs) const + { + return this->current == rhs.current; + } + bool operator<(const ElementIterator &rhs) const + { + return this->current.index < rhs.current.index; + } + bool operator<=(const ElementIterator &rhs) const + { + return this->current.index <= rhs.current.index; + } + bool operator>(const ElementIterator &rhs) const + { + return this->current.index > rhs.current.index; + } + bool operator>=(const ElementIterator &rhs) const + { + return this->current.index >= rhs.current.index; + } + + ElementRef current; +}; + +static_assert(std::random_access_iterator); + +struct MessageElements { + using value_type = ElementIterator::value_type; + using iterator = ElementIterator; + using size_type = size_t; + + explicit MessageElements(std::shared_ptr msg) + : msg(std::move(msg)) + { + } + ~MessageElements() = default; + + MessageElements(const MessageElements &) = delete; + MessageElements(MessageElements &&) = default; + MessageElements &operator=(const MessageElements &) = delete; + MessageElements &operator=(MessageElements &&) = default; + + ElementIterator begin() const + { + return {this->msg, 0}; + } + ElementIterator end() const + { + return {this->msg, this->msg->elements.size()}; + } + + size_type size() const + { + if (!this->msg) + { + return 0; + } + return this->msg->elements.size(); + } + + // NOLINTNEXTLINE + size_type max_size() const + { + return this->size(); // we can't insert + } + + bool empty() const + { + if (!this->msg) + { + return true; + } + return this->msg->elements.empty(); + } + + // NOLINTNEXTLINE + void push_back(ElementIterator::value_type /* v */) const + { + throw std::runtime_error("Insertion is not supported"); + } + + // NOLINTNEXTLINE + void erase(ElementIterator it) const + { + if (it.current.msg != this->msg || !this->msg || + it.current.index >= this->msg->elements.size()) + { + throw std::runtime_error("Can't erase here"); + } + checkWritable(this->msg.get()); + this->msg->elements.erase(this->msg->elements.begin() + + static_cast(it.current.index)); + } + + std::shared_ptr msg; +}; + void createUserType(sol::table &c2) { - c2.new_usertype("Message", - sol::factories([](const sol::table &tbl) { - return messageFromTable(tbl); - })); + c2.new_usertype( + "MessageElement", sol::no_constructor, // + "type", sol::property([](const ElementRef &el) { + return el.cref().type(); + }), + "flags", sol::property([](const ElementRef &el) { + return el.cref().getFlags().value(); + }), + "add_flags", + [](const ElementRef &el, MessageElementFlag flag) { + el.ref().addFlags(flag); + }, + "link", + sol::property( + [](const ElementRef &el) { + return el.cref().getLink(); + }, + [](const ElementRef &el, const Link &link) { + if (el.is() || el.is()) + { + throw std::runtime_error( + "Setting a link on this element is unsupported"); + } + setLinkOn(&el.ref(), link); + }), + "tooltip", + sol::property( + [](const ElementRef &el) { + return el.cref().getTooltip(); + }, + [](const ElementRef &el, const QString &tooltip) { + el.ref().setTooltip(tooltip); + }), + "trailing_space", + sol::property( + [](const ElementRef &el) { + return el.cref().hasTrailingSpace(); + }, + [](const ElementRef &el, bool trailingSpace) { + el.ref().setTrailingSpace(trailingSpace); + }), + "padding", sol::property([](const ElementRef &el) { + return el.asConst().map( + &CircularImageElement::padding); + }), + "background", sol::property([](const ElementRef &el) { + return el.as().map( + [](const CircularImageElement &el) { + return el.background().name(QColor::HexArgb); + }); + }), + "words", sol::property([](const ElementRef &el) { + return el.visit( + &TextElement::words, &SingleLineTextElement::words); + }), + "color", sol::property([](const ElementRef &el) { + return el.visit( + [](const TextElement &el) { + return el.color().toLua(); + }, + [](const SingleLineTextElement &el) { + return el.color().toLua(); + }); + }), + "style", sol::property([](const ElementRef &el) { + return el.visit( + &TextElement::fontStyle, &SingleLineTextElement::fontStyle); + }), + "lowercase", sol::property([](const ElementRef &el) { + return el.asConst().map(&LinkElement::lowercase); + }), + "original", sol::property([](const ElementRef &el) { + return el.asConst().map(&LinkElement::original); + }), + "fallback_color", sol::property([](const ElementRef &el) { + return el.asConst().map( + [](const MentionElement &el) { + return el.fallbackColor().toLua(); + }); + }), + "user_color", sol::property([](const ElementRef &el) { + return el.asConst().map( + [](const MentionElement &el) { + return el.userColor().toLua(); + }); + }), + "user_login_name", sol::property([](const ElementRef &el) { + return el.asConst().map( + &MentionElement::userLoginName); + }), + "time", sol::property([](const ElementRef &el) { + return el.asConst().map( + [](const TimestampElement &el) { + return QDateTime(QDate::currentDate(), el.time()) + .toMSecsSinceEpoch(); + }); + })); + + c2.new_usertype( + "Message", sol::factories([](const sol::table &tbl) { + return messageFromTable(tbl); + }), + "flags", + sol::property( + [](Message *msg) { + return msg->flags.value(); + }, + [](Message *msg, MessageFlag f) { + // flags are always mutable + msg->flags = f; + }), + "parse_time", + sol::property( + [](Message *msg) { + return QDateTime(QDate::currentDate(), msg->parseTime) + .toMSecsSinceEpoch(); + }, + [](Message *msg, qint64 ms) { + checkWritable(msg); + msg->parseTime = datetimeFromOffset(ms).time(); + }), + "id", memberAccessor<&Message::id>(), // + "search_text", memberAccessor<&Message::searchText>(), // + "message_text", memberAccessor<&Message::messageText>(), // + "login_name", memberAccessor<&Message::loginName>(), // + "display_name", memberAccessor<&Message::displayName>(), // + "localized_name", memberAccessor<&Message::localizedName>(), // + "user_id", memberAccessor<&Message::userID>(), // + "channel_name", memberAccessor<&Message::channelName>(), // + "username_color", + sol::property( + [](Message *msg) { + return msg->usernameColor.name(QColor::HexArgb); + }, + [](Message *msg, std::string_view sv) { + checkWritable(msg); + msg->usernameColor = QColor::fromString(sv); + }), + "server_received_time", + sol::property( + [](Message *msg) { + return msg->serverReceivedTime.toMSecsSinceEpoch(); + }, + [](Message *msg, qint64 ms) { + checkWritable(msg); + msg->serverReceivedTime = datetimeFromOffset(ms); + }), + "highlight_color", + sol::property( + [](Message *msg) { + if (!msg->highlightColor) + { + return QString{}; + } + return msg->highlightColor->name(QColor::HexArgb); + }, + [](Message *msg, std::string_view sv) { + checkWritable(msg); + if (sv.empty()) + { + msg->highlightColor.reset(); + } + else + { + msg->highlightColor = + std::make_shared(QColor::fromString(sv)); + } + }), + // must be read only (but it might be helpful for generic Lua functions) + "frozen", sol::property([](Message *msg) { + return msg->frozen; + }), + "elements", + [](const std::shared_ptr &msg) { + return MessageElements(msg); + }, + "append_element", + [](Message *msg, const sol::table &tbl) { + checkWritable(msg); + auto el = elementFromTable(tbl); + if (el) + { + msg->elements.emplace_back(std::move(el)); + } + }); } } // namespace chatterino::lua::api::message diff --git a/src/controllers/plugins/api/Message.hpp b/src/controllers/plugins/api/Message.hpp index 650ca8dc6bf..6cf9beab014 100644 --- a/src/controllers/plugins/api/Message.hpp +++ b/src/controllers/plugins/api/Message.hpp @@ -10,26 +10,19 @@ 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 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) ----@field highlight_color? string|nil The color of the highlight (if any) ----@field elements? MessageElementInit[] The elements of the message +---@class c2.MessageElementBase +---@field flags c2.MessageElementFlag The element's flags +---@field tooltip string The tooltip (if any) +---@field trailing_space boolean Whether to add a trailing space after the element +---@field link c2.Link An action when clicking on this element. Mention and Link elements don't support this. They manage the link themselves. +c2.MessageElementBase = {} +-- ^^^ this is kinda fake - this table doesn't exist in Lua, we only declare it to add methods + +--- Add flags to this element +--- +---@param flags c2.MessageElementFlag +function c2.MessageElementBase:add_flags(flags) end ---A base table to initialize a new message element ---@class MessageElementInitBase @@ -37,7 +30,11 @@ c2.Message = {} ---@field trailing_space? boolean Whether to add a trailing space after the element (default: true) ---@field link? c2.Link An action when clicking on this element. Mention and Link elements don't support this. They manage the link themselves. ----@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 +---@class c2.TextElement : c2.MessageElementBase +---@field type "text" +---@field words string[] The words of this element +---@field color string The color of the text +---@field style c2.FontStyle The font style of the text ---A table to initialize a new message text element ---@class TextElementInit : MessageElementInitBase @@ -47,6 +44,12 @@ c2.Message = {} ---@field color? MessageColor The color of the text ---@field style? c2.FontStyle The font style of the text +---@class c2.SingleLineTextElement : c2.MessageElementBase +---@field type "single-line-text" +---@field words string[] The words of this element +---@field color string 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 @@ -55,6 +58,12 @@ c2.Message = {} ---@field color? MessageColor The color of the text ---@field style? c2.FontStyle The font style of the text +---@class c2.MentionElement : c2.TextElement +---@field type "mention" +---@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 mention element ---@class MentionElementInit : MessageElementInitBase ---@field type "mention" The type of the element @@ -63,26 +72,117 @@ c2.Message = {} ---@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 +---@class c2.TimestampElement : c2.MessageElementBase +---@field type "timestamp" +---@field time number The time of the timestamp (in milliseconds since epoch). + ---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. +---@class c2.TwitchModerationElement : c2.MessageElementBase +---@field type "twitch-moderation" + ---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 +---@class c2.LinebreakElement : c2.MessageElementBase +---@field type "linebreak" + ---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`) +---@class c2.ReplyCurveElement : c2.MessageElementBase +---@field type "reply-curve" + ---A table to initialize a new reply curve element ---@class ReplyCurveElementInit : MessageElementInitBase ---@field type "reply-curve" The type of the element +---@class c2.LinkElement : c2.TextElement +---@field type "link" + +---@class c2.EmoteElement : c2.MessageElementBase +---@field type "emote" + +---@class c2.LayeredEmoteElement : c2.MessageElementBase +---@field type "layered-emote" + +---@class c2.ImageElement : c2.MessageElementBase +---@field type "image" + +---@class c2.CircularImageElement : c2.MessageElementBase +---@field type "circular-image" + +---@class c2.ScalingImageElement : c2.MessageElementBase +---@field type "scaling-image" + +---@class c2.BadgeElement : c2.MessageElementBase +---@field type "badge" + +---@class c2.ModBadgeElement : c2.BadgeElement +---@field type "mod-badge" + +---@class c2.VipBadgeElement : c2.BadgeElement +---@field type "vip-badge" + +---@class c2.FfzBadgeElement : c2.BadgeElement +---@field type "ffz-badge" + +---@alias MessageElement c2.TextElement|c2.SingleLineTextElement|c2.MentionElement|c2.TimestampElement|c2.TwitchModerationElement|c2.LinebreakElement|c2.ReplyCurveElement|c2.LinkElement|c2.EmoteElement|c2.LayeredEmoteElement|c2.ImageElement|c2.CircularImageElement|c2.ScalingImageElement|c2.BadgeElement|c2.ModBadgeElement|c2.VipBadgeElement|c2.FfzBadgeElement ---@alias MessageElementInit TextElementInit|SingleLineTextElementInit|MentionElementInit|TimestampElementInit|TwitchModerationElementInit|LinebreakElementInit|ReplyCurveElementInit +---A chat message +---@class c2.Message +---@field flags c2.MessageFlag The message's flags +---@field parse_time number Time the message was parsed (in milliseconds since epoch) +---@field id string The message ID +---@field search_text string Text to check when searching for messages +---@field message_text string Text content of this message (used for filters for example) +---@field login_name string The login name of the sender +---@field display_name string The dispay 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 sender +---@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 The color of the highlight or empty +---@field frozen boolean If this is set, Lua plugins can't modify this message (as it's visible to the user). +c2.Message = {} + +--- The elements this message is made up of +--- +---@return MessageElement[] elements +function c2.Message:elements() end + +--- Add an element to this message. +--- +---@param init MessageElementInit The element to add +function c2.Message:append_element(init) end + +---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 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) +---@field highlight_color? string|nil The color of the highlight (if any) +---@field elements? MessageElementInit[] The elements of the message + +---@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 + --- Creates a new message --- ---@param init MessageInit The message initialization table diff --git a/src/messages/MessageColor.cpp b/src/messages/MessageColor.cpp index ff5f01aca6f..aba1d0dfef1 100644 --- a/src/messages/MessageColor.cpp +++ b/src/messages/MessageColor.cpp @@ -50,4 +50,43 @@ QString MessageColor::toString() const } } +QString MessageColor::toLua() const +{ + switch (this->type_) + { + case Type::Custom: + return this->customColor_.name(QColor::HexArgb); + case Type::Text: + return QStringLiteral("text"); + case Type::System: + return QStringLiteral("system"); + case Type::Link: + return QStringLiteral("link"); + default: + return {}; + } +} + +MessageColor MessageColor::fromLua(const QString &spec, Type fallback) +{ + if (spec.isEmpty()) + { + return fallback; + } + if (spec == u"text") + { + return MessageColor::Text; + } + if (spec == u"link") + { + return MessageColor::Link; + } + if (spec == u"system") + { + return MessageColor::System; + } + // custom + return QColor(spec); +} + } // namespace chatterino diff --git a/src/messages/MessageColor.hpp b/src/messages/MessageColor.hpp index 0e0665d7a18..f4d23fcc5b1 100644 --- a/src/messages/MessageColor.hpp +++ b/src/messages/MessageColor.hpp @@ -17,6 +17,10 @@ struct MessageColor { QString toString() const; + QString toLua() const; + static MessageColor fromLua(const QString &spec, + Type fallback = Type::Text); + bool operator==(const MessageColor &other) const noexcept { return this->type_ == other.type_ && diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index eb059be6be8..b02352be994 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -144,6 +144,11 @@ QJsonObject ImageElement::toJson() const return base; } +std::string_view ImageElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + CircularImageElement::CircularImageElement(ImagePtr image, int padding, QColor background, MessageElementFlags flags) @@ -178,6 +183,11 @@ QJsonObject CircularImageElement::toJson() const return base; } +std::string_view CircularImageElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // EMOTE EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags, const MessageColor &textElementColor) @@ -266,6 +276,11 @@ QJsonObject EmoteElement::toJson() const return base; } +std::string_view EmoteElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + LayeredEmoteElement::LayeredEmoteElement( std::vector &&emotes, MessageElementFlags flags, const MessageColor &textElementColor) @@ -461,6 +476,11 @@ QJsonObject LayeredEmoteElement::toJson() const return base; } +std::string_view LayeredEmoteElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // BADGE BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) : MessageElement(flags) @@ -508,6 +528,11 @@ QJsonObject BadgeElement::toJson() const return base; } +std::string_view BadgeElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // MOD BADGE ModBadgeElement::ModBadgeElement(const EmotePtr &data, MessageElementFlags flags_) @@ -534,6 +559,11 @@ QJsonObject ModBadgeElement::toJson() const return base; } +std::string_view ModBadgeElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // VIP BADGE VipBadgeElement::VipBadgeElement(const EmotePtr &data, MessageElementFlags flags_) @@ -557,6 +587,11 @@ QJsonObject VipBadgeElement::toJson() const return base; } +std::string_view VipBadgeElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // FFZ Badge FfzBadgeElement::FfzBadgeElement(const EmotePtr &data, MessageElementFlags flags_, QColor color_) @@ -583,6 +618,11 @@ QJsonObject FfzBadgeElement::toJson() const return base; } +std::string_view FfzBadgeElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // TEXT TextElement::TextElement(const QString &text, MessageElementFlags flags, const MessageColor &color, FontStyle style) @@ -827,6 +867,11 @@ QJsonObject TextElement::toJson() const return base; } +std::string_view TextElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + SingleLineTextElement::SingleLineTextElement(const QString &text, MessageElementFlags flags, const MessageColor &color, @@ -834,11 +879,8 @@ SingleLineTextElement::SingleLineTextElement(const QString &text, : MessageElement(flags) , color_(color) , style_(style) + , words_(text.split(' ')) { - for (const auto &word : text.split(' ')) - { - this->words_.push_back({word, -1}); - } } void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, @@ -872,7 +914,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, QString currentText; bool firstIteration = true; - for (Word &word : this->words_) + for (const auto &word : this->words_) { if (firstIteration) { @@ -885,7 +927,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, bool done = false; for (const auto &parsedWord : - app->getEmotes()->getEmojis()->parse(word.text)) + app->getEmotes()->getEmojis()->parse(word)) { if (parsedWord.type() == typeid(QString)) { @@ -958,11 +1000,7 @@ QJsonObject SingleLineTextElement::toJson() const { auto base = MessageElement::toJson(); base["type"_L1] = u"SingleLineTextElement"_s; - QJsonArray words; - for (const auto &word : this->words_) - { - words.append(word.text); - } + QJsonArray words = QJsonArray::fromStringList(this->words_); base["words"_L1] = words; base["color"_L1] = this->color_.toString(); base["style"_L1] = qmagicenum::enumNameString(this->style_); @@ -970,6 +1008,11 @@ QJsonObject SingleLineTextElement::toJson() const return base; } +std::string_view SingleLineTextElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + LinkElement::LinkElement(const Parsed &parsed, const QString &fullUrl, MessageElementFlags flags, const MessageColor &color, FontStyle style) @@ -1005,14 +1048,19 @@ QJsonObject LinkElement::toJson() const return base; } +std::string_view LinkElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + MentionElement::MentionElement(const QString &displayName, QString loginName_, MessageColor fallbackColor_, MessageColor userColor_) : TextElement(displayName, {MessageElementFlag::Text, MessageElementFlag::Mention}) - , fallbackColor(fallbackColor_) - , userColor(userColor_) - , userLoginName(std::move(loginName_)) + , fallbackColor_(fallbackColor_) + , userColor_(userColor_) + , userLoginName_(std::move(loginName_)) { } @@ -1021,9 +1069,9 @@ MentionElement::MentionElement(const QString &displayName, QString loginName_, MessageColor fallbackColor_, QColor userColor_) : TextElement(displayName, {MessageElementFlag::Text, MessageElementFlag::Mention}) - , fallbackColor(fallbackColor_) - , userColor(userColor_.isValid() ? userColor_ : fallbackColor_) - , userLoginName(std::move(loginName_)) + , fallbackColor_(fallbackColor_) + , userColor_(userColor_.isValid() ? userColor_ : fallbackColor_) + , userLoginName_(std::move(loginName_)) { } @@ -1037,11 +1085,11 @@ void MentionElement::addToContainer(MessageLayoutContainer &container, { if (getSettings()->colorUsernames) { - this->color_ = this->userColor; + this->color_ = this->userColor_; } else { - this->color_ = this->fallbackColor; + this->color_ = this->fallbackColor_; } if (getSettings()->boldUsernames) @@ -1067,26 +1115,31 @@ MessageElement *MentionElement::setLink(const Link &link) Link MentionElement::getLink() const { - if (this->userLoginName.isEmpty()) + if (this->userLoginName_.isEmpty()) { // Some rare mention elements don't have the knowledge of the login name return {}; } - return {Link::UserInfo, this->userLoginName}; + return {Link::UserInfo, this->userLoginName_}; } QJsonObject MentionElement::toJson() const { auto base = TextElement::toJson(); base["type"_L1] = u"MentionElement"_s; - base["fallbackColor"_L1] = this->fallbackColor.toString(); - base["userColor"_L1] = this->userColor.toString(); - base["userLoginName"_L1] = this->userLoginName; + base["fallbackColor"_L1] = this->fallbackColor_.toString(); + base["userColor"_L1] = this->userColor_.toString(); + base["userLoginName"_L1] = this->userLoginName_; return base; } +std::string_view MentionElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // TIMESTAMP TimestampElement::TimestampElement() : TimestampElement(getApp()->isTest() ? QTime::fromMSecsSinceStartOfDay(0) @@ -1150,6 +1203,11 @@ QJsonObject TimestampElement::toJson() const return base; } +std::string_view TimestampElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + // TWITCH MODERATION TwitchModerationElement::TwitchModerationElement() : MessageElement(MessageElementFlag::ModeratorTools) @@ -1194,6 +1252,11 @@ QJsonObject TwitchModerationElement::toJson() const return base; } +std::string_view TwitchModerationElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + LinebreakElement::LinebreakElement(MessageElementFlags flags) : MessageElement(flags) { @@ -1216,6 +1279,11 @@ QJsonObject LinebreakElement::toJson() const return base; } +std::string_view LinebreakElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + ScalingImageElement::ScalingImageElement(ImageSet images, MessageElementFlags flags) : MessageElement(flags) @@ -1249,6 +1317,11 @@ QJsonObject ScalingImageElement::toJson() const return base; } +std::string_view ScalingImageElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + ReplyCurveElement::ReplyCurveElement() : MessageElement(MessageElementFlag::RepliedMessage) { @@ -1279,4 +1352,9 @@ QJsonObject ReplyCurveElement::toJson() const return base; } +std::string_view ReplyCurveElement::type() const +{ + return std::remove_pointer_t::TYPE; +} + } // namespace chatterino diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 4423f02ecb4..c63418f30b0 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -184,6 +184,12 @@ class MessageElement virtual QJsonObject toJson() const; + /// The type name for this message element. Used for Lua plugins. + /// + /// This must be unique per element. It should return the static `TYPE` + /// member. + virtual std::string_view type() const = 0; + protected: MessageElement(MessageElementFlags flags); bool trailingSpace = true; @@ -198,12 +204,15 @@ class MessageElement class ImageElement : public MessageElement { public: + static constexpr std::string_view TYPE = "image"; + ImageElement(ImagePtr image, MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; private: ImagePtr image_; @@ -213,6 +222,8 @@ class ImageElement : public MessageElement class CircularImageElement : public MessageElement { public: + static constexpr std::string_view TYPE = "circular-image"; + CircularImageElement(ImagePtr image, int padding, QColor background, MessageElementFlags flags); @@ -220,6 +231,16 @@ class CircularImageElement : public MessageElement const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; + + int padding() const + { + return this->padding_; + } + QColor background() const + { + return this->background_; + } private: ImagePtr image_; @@ -231,6 +252,8 @@ class CircularImageElement : public MessageElement class TextElement : public MessageElement { public: + static constexpr std::string_view TYPE = "text"; + TextElement(const QString &text, MessageElementFlags flags, const MessageColor &color = MessageColor::Text, FontStyle style = FontStyle::ChatMedium); @@ -240,6 +263,7 @@ class TextElement : public MessageElement const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; const MessageColor &color() const noexcept; FontStyle fontStyle() const noexcept; @@ -247,6 +271,11 @@ class TextElement : public MessageElement void appendText(QStringView text); void appendText(const QString &text); + QStringList words() const + { + return this->words_; + } + protected: QStringList words_; @@ -258,6 +287,8 @@ class TextElement : public MessageElement class SingleLineTextElement : public MessageElement { public: + static constexpr std::string_view TYPE = "single-line-text"; + SingleLineTextElement(const QString &text, MessageElementFlags flags, const MessageColor &color = MessageColor::Text, FontStyle style = FontStyle::ChatMedium); @@ -267,21 +298,33 @@ class SingleLineTextElement : public MessageElement const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; + + const MessageColor &color() const + { + return this->color_; + } + FontStyle fontStyle() const + { + return this->style_; + } + QStringList words() const + { + return this->words_; + } private: MessageColor color_; FontStyle style_; - struct Word { - QString text; - int width = -1; - }; - std::vector words_; + QStringList words_; }; class LinkElement : public TextElement { public: + static constexpr std::string_view TYPE = "link"; + struct Parsed { QString lowercase; QString original; @@ -309,7 +352,17 @@ class LinkElement : public TextElement return &this->linkInfo_; } + QStringList lowercase() const + { + return this->lowercase_; + } + QStringList original() const + { + return this->original_; + } + QJsonObject toJson() const override; + std::string_view type() const override; private: LinkInfo linkInfo_; @@ -331,6 +384,8 @@ class LinkElement : public TextElement class MentionElement : public TextElement { public: + static constexpr std::string_view TYPE = "mention"; + explicit MentionElement(const QString &displayName, QString loginName_, MessageColor fallbackColor_, MessageColor userColor_); @@ -352,20 +407,34 @@ class MentionElement : public TextElement MessageElement *setLink(const Link &link) override; Link getLink() const override; + const MessageColor &fallbackColor() const + { + return this->fallbackColor_; + } + const MessageColor &userColor() const + { + return this->userColor_; + } + QString userLoginName() const + { + return this->userLoginName_; + } + QJsonObject toJson() const override; + std::string_view type() const override; private: /** * The color of the element in case the "Colorize @usernames" is disabled **/ - MessageColor fallbackColor; + MessageColor fallbackColor_; /** * The color of the element in case the "Colorize @usernames" is enabled **/ - MessageColor userColor; + MessageColor userColor_; - QString userLoginName; + QString userLoginName_; }; // contains emote data and will pick the emote based on : @@ -374,6 +443,8 @@ class MentionElement : public TextElement class EmoteElement : public MessageElement { public: + static constexpr std::string_view TYPE = "emote"; + EmoteElement(const EmotePtr &data, MessageElementFlags flags_, const MessageColor &textElementColor = MessageColor::Text); @@ -382,6 +453,7 @@ class EmoteElement : public MessageElement EmotePtr getEmote() const; QJsonObject toJson() const override; + std::string_view type() const override; protected: virtual MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, @@ -403,6 +475,8 @@ class EmoteElement : public MessageElement class LayeredEmoteElement : public MessageElement { public: + static constexpr std::string_view TYPE = "layered-emote"; + struct Emote { EmotePtr ptr; MessageElementFlags flags; @@ -424,6 +498,7 @@ class LayeredEmoteElement : public MessageElement const std::vector &getEmoteTooltips() const; QJsonObject toJson() const override; + std::string_view type() const override; private: MessageLayoutElement *makeImageLayoutElement( @@ -444,6 +519,8 @@ class LayeredEmoteElement : public MessageElement class BadgeElement : public MessageElement { public: + static constexpr std::string_view TYPE = "badge"; + BadgeElement(const EmotePtr &data, MessageElementFlags flags_); void addToContainer(MessageLayoutContainer &container, @@ -452,6 +529,7 @@ class BadgeElement : public MessageElement EmotePtr getEmote() const; QJsonObject toJson() const override; + std::string_view type() const override; protected: virtual MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, @@ -464,9 +542,12 @@ class BadgeElement : public MessageElement class ModBadgeElement : public BadgeElement { public: + static constexpr std::string_view TYPE = "mod-badge"; + ModBadgeElement(const EmotePtr &data, MessageElementFlags flags_); QJsonObject toJson() const override; + std::string_view type() const override; protected: MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, @@ -476,9 +557,12 @@ class ModBadgeElement : public BadgeElement class VipBadgeElement : public BadgeElement { public: + static constexpr std::string_view TYPE = "vip-badge"; + VipBadgeElement(const EmotePtr &data, MessageElementFlags flags_); QJsonObject toJson() const override; + std::string_view type() const override; protected: MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, @@ -488,10 +572,13 @@ class VipBadgeElement : public BadgeElement class FfzBadgeElement : public BadgeElement { public: + static constexpr std::string_view TYPE = "ffz-badge"; + FfzBadgeElement(const EmotePtr &data, MessageElementFlags flags_, QColor color_); QJsonObject toJson() const override; + std::string_view type() const override; protected: MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, @@ -503,6 +590,8 @@ class FfzBadgeElement : public BadgeElement class TimestampElement : public MessageElement { public: + static constexpr std::string_view TYPE = "timestamp"; + TimestampElement(); TimestampElement(QTime time_); ~TimestampElement() override = default; @@ -513,7 +602,13 @@ class TimestampElement : public MessageElement TextElement *formatTime(const QTime &time); MessageElement *setLink(const Link &link) override; + QTime time() const + { + return this->time_; + } + QJsonObject toJson() const override; + std::string_view type() const override; private: QTime time_; @@ -526,36 +621,45 @@ class TimestampElement : public MessageElement class TwitchModerationElement : public MessageElement { public: + static constexpr std::string_view TYPE = "twitch-moderation"; + TwitchModerationElement(); void addToContainer(MessageLayoutContainer &container, const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; }; // Forces a linebreak class LinebreakElement : public MessageElement { public: + static constexpr std::string_view TYPE = "linebreak"; + LinebreakElement(MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; }; // Image element which will pick the quality of the image based on ui scale class ScalingImageElement : public MessageElement { public: + static constexpr std::string_view TYPE = "scaling-image"; + ScalingImageElement(ImageSet images, MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; private: ImageSet images_; @@ -564,12 +668,15 @@ class ScalingImageElement : public MessageElement class ReplyCurveElement : public MessageElement { public: + static constexpr std::string_view TYPE = "reply-curve"; + ReplyCurveElement(); void addToContainer(MessageLayoutContainer &container, const MessageLayoutContext &ctx) override; QJsonObject toJson() const override; + std::string_view type() const override; }; } // namespace chatterino diff --git a/tests/lua/message/docs.lua b/tests/lua/message/docs.lua new file mode 100644 index 00000000000..818f17cd92e --- /dev/null +++ b/tests/lua/message/docs.lua @@ -0,0 +1,48 @@ +-- These tests mirror the code example from the docs - make sure they work +local tests = { + frozen_messages = function() + local my_msg = c2.Message.new({ id = "foobar" }) + assert(not my_msg.frozen) + local chan = c2.Channel.by_name("mm2pl") + assert(chan) + chan:add_message(my_msg) + assert(my_msg.frozen) + end, + element_access = function() + local my_msg = c2.Message.new({ + id = "foo", + elements = { + { type = "text", text = "foo" }, + { type = "text", text = "bar" }, + { type = "text", text = "baz" }, + } + }) + local elements = my_msg:elements() + assert(#elements == 3) + assert(elements[1].words[1] == "foo") + assert(elements[2].words[1] == "bar") + + elements:erase(2) -- erase "bar" + assert(#elements == 2) + assert(elements[1].words[1] == "foo") + assert(elements[2].words[1] == "baz") + end, + append_element = function() + local my_msg = c2.Message.new({ id = "foo" }) + local els = my_msg:elements() + assert(#els == 0) + my_msg:append_element({ + type = "text", + text = "My text element", + }) + assert(#els == 1) + assert(els[1].type == "text") + end +} + +for name, fn in pairs(tests) do + local ok, res = pcall(fn) + if not ok then + error(name .. " failed: " .. res) + end +end diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index 5b6d7ec22ac..17446f7894b 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -14,6 +14,7 @@ # include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep # include "lib/Snapshot.hpp" # include "messages/Message.hpp" +# include "messages/MessageElement.hpp" # include "mocks/BaseApplication.hpp" # include "mocks/Channel.hpp" # include "mocks/EmoteController.hpp" @@ -1018,6 +1019,417 @@ TEST_F(PluginTest, ChannelAddMessage) ASSERT_EQ(added[5].first, logged[2]); } +TEST_F(PluginTest, MessageFrozenFlag) +{ + configure(); + sol::protected_function isFrozenFn = lua->script(R"lua( + return function(msg) + return msg.frozen + end + )lua"); + sol::protected_function setFrozenFn = lua->script(R"lua( + return function(msg, val) + msg.frozen = val + end + )lua"); + + auto liquid = std::make_shared(); + auto res = isFrozenFn(liquid); + ASSERT_TRUE(res.valid()); + ASSERT_FALSE(res.get()); + + auto frozen = std::make_shared(); + frozen->freeze(); + res = isFrozenFn(frozen); + ASSERT_TRUE(res.valid()); + ASSERT_TRUE(res.get()); + + // we shouldn't be able to modify the flag + ASSERT_FALSE(setFrozenFn(liquid, true).valid()); + ASSERT_FALSE(setFrozenFn(liquid, false).valid()); + ASSERT_FALSE(setFrozenFn(frozen, true).valid()); + ASSERT_FALSE(setFrozenFn(frozen, false).valid()); +} + +TEST_F(PluginTest, MessageFlagModification) +{ + configure(); + sol::protected_function pfn = lua->script(R"lua( + return function(msg) + assert(msg.flags == c2.MessageFlag.Debug) + msg.flags = c2.MessageFlag.System + assert(msg.flags == c2.MessageFlag.System) + end + )lua"); + sol::protected_function isFrozenFn = lua->script(R"lua( + return function(msg) + return msg.frozen + end + )lua"); + + auto liquid = std::make_shared(); + liquid->flags = MessageFlag::Debug; + auto res = pfn(liquid); + ASSERT_TRUE(res.valid()); + + // for the flags, it shouldn't matter if the message is frozen + auto frozen = std::make_shared(); + frozen->flags = MessageFlag::Debug; + frozen->freeze(); + res = pfn(frozen); + ASSERT_TRUE(res.valid()); +} + +TEST_F(PluginTest, MessageModification) +{ + configure(); + + // Test that we can modify properties and that Lua sees the modification + sol::table tests = lua->script(R"lua( + return { + function(msg) + msg.parse_time = 1234567 + end, + function(msg) + assert(msg.id == "abc") + msg.id = "1234" + assert(msg.id == "1234") + end, + function(msg) + assert(msg.search_text == "search") + msg.search_text = "query" + assert(msg.search_text == "query") + end, + function(msg) + assert(msg.message_text == "msg") + msg.message_text = "text" + assert(msg.message_text == "text") + end, + function(msg) + assert(msg.login_name == "login") + msg.login_name = "name" + assert(msg.login_name == "name") + end, + function(msg) + assert(msg.display_name == "display") + msg.display_name = "name" + assert(msg.display_name == "name") + end, + function(msg) + assert(msg.localized_name == "localized") + msg.localized_name = "name" + assert(msg.localized_name == "name") + end, + function(msg) + assert(msg.user_id == "id") + msg.user_id = "id" + assert(msg.user_id == "id") + end, + function(msg) + assert(msg.channel_name == "channel") + msg.channel_name = "name" + assert(msg.channel_name == "name") + end, + function(msg) + assert(msg.username_color == "#ffaabbcc") + msg.username_color = "#ccbbaaff" + assert(msg.username_color == "#ccbbaaff") + end, + function(msg) + assert(msg.server_received_time == 1230000) + msg.server_received_time = 1240000 + assert(msg.server_received_time == 1240000) + end, + function(msg) + print(msg.highlight_color) + assert(msg.highlight_color == "#ff223344") + msg.highlight_color = "#44332211" + assert(msg.highlight_color == "#44332211") + end, + function(msg) + assert(#msg:elements() == 2) + msg:append_element({ type = "linebreak" }) + assert(#msg:elements() == 3) + assert(msg:elements()[3].type == "linebreak") + end, + } + )lua"); + + auto makeMsg = [] { + auto msg = std::make_shared(); + msg->flags = MessageFlag::Debug; + msg->id = "abc"; + msg->searchText = "search"; + msg->messageText = "msg"; + msg->loginName = "login"; + msg->displayName = "display"; + msg->localizedName = "localized"; + msg->userID = "id"; + msg->channelName = "channel"; + msg->usernameColor = QColor(0xaabbcc); + msg->serverReceivedTime = QDateTime::fromMSecsSinceEpoch(1230000); + msg->highlightColor = std::make_shared(0x223344); + msg->elements.push_back( + std::make_unique("lol", MessageElementFlag::Text)); + msg->elements.push_back( + std::make_unique("wow", MessageElementFlag::Text)); + return msg; + }; + + auto liquid = makeMsg(); + ASSERT_TRUE(tests.valid()); + for (const auto &[_key, cb] : tests) + { + sol::protected_function pf = cb; + auto res = pf(liquid); + if (!res.valid()) + { + sol::error err = res; + ASSERT_TRUE(false) << err.what(); + } + } + + // If the message is frozen, all modifications should fail with an error + auto frozen = makeMsg(); + frozen->freeze(); + for (const auto &[key, cb] : tests) + { + sol::protected_function pf = cb; + auto res = pf(frozen); + ASSERT_FALSE(res.valid()); + sol::error err = res; + ASSERT_EQ(std::string_view(err.what()), "Message is frozen"); + } +} + +TEST_F(PluginTest, MessageConstness) +{ + configure(); + sol::protected_function pfn = lua->script(R"lua( + return function(msg) + assert(msg.login_name == "hello") + msg.login_name = "alien" + end + )lua"); + + auto msg = std::make_shared(); + msg->loginName = "hello"; + MessagePtr cmsg = msg; + + auto res = pfn(cmsg); + ASSERT_TRUE(res.valid()); + cmsg->freeze(); + res = pfn(cmsg); + ASSERT_FALSE(res.valid()); +} + +// Test that we can access properties of message elements +TEST_F(PluginTest, MessageElementAccess) +{ + configure(); + sol::protected_function pfn = lua->script(R"lua( + return function(msg, idx, prop) + return msg:elements()[idx][prop] + end + )lua"); + + auto msg = std::make_shared(); + msg->elements.emplace_back( + std::make_unique("my text", MessageElementFlag::Text)); + msg->elements.emplace_back(std::make_unique( + "single line", MessageElementFlag::Text)); + msg->elements.emplace_back(std::make_unique( + ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton)); + msg->elements.emplace_back(std::make_unique( + "display", "login", MessageColor::Text, MessageColor::System)); + + msg->elements[1]->setTooltip("tooltip"); + msg->elements[2]->setTrailingSpace(false); + msg->freeze(); + + auto getAll = [&](std::string_view key) { + return std::array{ + pfn(msg, 1, key).get(), + pfn(msg, 2, key).get(), + pfn(msg, 3, key).get(), + pfn(msg, 4, key).get(), + }; + }; + + auto types = getAll("type"); + ASSERT_EQ(types[0].as(), "text"); + ASSERT_EQ(types[1].as(), "single-line-text"); + ASSERT_EQ(types[2].as(), "circular-image"); + ASSERT_EQ(types[3].as(), "mention"); + + auto flags = getAll("flags"); + ASSERT_EQ(flags[0].as(), MessageElementFlag::Text); + ASSERT_EQ(flags[1].as(), MessageElementFlag::Text); + ASSERT_EQ(flags[2].as(), + MessageElementFlag::ReplyButton); + ASSERT_EQ(flags[3].as(), + (MessageElementFlags(MessageElementFlag::Text, + MessageElementFlag::Mention))); + + auto tooltips = getAll("tooltip"); + ASSERT_EQ(tooltips[0].as(), ""); + ASSERT_EQ(tooltips[1].as(), "tooltip"); + ASSERT_EQ(tooltips[2].as(), ""); + ASSERT_EQ(tooltips[3].as(), ""); + + auto spaces = getAll("trailing_space"); + ASSERT_TRUE(spaces[0].as()); + ASSERT_TRUE(spaces[1].as()); + ASSERT_FALSE(spaces[2].as()); + ASSERT_TRUE(spaces[3].as()); + + // Properties only found on _some_ elements should not error + // (like non existent properties) + auto paddings = getAll("padding"); + ASSERT_TRUE(paddings[0].is()); + ASSERT_TRUE(paddings[1].is()); + ASSERT_EQ(paddings[2].as(), 2); + ASSERT_TRUE(paddings[3].is()); + + auto words = getAll("words"); + ASSERT_EQ(words[0].as>(), + (std::vector{"my", "text"})); + ASSERT_EQ(words[1].as>(), + (std::vector{"single", "line"})); + ASSERT_TRUE(words[2].is()); + // mention elements are also text elements + ASSERT_EQ(words[3].as>(), + (std::vector{"display"})); + + auto userLogins = getAll("user_login_name"); + ASSERT_TRUE(userLogins[0].is()); + ASSERT_TRUE(userLogins[1].is()); + ASSERT_TRUE(userLogins[2].is()); + ASSERT_EQ(userLogins[3].as(), "login"); + + auto times = getAll("time"); + ASSERT_TRUE(times[0].is()); + ASSERT_TRUE(times[1].is()); + ASSERT_TRUE(times[2].is()); + ASSERT_TRUE(times[3].is()); + + auto nonExistent = getAll("non_existent"); + ASSERT_TRUE(nonExistent[0].is()); + ASSERT_TRUE(nonExistent[1].is()); + ASSERT_TRUE(nonExistent[2].is()); + ASSERT_TRUE(nonExistent[3].is()); + + auto links = getAll("link"); + ASSERT_TRUE(links[0].is()); + ASSERT_TRUE(links[1].is()); + ASSERT_TRUE(links[2].is()); + ASSERT_TRUE(links[3].is()); + + // test that accessing anything outside the elements vector causes an error + auto res = pfn(msg, 0, "flags"); + ASSERT_FALSE(res.valid()); + res = pfn(msg, 42, "flags"); + ASSERT_FALSE(res.valid()); +} + +// Test that we can modify properties of message elements +TEST_F(PluginTest, MessageElementModification) +{ + configure(); + sol::protected_function pfn = lua->script(R"lua( + return function(msg, idx, prop, val) + msg:elements()[idx][prop] = val + end + )lua"); + + // same as MessageElementAccess... + auto msg = std::make_shared(); + msg->elements.emplace_back( + std::make_unique("my text", MessageElementFlag::Text)); + msg->elements.emplace_back(std::make_unique( + "single line", MessageElementFlag::Text)); + msg->elements.emplace_back(std::make_unique( + ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton)); + msg->elements.emplace_back(std::make_unique( + "display", "login", MessageColor::Text, MessageColor::System)); + + msg->elements[1]->setTooltip("tooltip"); + msg->elements[2]->setTrailingSpace(false); + // ...but we don't freeze the message here + + auto setAll = [&](std::string_view key, auto value) { + for (size_t i = 1; i <= 4; i++) + { + EXPECT_TRUE(pfn(msg, i, key, value).valid()); + } + }; + setAll("tooltip", "tool"); + ASSERT_EQ(msg->elements[0]->getTooltip(), "tool"); + ASSERT_EQ(msg->elements[1]->getTooltip(), "tool"); + ASSERT_EQ(msg->elements[2]->getTooltip(), "tool"); + ASSERT_EQ(msg->elements[3]->getTooltip(), "tool"); + + setAll("trailing_space", false); + ASSERT_FALSE(msg->elements[0]->hasTrailingSpace()); + ASSERT_FALSE(msg->elements[1]->hasTrailingSpace()); + ASSERT_FALSE(msg->elements[2]->hasTrailingSpace()); + ASSERT_FALSE(msg->elements[3]->hasTrailingSpace()); + + pfn(msg, 1, "link", Link{Link::CopyToClipboard, "foo"}); + pfn(msg, 2, "link", Link{Link::CopyToClipboard, "foo"}); + pfn(msg, 3, "link", Link{Link::CopyToClipboard, "foo"}); + // can't modify links of mention elements + ASSERT_FALSE( + pfn(msg, 4, "link", Link{Link::CopyToClipboard, "foo"}).valid()); + + ASSERT_EQ(msg->elements[0]->getLink().type, Link::CopyToClipboard); + ASSERT_EQ(msg->elements[0]->getLink().value, "foo"); + ASSERT_EQ(msg->elements[0]->getTooltip(), "Copy to clipboard"); + ASSERT_EQ(msg->elements[1]->getLink().type, Link::CopyToClipboard); + ASSERT_EQ(msg->elements[1]->getLink().value, "foo"); + ASSERT_EQ(msg->elements[2]->getLink().type, Link::CopyToClipboard); + ASSERT_EQ(msg->elements[2]->getLink().value, "foo"); + + auto expectErr = [&](std::string_view key, auto value) { + for (size_t i = 1; i <= 4; i++) + { + auto result = pfn(msg, i, key, value); + EXPECT_FALSE(result.valid()) << key; + } + }; + expectErr("type", "something"); + expectErr("trailing_space", "something"); + + // can't set these types + expectErr("link", Link{Link::ViewThread, "foo"}); + expectErr("link", Link{Link::AutoModAllow, "foo"}); + + // We can't modify these yet + expectErr("padding", 1); + expectErr("background", 0xabcdef12); + expectErr("words", QStringList{"a", "b"}); + expectErr("color", 0x1234); + expectErr("style", FontStyle::ChatMedium); + expectErr("lowercase", "abc"); + expectErr("original", "or"); + expectErr("fallback_color", "system"); + expectErr("user_color", "system"); + expectErr("user_login_name", "system"); + expectErr("time", 42); + + // test that accessing anything outside the elements vector causes an error + auto res = pfn(msg, 0, "trailing_space", true); + ASSERT_FALSE(res.valid()); + res = pfn(msg, 42, "trailing_space", true); + ASSERT_FALSE(res.valid()); + + // we can't modify anything on frozen messages + msg->freeze(); + expectErr("tooltip", "tool"); + expectErr("trailing_space", false); + expectErr("padding", 1); +} + /// Test that both C++ exceptions and luaL_error properly unwind the stack. TEST_F(PluginTest, LuaUnwind) { @@ -1139,6 +1551,28 @@ TEST_P(PluginJsonTest, Run) INSTANTIATE_TEST_SUITE_P(PluginJson, PluginJsonTest, testing::ValuesIn(discoverLuaTests("json"))); +class PluginMessageTest : public PluginTest, + public ::testing::WithParamInterface +{ +}; +TEST_P(PluginMessageTest, Run) +{ + configure(); + + auto pfr = lua->safe_script_file(luaTestPath("message", GetParam())); + EXPECT_TRUE(pfr.valid()); + if (!pfr.valid()) + { + qDebug() << "Test" << GetParam() << "failed:"; + sol::error err = pfr; + qDebug() << err.what(); + return; + } +} + +INSTANTIATE_TEST_SUITE_P(PluginMessage, PluginMessageTest, + testing::ValuesIn(discoverLuaTests("message"))); + // verify that all snapshots are included TEST(PluginMessageConstructionTest, Integrity) {