diff --git a/docs/discord.md b/docs/discord.md index ad3d2f3983..f8a37cdb58 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -51,6 +51,24 @@ Your character's name is never shown, in any setting. The setting applies immediately and is stored in `config.ini`. +## Messages from Discord + +> Requires a server with a Discord chat bridge, like OpenMU's (its guild and +> alliance chat can be bound to Discord channels). + +A message written in a Discord channel that the server bridges into the game +appears in the chat of its scope - guild or alliance - with that chat's +colours. Its sender is shown as `[Discord] `: with OpenMU's bridge the +name is the character the Discord user is linked to (OpenMU sends it as +`@`, which no character can be called). A server that sends the Discord +name itself can show it with up to 32 characters. + +- Discord users are not characters: right-clicking their line does not start a + whisper, and they are not added to your whisper list. +- **F2** switches the chat log between all messages and whispers. Once a + message from Discord has arrived, it also offers a view with only the + messages from Discord: all → whispers → Discord → all. + ## For server operators Each server uses its own Discord application, so the name your players see diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 7932fbbc42..763c87466f 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -1,6 +1,7 @@ #include "stdafx.h" #include "App/Control/ControlTaps.h" #include "Core/Utilities/Log/MuLogger.h" +#include "UI/Chat/ExternalChat.h" #include "UI/Chat/Chat.h" #include "UI/Chat/ChatMessages.h" #include "UI/Combat/SiegeUpdates.h" @@ -1819,12 +1820,23 @@ void ReceiveChat(const BYTE* ReceiveBuffer) { for (int i = 0; i < messageSize - 2; i++) Text[i] = Text[i + 2]; + if (UI::Chat::External::IsBridgedSender(ID)) + { + // Recorded for the control socket as a Discord line instead. + UI::Chat::External::ShowBridged(UI::Chat::External::Scope::Alliance, ID, Text); + return; + } UI::Chat::PostChat(ID, Text, mu::ui::window::TYPE_UNION_MESSAGE); } else if (Text[0] == L'@') { for (int i = 0; i < messageSize - 1; i++) Text[i] = Text[i + 1]; + if (UI::Chat::External::IsBridgedSender(ID)) + { + UI::Chat::External::ShowBridged(UI::Chat::External::Scope::Guild, ID, Text); + return; + } UI::Chat::PostChat(ID, Text, mu::ui::window::TYPE_GUILD_MESSAGE); } else if (Text[0] == L'$') diff --git a/src/source/UI/Chat/ExternalChat.cpp b/src/source/UI/Chat/ExternalChat.cpp new file mode 100644 index 0000000000..40195a2b85 --- /dev/null +++ b/src/source/UI/Chat/ExternalChat.cpp @@ -0,0 +1,74 @@ +#include "stdafx.h" +#include "UI/Chat/ExternalChat.h" + +#include "App/Control/ControlTaps.h" +#include "Network/Server/WSclient.h" +#include "UI/Core/WindowSystem.h" + +namespace +{ +constexpr const wchar_t* DiscordBadge = L"[Discord] "; +constexpr const char* DiscordChatKind = "discord"; +} // namespace + +namespace UI::Chat::External +{ +std::wstring SenderLabel(Source source, std::wstring_view displayName) +{ + switch (source) + { + case Source::Discord: + default: + return DiscordBadge + std::wstring(displayName.substr(0, MaxDisplayNameLength)); + } +} + +mu::ui::window::MESSAGE_TYPE ScopeMessageType(Scope scope) +{ + switch (scope) + { + case Scope::Guild: + return mu::ui::window::TYPE_GUILD_MESSAGE; + case Scope::Alliance: + return mu::ui::window::TYPE_UNION_MESSAGE; + case Scope::World: + default: + return mu::ui::window::TYPE_CHAT_MESSAGE; + } +} + +std::wstring ShownText(std::wstring_view text) +{ + return std::wstring(text.substr(0, MAX_CHAT_SIZE)); +} + +// Unlike an in-game message, no speech balloon (nobody stands there) and no +// entry in the whisper allow-list (nobody to whisper). +void Show(const Message& message) +{ + const std::wstring sender = SenderLabel(message.source, message.displayName); + const std::wstring text = ShownText(message.text); + if (text.empty()) + { + return; + } + + g_pChatListBox->AddExternalText(sender, text, ScopeMessageType(message.scope)); + App::Control::Events::RecordChatLine(sender.c_str(), text.c_str(), DiscordChatKind); +} + +bool IsBridgedSender(std::wstring_view sender) +{ + return sender.size() > 1 && sender.front() == BridgedSenderPrefix; +} + +void ShowBridged(Scope scope, std::wstring_view sender, std::wstring_view text) +{ + Message message; + message.source = Source::Discord; + message.scope = scope; + message.displayName = std::wstring(sender.substr(1)); + message.text = std::wstring(text); + Show(message); +} +} // namespace UI::Chat::External diff --git a/src/source/UI/Chat/ExternalChat.h b/src/source/UI/Chat/ExternalChat.h new file mode 100644 index 0000000000..6657ac85d2 --- /dev/null +++ b/src/source/UI/Chat/ExternalChat.h @@ -0,0 +1,70 @@ +// Chat messages written outside the game - today in a Discord channel that +// the server bridges into guild, alliance or world chat (#700). +// +// They are shown as such: the sender is marked with where they wrote, keeps +// a name longer than a character's, and can't be whispered, because there +// is no character behind it. +#pragma once + +#include "UI/HUD/ChatLogWindow.h" + +#include +#include +#include + +namespace UI::Chat::External +{ +enum class Source +{ + Discord, +}; + +// Which in-game chat the message belongs to; it takes that chat's colours. +enum class Scope +{ + Guild, + Alliance, + World, +}; + +// Discord's display names are at most 32 characters long. +inline constexpr std::size_t MaxDisplayNameLength = 32; + +struct Message +{ + Source source = Source::Discord; + Scope scope = Scope::World; + std::wstring displayName; + std::wstring text; +}; + +// What the chat log shows as the sender: "[Discord] name", the name cut to +// MaxDisplayNameLength characters. +[[nodiscard]] std::wstring SenderLabel(Source source, std::wstring_view displayName); + +// The chat log type whose colours the scope uses. +[[nodiscard]] mu::ui::window::MESSAGE_TYPE ScopeMessageType(Scope scope); + +// The text as shown: cut to the length of an in-game chat message. +[[nodiscard]] std::wstring ShownText(std::wstring_view text); + +// Shows the message in the chat log, in its scope and in the Discord view. +void Show(const Message& message); + +// OpenMU's chat bridge (MUnique/OpenMU#1161) sends messages written in Discord +// through the ordinary guild and alliance chat, as the linked character with +// this prefix, which OpenMU doesn't allow in character names. +// +// This is a best-effort fallback for servers which don't send the dedicated +// ExternalChatMessage: the prefix is a convention of the server, not a +// guarantee the client can check - a server which allows it in character +// names would have such characters shown as Discord users. When a server +// sends ExternalChatMessage, that is the authoritative signal. +inline constexpr wchar_t BridgedSenderPrefix = L'@'; + +[[nodiscard]] bool IsBridgedSender(std::wstring_view sender); + +// Shows a guild or alliance chat line of a bridged sender as a message from +// Discord, named after the character without the prefix. +void ShowBridged(Scope scope, std::wstring_view sender, std::wstring_view text); +} // namespace UI::Chat::External diff --git a/src/source/UI/Chat/MessageType.h b/src/source/UI/Chat/MessageType.h index 08bdfad4fc..230c4904fc 100644 --- a/src/source/UI/Chat/MessageType.h +++ b/src/source/UI/Chat/MessageType.h @@ -14,6 +14,9 @@ enum MESSAGE_TYPE TYPE_UNION_MESSAGE, TYPE_GENS_MESSAGE, TYPE_GM_MESSAGE, + // A view, not a message type: the messages from Discord. Each of them + // keeps the type of its scope (guild, alliance, chat) for its colours. + TYPE_DISCORD_MESSAGE, NUMBER_OF_TYPES, TYPE_UNKNOWN = 0xFFFFFFFF diff --git a/src/source/UI/HUD/ChatInputBox.cpp b/src/source/UI/HUD/ChatInputBox.cpp index 5305389e19..392b80a347 100644 --- a/src/source/UI/HUD/ChatInputBox.cpp +++ b/src/source/UI/HUD/ChatInputBox.cpp @@ -59,8 +59,6 @@ void mu::ui::window::CChatInputBox::Init() m_bShowSystemMessages = true; m_bShowChatLog = true; m_bWhisperSend = true; - - m_bShowMessageElseNormal = false; } bool mu::ui::window::CChatInputBox::Create( @@ -197,16 +195,7 @@ bool mu::ui::window::CChatInputBox::UpdateKeyEvent() { if (mu::ui::window::IsPress(VK_F2)) { - m_bShowMessageElseNormal = !m_bShowMessageElseNormal; - - if (m_bShowMessageElseNormal) - { - m_pNewUIChatLogWnd->ChangeMessage(mu::ui::window::TYPE_WHISPER_MESSAGE); - } - else - { - m_pNewUIChatLogWnd->ChangeMessage(mu::ui::window::TYPE_ALL_MESSAGE); - } + m_pNewUIChatLogWnd->ChangeMessage(m_pNewUIChatLogWnd->GetNextView()); PlayBuffer(SOUND_CLICK01); return false; diff --git a/src/source/UI/HUD/ChatInputBox.h b/src/source/UI/HUD/ChatInputBox.h index 7ce6e74a9a..8821bc0968 100644 --- a/src/source/UI/HUD/ChatInputBox.h +++ b/src/source/UI/HUD/ChatInputBox.h @@ -80,7 +80,6 @@ namespace mu::ui::window bool m_bShowSystemMessages; bool m_bShowChatLog; bool m_bWhisperSend; - bool m_bShowMessageElseNormal; void Init(); diff --git a/src/source/UI/HUD/ChatLogWindow.cpp b/src/source/UI/HUD/ChatLogWindow.cpp index 46a3886e84..e48f0bde1d 100644 --- a/src/source/UI/HUD/ChatLogWindow.cpp +++ b/src/source/UI/HUD/ChatLogWindow.cpp @@ -105,6 +105,18 @@ void mu::ui::window::CChatLogWindow::Release() } void mu::ui::window::CChatLogWindow::AddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType /*= TYPE_ALL_MESSAGE*/) +{ + AddTextToViews(strID, strText, MsgType, ErrMsgType, false); +} + +void mu::ui::window::CChatLogWindow::AddExternalText(const type_string& strSender, const type_string& strText, + MESSAGE_TYPE MsgType) +{ + AddTextToViews(strSender, strText, MsgType, TYPE_ALL_MESSAGE, true); +} + +void mu::ui::window::CChatLogWindow::AddTextToViews(const type_string& strID, const type_string& strText, + MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal) { if (strID.empty() && strText.empty()) { @@ -121,19 +133,24 @@ void mu::ui::window::CChatLogWindow::AddText(const type_string& strID, const typ RemoveFrontLine(TYPE_ALL_MESSAGE); } + if (bExternal && GetNumberOfLines(TYPE_DISCORD_MESSAGE) >= MAX_NUMBER_OF_LINES) + { + RemoveFrontLine(TYPE_DISCORD_MESSAGE); + } + if (m_vecFilters.empty()) { - ProcessAddText(strID, strText, MsgType, ErrMsgType); + ProcessAddText(strID, strText, MsgType, ErrMsgType, bExternal); } else { if (MsgType != TYPE_CHAT_MESSAGE) { - ProcessAddText(strID, strText, MsgType, ErrMsgType); + ProcessAddText(strID, strText, MsgType, ErrMsgType, bExternal); } else if (CheckFilterText(strID) || CheckFilterText(strText)) { - ProcessAddText(strID, strText, MsgType, ErrMsgType); + ProcessAddText(strID, strText, MsgType, ErrMsgType, bExternal); if (g_pOption->IsWhisperSound()) { @@ -143,7 +160,45 @@ void mu::ui::window::CChatLogWindow::AddText(const type_string& strID, const typ } } -void mu::ui::window::CChatLogWindow::ProcessAddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType) +void mu::ui::window::CChatLogWindow::AppendMessage(type_vector_msgs* pvecMsgs, const type_string& strID, + const type_string& strText, MESSAGE_TYPE MsgType, bool bExternal) +{ + const auto pMsgText = new CMessageText; + if (!pMsgText->Create(strID, strText, MsgType, bExternal)) + { + delete pMsgText; + return; + } + pvecMsgs->push_back(pMsgText); +} + +mu::ui::window::CChatLogWindow::type_vector_msgs* +mu::ui::window::CChatLogWindow::GetSecondaryMsgs(MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal) +{ + if (bExternal) + { + return &m_vecDiscordMsgs; + } + + // An error caused by, say, a whisper is listed among the whispers too. + if (MsgType == TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE) + { + type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); + assert(pErrvecMsgs != nullptr && "Error chat"); + return pErrvecMsgs; + } + + return nullptr; +} + +bool mu::ui::window::CChatLogWindow::IsInCurrentView(MESSAGE_TYPE MsgType, bool bExternal) const +{ + const MESSAGE_TYPE current = GetCurrentMsgType(); + return current == TYPE_ALL_MESSAGE || current == MsgType || (bExternal && current == TYPE_DISCORD_MESSAGE); +} + +void mu::ui::window::CChatLogWindow::ProcessAddText(const type_string& strID, const type_string& strText, + MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal) { m_bLinesDirty = true; type_vector_msgs* pvecMsgs = GetMsgs(MsgType); @@ -153,6 +208,8 @@ void mu::ui::window::CChatLogWindow::ProcessAddText(const type_string& strID, co return; } + type_vector_msgs* pSecondaryMsgs = GetSecondaryMsgs(MsgType, ErrMsgType, bExternal); + int nScrollLines = 0; if (strText.size() >= 20) { @@ -160,84 +217,28 @@ void mu::ui::window::CChatLogWindow::ProcessAddText(const type_string& strID, co SeparateText(strID, strText, MsgType, strText1, strText2); if (!strText1.empty()) { - const auto pMsgText = new CMessageText; - if (!pMsgText->Create(strID, strText, MsgType)) - delete pMsgText; - else + AppendMessage(pvecMsgs, strID, strText, MsgType, bExternal); + AppendMessage(&m_vecAllMsgs, strID, strText1, MsgType, bExternal); + if (pSecondaryMsgs != nullptr) { - pvecMsgs->push_back(pMsgText); + AppendMessage(pSecondaryMsgs, strID, strText1, MsgType, bExternal); } - const auto pAllMsgText = new CMessageText; - if (!pAllMsgText->Create(strID, strText1, MsgType)) - { - delete pAllMsgText; - } - else - { - m_vecAllMsgs.push_back(pAllMsgText); - } - - if ((MsgType == TYPE_ERROR_MESSAGE) && (ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE)) - { - type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); - if (pErrvecMsgs == nullptr) - { - assert(!"Error Chat"); - return; - } - - const auto pErrMsgText = new CMessageText; - if (!pErrMsgText->Create(strID, strText1, MsgType)) - delete pErrMsgText; - else - { - pErrvecMsgs->push_back(pErrMsgText); - } - } - - if (GetCurrentMsgType() == TYPE_ALL_MESSAGE || GetCurrentMsgType() == MsgType) + if (IsInCurrentView(MsgType, bExternal)) { nScrollLines++; } } if (!strText2.empty()) { - const auto pMsgText = new CMessageText; - if (!pMsgText->Create(L"", strText2, MsgType)) - delete pMsgText; - else + AppendMessage(pvecMsgs, L"", strText2, MsgType, bExternal); + AppendMessage(&m_vecAllMsgs, L"", strText2, MsgType, bExternal); + if (pSecondaryMsgs != nullptr) { - pvecMsgs->push_back(pMsgText); + AppendMessage(pSecondaryMsgs, L"", strText2, MsgType, bExternal); } - const auto pAllMsgText = new CMessageText; - if (!pAllMsgText->Create(L"", strText2, MsgType)) - delete pAllMsgText; - else - { - m_vecAllMsgs.push_back(pAllMsgText); - } - - if ((MsgType == TYPE_ERROR_MESSAGE) && (ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE)) - { - type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); - if (pErrvecMsgs == nullptr) - { - assert(!"Error chat 2"); - return; - } - - const auto pErrMsgText = new CMessageText; - if (!pErrMsgText->Create(L"", strText2, MsgType)) - delete pErrMsgText; - else - { - pErrvecMsgs->push_back(pErrMsgText); - } - } - - if (GetCurrentMsgType() == TYPE_ALL_MESSAGE || GetCurrentMsgType() == MsgType) + if (IsInCurrentView(MsgType, bExternal)) { nScrollLines++; } @@ -245,42 +246,14 @@ void mu::ui::window::CChatLogWindow::ProcessAddText(const type_string& strID, co } else { - const auto pMsgText = new CMessageText; - if (!pMsgText->Create(strID, strText, MsgType)) - delete pMsgText; - else + AppendMessage(pvecMsgs, strID, strText, MsgType, bExternal); + AppendMessage(&m_vecAllMsgs, strID, strText, MsgType, bExternal); + if (pSecondaryMsgs != nullptr) { - pvecMsgs->push_back(pMsgText); + AppendMessage(pSecondaryMsgs, strID, strText, MsgType, bExternal); } - const auto pAllMsgText = new CMessageText; - if (!pAllMsgText->Create(strID, strText, MsgType)) - delete pAllMsgText; - else - { - m_vecAllMsgs.push_back(pAllMsgText); - } - - if ((MsgType == TYPE_ERROR_MESSAGE) - && (ErrMsgType != TYPE_ERROR_MESSAGE && ErrMsgType != TYPE_ALL_MESSAGE)) - { - type_vector_msgs* pErrvecMsgs = GetMsgs(ErrMsgType); - if (pErrvecMsgs == nullptr) - { - assert(!"Error chat 3"); - return; - } - - const auto pErrMsgText = new CMessageText; - if (!pErrMsgText->Create(strID, strText, MsgType)) - delete pErrMsgText; - else - { - pErrvecMsgs->push_back(pErrMsgText); - } - } - - if (GetCurrentMsgType() == TYPE_ALL_MESSAGE || GetCurrentMsgType() == MsgType) + if (IsInCurrentView(MsgType, bExternal)) { nScrollLines++; } @@ -856,8 +829,9 @@ void mu::ui::window::CChatLogWindow::UpdatePointedLine() type_vector_msgs* pvecMsgs = GetMsgs(GetCurrentMsgType()); if (pvecMsgs != nullptr && pointed < (int)pvecMsgs->size()) { - const type_string& strID = (*pvecMsgs)[pointed]->GetID(); - if (!strID.empty() && g_pChatInputBox) + const CMessageText* pMsgText = (*pvecMsgs)[pointed]; + const type_string& strID = pMsgText->GetID(); + if (!strID.empty() && !pMsgText->IsExternal() && g_pChatInputBox) g_pChatInputBox->SetWhsprID(strID.c_str()); } } @@ -1046,6 +1020,8 @@ mu::ui::window::CChatLogWindow::type_vector_msgs* mu::ui::window::CChatLogWindow return &m_vecGensMsgs; case TYPE_GM_MESSAGE: return &m_vecGMMsgs; + case TYPE_DISCORD_MESSAGE: + return &m_vecDiscordMsgs; } return nullptr; @@ -1070,6 +1046,21 @@ mu::ui::window::MESSAGE_TYPE mu::ui::window::CChatLogWindow::GetCurrentMsgType() return m_CurrentRenderMsgType; } +// Players on a server without a Discord chat bridge never get the Discord +// view, so F2 keeps toggling between all messages and whispers for them. +mu::ui::window::MESSAGE_TYPE mu::ui::window::CChatLogWindow::GetNextView() const +{ + switch (GetCurrentMsgType()) + { + case TYPE_ALL_MESSAGE: + return TYPE_WHISPER_MESSAGE; + case TYPE_WHISPER_MESSAGE: + return m_vecDiscordMsgs.empty() ? TYPE_ALL_MESSAGE : TYPE_DISCORD_MESSAGE; + default: + return TYPE_ALL_MESSAGE; + } +} + void mu::ui::window::CChatLogWindow::ShowChatLog() { m_bShowChatLog = true; diff --git a/src/source/UI/HUD/ChatLogWindow.h b/src/source/UI/HUD/ChatLogWindow.h index 18deb1e157..151b2628da 100644 --- a/src/source/UI/HUD/ChatLogWindow.h +++ b/src/source/UI/HUD/ChatLogWindow.h @@ -31,12 +31,13 @@ namespace mu::ui::window type_string m_strID, m_strText; MESSAGE_TYPE m_MsgType; DWORD m_dwIndentSize; + bool m_bExternal; public: - TMessageText() : m_MsgType(TYPE_UNKNOWN), m_dwIndentSize(0) {} + TMessageText() : m_MsgType(TYPE_UNKNOWN), m_dwIndentSize(0), m_bExternal(false) {} ~TMessageText() { Release(); } - bool Create(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType) + bool Create(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, bool bExternal = false) { if (MsgType >= NUMBER_OF_TYPES) return false; @@ -44,6 +45,7 @@ namespace mu::ui::window m_strID = strID; m_strText = strText; m_MsgType = MsgType; + m_bExternal = bExternal; return true; } @@ -52,11 +54,18 @@ namespace mu::ui::window m_strID.resize(0); m_strText.resize(0); m_MsgType = TYPE_UNKNOWN; + m_bExternal = false; } const type_string& GetID() const { return m_strID; } const type_string& GetText() const { return m_strText; } MESSAGE_TYPE GetType() const { return m_MsgType; } + // Written outside the game (in Discord): its sender is no character, + // so it can't be whispered. + bool IsExternal() const + { + return m_bExternal; + } }; typedef TMessageText CMessageText; @@ -138,6 +147,7 @@ namespace mu::ui::window type_vector_msgs m_VecSystemMsgs; type_vector_msgs m_vecErrorMsgs; type_vector_msgs m_vecGMMsgs; + type_vector_msgs m_vecDiscordMsgs; type_vector_filters m_vecFilters; SIZE m_WndSize; @@ -209,6 +219,9 @@ namespace mu::ui::window void Release(); void AddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType = TYPE_ALL_MESSAGE); + // A message written outside the game, in the given scope's type. It is + // also listed in the TYPE_DISCORD_MESSAGE view. + void AddExternalText(const type_string& strSender, const type_string& strText, MESSAGE_TYPE MsgType); void RemoveFrontLine(MESSAGE_TYPE MsgType); void Clear(MESSAGE_TYPE MsgType); void ClearAll(); @@ -217,6 +230,9 @@ namespace mu::ui::window MESSAGE_TYPE GetCurrentMsgType() const; void ChangeMessage(MESSAGE_TYPE MsgType); + // The view F2 switches to from the current one: all, whispers, and + // Discord once a message from Discord has arrived. + MESSAGE_TYPE GetNextView() const; void ShowChatLog(); void HideChatLog(); @@ -259,7 +275,16 @@ namespace mu::ui::window protected: type_vector_msgs* GetMsgs(MESSAGE_TYPE MsgType); - void ProcessAddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType); + void AddTextToViews(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, + MESSAGE_TYPE ErrMsgType, bool bExternal); + void ProcessAddText(const type_string& strID, const type_string& strText, MESSAGE_TYPE MsgType, + MESSAGE_TYPE ErrMsgType, bool bExternal); + void AppendMessage(type_vector_msgs* pvecMsgs, const type_string& strID, const type_string& strText, + MESSAGE_TYPE MsgType, bool bExternal); + // The view a message is copied to besides its own and "all": the + // error's origin, or the Discord view. Null for none. + type_vector_msgs* GetSecondaryMsgs(MESSAGE_TYPE MsgType, MESSAGE_TYPE ErrMsgType, bool bExternal); + bool IsInCurrentView(MESSAGE_TYPE MsgType, bool bExternal) const; void SeparateText(IN const type_string& strID, IN const type_string& strText, MESSAGE_TYPE MsgType, OUT type_string& strText1, OUT type_string& strText2); diff --git a/tests/ui/CMakeLists.txt b/tests/ui/CMakeLists.txt index eaa25adc70..213ab6bc50 100644 --- a/tests/ui/CMakeLists.txt +++ b/tests/ui/CMakeLists.txt @@ -77,6 +77,16 @@ mu_add_test( LINK_LIBS MuClient ) +# The chat log pulls in client globals defined in Winmain.cpp, so it is linked +# here (same as test_audio_playback). +mu_add_test( + NAME external_chat_tests + SOURCES + test_external_chat.cpp + ${CMAKE_SOURCE_DIR}/src/source/App/Platform/Windows/Winmain.cpp + LINK_LIBS MuClient +) + mu_add_test( NAME help_pages_tests SOURCES test_help_pages.cpp diff --git a/tests/ui/test_external_chat.cpp b/tests/ui/test_external_chat.cpp new file mode 100644 index 0000000000..1013690fc7 --- /dev/null +++ b/tests/ui/test_external_chat.cpp @@ -0,0 +1,106 @@ +// doctest unit tests for chat messages written outside the game (Discord, +// #700): how their sender is shown, which chat they belong to, and the chat +// log's Discord view. +// +// Run: ctest --test-dir --build-config Release -R "External chat" + +#include "App/stdafx.h" +#include "doctest.h" + +#include "UI/Chat/ExternalChat.h" +#include "UI/HUD/ChatLogWindow.h" + +#include + +using namespace UI::Chat::External; +using mu::ui::window::CChatLogWindow; + +namespace +{ +// Short enough that the chat log keeps it on one line without measuring it. +constexpr const wchar_t* ShortText = L"hi guild"; +} // namespace + +TEST_CASE("External chat marks the sender with where they wrote [ui][chat]") +{ + CHECK(SenderLabel(Source::Discord, L"Sven") == L"[Discord] Sven"); +} + +TEST_CASE("External chat keeps a Discord name longer than a character's, up to its limit [ui][chat]") +{ + const std::wstring longName(MaxDisplayNameLength + 8, L'n'); + CHECK(SenderLabel(Source::Discord, longName) == L"[Discord] " + longName.substr(0, MaxDisplayNameLength)); +} + +TEST_CASE("External chat recognises senders bridged from Discord by their prefix [ui][chat]") +{ + CHECK(IsBridgedSender(L"@Hero")); + CHECK_FALSE(IsBridgedSender(L"Hero")); + CHECK_FALSE(IsBridgedSender(L"@")); + CHECK_FALSE(IsBridgedSender(L"")); +} + +TEST_CASE("External chat takes the colours of its scope [ui][chat]") +{ + CHECK(ScopeMessageType(Scope::Guild) == mu::ui::window::TYPE_GUILD_MESSAGE); + CHECK(ScopeMessageType(Scope::Alliance) == mu::ui::window::TYPE_UNION_MESSAGE); + CHECK(ScopeMessageType(Scope::World) == mu::ui::window::TYPE_CHAT_MESSAGE); +} + +TEST_CASE("External chat cuts the text to an in-game chat message [ui][chat]") +{ + const std::wstring longText(500, L'x'); + CHECK(ShownText(longText).size() < longText.size()); + CHECK(ShownText(ShortText) == ShortText); +} + +TEST_CASE("External chat is listed in its scope, among all messages and in the Discord view [ui][chat]") +{ + CChatLogWindow chatLog; + chatLog.AddExternalText(SenderLabel(Source::Discord, L"Sven"), ShortText, mu::ui::window::TYPE_GUILD_MESSAGE); + + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_GUILD_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_ALL_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_DISCORD_MESSAGE) == 1); +} + +TEST_CASE("In-game chat stays out of the Discord view [ui][chat]") +{ + CChatLogWindow chatLog; + chatLog.AddText(L"Player", ShortText, mu::ui::window::TYPE_GUILD_MESSAGE); + + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_GUILD_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_DISCORD_MESSAGE) == 0); +} + +TEST_CASE("F2 toggles all messages and whispers while nothing came from Discord [ui][chat]") +{ + CChatLogWindow chatLog; + CHECK(chatLog.GetNextView() == mu::ui::window::TYPE_WHISPER_MESSAGE); + + chatLog.ChangeMessage(mu::ui::window::TYPE_WHISPER_MESSAGE); + CHECK(chatLog.GetNextView() == mu::ui::window::TYPE_ALL_MESSAGE); +} + +TEST_CASE("F2 also offers the Discord view once something came from Discord [ui][chat]") +{ + CChatLogWindow chatLog; + chatLog.AddExternalText(SenderLabel(Source::Discord, L"Sven"), ShortText, mu::ui::window::TYPE_CHAT_MESSAGE); + + chatLog.ChangeMessage(mu::ui::window::TYPE_WHISPER_MESSAGE); + CHECK(chatLog.GetNextView() == mu::ui::window::TYPE_DISCORD_MESSAGE); + + chatLog.ChangeMessage(mu::ui::window::TYPE_DISCORD_MESSAGE); + CHECK(chatLog.GetNextView() == mu::ui::window::TYPE_ALL_MESSAGE); +} + +TEST_CASE("An error is still listed among the messages that caused it [ui][chat]") +{ + CChatLogWindow chatLog; + chatLog.AddText(L"", L"not online", mu::ui::window::TYPE_ERROR_MESSAGE, mu::ui::window::TYPE_WHISPER_MESSAGE); + + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_ERROR_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_WHISPER_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_ALL_MESSAGE) == 1); + CHECK(chatLog.GetNumberOfLines(mu::ui::window::TYPE_DISCORD_MESSAGE) == 0); +}