diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95f4f6d83ea..5daae4fa5fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
- Minor: Added WebSocket API for plugins. (#6076, #6186, #6314, #6315)
- Minor: Allow for themes to set transparent values for window background on Linux. (#6137)
- Minor: Popup overlay now only draws an outline when being interacted with. (#6140)
+- Minor: Added basic message API to plugins. (#5754)
- Minor: Made filters searchable in the Settings dialog search bar. (#5890)
- Minor: Updated emojis to Unicode 16.0. (#6155)
- Minor: Allow disabling of double-click tab renaming through setting. (#6163, #6184)
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,
+ }
}
diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua
index b9e0b7bf593..f6dede6d078 100644
--- a/docs/plugin-meta.lua
+++ b/docs/plugin-meta.lua
@@ -112,6 +112,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.
@@ -257,6 +264,227 @@ 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 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
+
+---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", "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
+---@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
+ TimestampMedium = {}, ---@type c2.FontStyle.TimestampMedium
+ 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,
+ ClearChat = 0,
+ EventSub = 0,
+ ModerationAction = 0,
+ InvalidReplyTarget = 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/controllers/plugins/api/WebSocket.hpp
---@class c2.WebSocket
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
diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py
index e1dafe496c1..14f8a2b2c19 100755
--- a/scripts/make_luals_meta.py
+++ b/scripts/make_luals_meta.py
@@ -27,6 +27,14 @@
@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. These only work in the top-level scope. They don't work
+for class bodies.
"""
from io import TextIOWrapper
@@ -55,10 +63,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 +112,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 +153,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 +176,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 +187,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 +210,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 +258,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 +284,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 +309,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
@@ -322,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__":
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 09a5978a2cf..74588a8d363 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -250,6 +250,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/api/WebSocket.cpp
controllers/plugins/api/WebSocket.hpp
controllers/plugins/LuaAPI.cpp
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 b8c382b5280..3a003f34ed3 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 controllers/plugins/api/WebSocket.hpp
* @includefile common/network/NetworkCommon.hpp
*/
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 baa40b47779..19f1f6e3cd0 100644
--- a/src/controllers/plugins/PluginController.cpp
+++ b/src/controllers/plugins/PluginController.cpp
@@ -11,11 +11,13 @@
# 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/api/WebSocket.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# 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"
@@ -222,10 +224,16 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin)
lua::api::HTTPResponse::createUserType(c2);
lua::api::HTTPRequest::createUserType(c2);
lua::api::WebSocket::createUserType(c2, plugin);
+ 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..254b01b7432 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 &message,
+ 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(message, 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..3d63f9d344a
--- /dev/null
+++ b/src/controllers/plugins/api/Message.cpp
@@ -0,0 +1,249 @@
+#include "controllers/plugins/api/Message.hpp"
+
+#include "Application.hpp"
+#include "messages/MessageElement.hpp"
+
+#ifdef CHATTERINO_HAVE_PLUGINS
+
+# include "controllers/plugins/SolTypes.hpp"
+# include "messages/Message.hpp"
+
+# include
+
+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)
+{
+ 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)
+{
+ 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(
+ requiredGet(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(
+ requiredGet(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(
+ requiredGet(tbl, "display_name"),
+ requiredGet(tbl, "login_name"),
+ tryMakeMessageColor(requiredGet(tbl, "fallback_color")),
+ tryMakeMessageColor(requiredGet(tbl, "user_color")));
+}
+
+std::unique_ptr timestampElementFromTable(
+ const sol::table &tbl)
+{
+ // no flags!
+ auto time = tbl.get>("time");
+ if (time)
+ {
+ return std::make_unique(
+ datetimeFromOffset(*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)
+{
+ auto type = requiredGet(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 = datetimeFromOffset(*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{});
+ msg->userID = tbl.get_or("user_id", 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 = datetimeFromOffset(*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([](const 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..119a6b5238b
--- /dev/null
+++ b/src/controllers/plugins/api/Message.hpp
@@ -0,0 +1,101 @@
+#pragma once
+#ifdef CHATTERINO_HAVE_PLUGINS
+# include "messages/Message.hpp"
+
+# include
+
+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
+
+---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", "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
+---@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
+
+#endif
diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp
index 67f664acf3b..abf0d3ce6c3 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 39702b8144e..12609360bc7 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 61ce62368df..96997be6cf6 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,
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),
diff --git a/tests/snapshots/PluginMessageCtor/empty.json b/tests/snapshots/PluginMessageCtor/empty.json
new file mode 100644
index 00000000000..87beab7c142
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/empty.json
@@ -0,0 +1,24 @@
+{
+ "input": "msg = {}",
+ "output": {
+ "badgeInfos": {
+ },
+ "badges": [
+ ],
+ "channelName": "",
+ "count": 1,
+ "displayName": "",
+ "elements": [
+ ],
+ "flags": "",
+ "id": "",
+ "localizedName": "",
+ "loginName": "",
+ "messageText": "",
+ "searchText": "",
+ "serverReceivedTime": "",
+ "timeoutUser": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/snapshots/PluginMessageCtor/linebreak-element.json b/tests/snapshots/PluginMessageCtor/linebreak-element.json
new file mode 100644
index 00000000000..536aa436348
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/linebreak-element.json
@@ -0,0 +1,71 @@
+{
+ "input": [
+ "msg = {elements={",
+ " { type = 'linebreak' },",
+ " { type = 'linebreak', flags = c2.MessageElementFlag.BttvEmoteImage },",
+ " { 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",
+ "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": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/snapshots/PluginMessageCtor/mention-element.json b/tests/snapshots/PluginMessageCtor/mention-element.json
new file mode 100644
index 00000000000..b86ce325cf9
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/mention-element.json
@@ -0,0 +1,103 @@
+{
+ "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": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/snapshots/PluginMessageCtor/properties.json b/tests/snapshots/PluginMessageCtor/properties.json
new file mode 100644
index 00000000000..9583d4bb606
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/properties.json
@@ -0,0 +1,60 @@
+{
+ "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',",
+ " user_id = 'user-id',",
+ " 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-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
new file mode 100644
index 00000000000..1e5cb5a7102
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/reply-curve-element.json
@@ -0,0 +1,71 @@
+{
+ "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": "",
+ "userID": "",
+ "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..635a0625b0e
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/single-line-text-element.json
@@ -0,0 +1,188 @@
+{
+ "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": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/snapshots/PluginMessageCtor/text-element.json b/tests/snapshots/PluginMessageCtor/text-element.json
new file mode 100644
index 00000000000..91da4e3e971
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/text-element.json
@@ -0,0 +1,188 @@
+{
+ "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": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/snapshots/PluginMessageCtor/timestamp-element.json b/tests/snapshots/PluginMessageCtor/timestamp-element.json
new file mode 100644
index 00000000000..8e42723b218
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/timestamp-element.json
@@ -0,0 +1,139 @@
+{
+ "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": "TimestampMedium",
+ "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": "TimestampMedium",
+ "tooltip": "",
+ "trailingSpace": true,
+ "type": "TextElement",
+ "words": [
+ "0:20"
+ ]
+ },
+ "flags": "Timestamp",
+ "format": "",
+ "link": {
+ "type": "None",
+ "value": ""
+ },
+ "time": "00:20:30",
+ "tooltip": "",
+ "trailingSpace": true,
+ "type": "TimestampElement"
+ },
+ {
+ "element": {
+ "color": "System",
+ "flags": "Timestamp",
+ "link": {
+ "type": "None",
+ "value": ""
+ },
+ "style": "TimestampMedium",
+ "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": "TimestampMedium",
+ "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": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/snapshots/PluginMessageCtor/twitch-moderation.json b/tests/snapshots/PluginMessageCtor/twitch-moderation.json
new file mode 100644
index 00000000000..2e62e19d558
--- /dev/null
+++ b/tests/snapshots/PluginMessageCtor/twitch-moderation.json
@@ -0,0 +1,71 @@
+{
+ "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": "",
+ "userID": "",
+ "usernameColor": "#ff000000"
+ }
+}
diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp
index 9bbd675eaf2..38390361a25 100644
--- a/tests/src/Plugins.cpp
+++ b/tests/src/Plugins.cpp
@@ -10,6 +10,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"
@@ -32,6 +34,8 @@ using chatterino::mock::MockChannel;
namespace {
+constexpr bool UPDATE_SNAPSHOTS = false;
+
const QString TEST_SETTINGS = R"(
{
"plugins": {
@@ -104,7 +108,7 @@ class MockApplication : public mock::BaseApplication
}
PluginController plugins;
- mock::EmptyLogging logging;
+ mock::Logging logging;
CommandController commands;
mock::Emotes emotes;
MockTwitch twitch;
@@ -861,4 +865,150 @@ TEST_F(PluginTest, testWebSocketUnsetFns)
waiter.waitForRequest();
}
+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, function(a, b) return a:lower() < b:lower() end)
+ 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,"
+ "BitsAmount=0x200000,"
+ "BitsAnimated=0x1000,"
+ "BitsStatic=0x800,"
+ "BttvEmoteImage=0x40,"
+ "BttvEmoteText=0x80,"
+ "ChannelName=0x100000,"
+ "ChannelPointReward=0x100,"
+ "Collapsed=0x4000000,"
+ "EmojiImage=0x800000,"
+ "EmojiText=0x1000000,"
+ "FfzEmoteImage=0x200,"
+ "FfzEmoteText=0x400,"
+ "LowercaseLinks=0x20000000,"
+ "Mention=0x8000000,"
+ "Misc=0x1,"
+ "ModeratorTools=0x400000,"
+ "RepliedMessage=0x100000000,"
+ "ReplyButton=0x200000000,"
+ "SevenTVEmoteImage=0x400000000,"
+ "SevenTVEmoteText=0x800000000,"
+ "Text=0x2,"
+ "Timestamp=0x8,"
+ "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 e4870bafeec..49c4b30a143 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,