diff --git a/api/chat.lua b/api/chat.lua index 62115ff..eff916f 100644 --- a/api/chat.lua +++ b/api/chat.lua @@ -44,15 +44,56 @@ local function make_not_enabled_cb() end end +-- How much of a message to quote when attributing a failed send to it (item +-- 2: with several sends in flight -- the fast-typing case that triggers rate +-- limiting -- the player needs to tell which one a notice is about). Long +-- enough to recognize at a glance, short enough to stay on one line. +local NOTICE_QUOTE_MAX = 30 + +local function quote_for_notice(text) + return '"' .. MPAPI.truncate(text, NOTICE_QUOTE_MAX) .. '"' +end + local function make_publish_fn(lobby) return function(text) if not MPAPI.config.chat_enabled then MPAPI.chat.addMessage(localize('k_chat_client_disabled'), COLOUR_SYSTEM) return end - MPAPI._internal.send_chat_message(lobby.code, text, function(err, _) + -- The server rejects whitespace-only messages; don't echo them either. + if text:match('^%s*$') then + return + end + -- Optimistic echo: the sender sees their message the instant they hit + -- enter rather than after the round trip. The log is append-only on + -- both backends (DebugPlus owns its own log and hands back no handle), + -- so a line cannot be retracted or recoloured afterwards -- instead a + -- failure notice below quotes the message it refers to, which also + -- disambiguates several sends in flight. + local own_name = MPAPI.chat._own_name or localize('k_you') + MPAPI.chat.addMessage(own_name .. ': ' .. text, COLOUR_OWN) + + MPAPI._internal.send_chat_message(lobby.code, text, function(err, data) if err then - MPAPI.chat.addMessage('[!] ' .. tostring(err), COLOUR_SYSTEM) + -- Client-side failures (offline, transport, an unreadable + -- server response) don't carry player-facing copy -- only + -- ErrorKind.SERVER does -- so those get a generic reason + -- instead of leaking transport/proxy jargon. + local reason = tostring(err) + if err.kind ~= MPAPI.ErrorKind.SERVER then + reason = localize('k_chat_reason_unavailable') + end + MPAPI.chat.addMessage( + localize('k_chat_not_sent') .. ' ' .. quote_for_notice(text) .. ': ' .. reason, + COLOUR_SYSTEM + ) + return + end + + if data and type(data.publishText) == 'string' and data.publishText ~= text then + -- Moderation rewrote the message; the echo above showed the raw + -- form, so tell the sender what other players actually got. + MPAPI.chat.addMessage(localize('k_chat_sent_as') .. ' ' .. data.publishText, COLOUR_SYSTEM) end end) end @@ -74,9 +115,15 @@ local function subscribe_chat(lobby) return end + -- Own messages are rendered directly from the send result (see + -- make_publish_fn), never from this subscription; drop our own MQTT + -- echo so they don't double-render. + if sender_id == lobby.player_id then + return + end + local name = data.displayName or sender_id - local colour = (sender_id == lobby.player_id) and COLOUR_OWN or COLOUR_INCOMING - MPAPI.chat.addMessage(name .. ': ' .. data.message, colour) + MPAPI.chat.addMessage(name .. ': ' .. data.message, COLOUR_INCOMING) end) end diff --git a/compatibility/debugplus.lua b/compatibility/debugplus.lua index fbc5f8b..d6eefc3 100644 --- a/compatibility/debugplus.lua +++ b/compatibility/debugplus.lua @@ -87,6 +87,11 @@ local function patch(dp_console, orig_render) -- /say → send as chat, don't let DP see it at all. if M.send_fn then M.send_fn(say_arg) + elseif MPAPI.connection_state.chat_enabled then + -- Chat is enabled server-side but we're not in a lobby + -- (send_fn only exists while lobby chat is active) -- + -- mirrors chat.lua's make_not_enabled_cb branch. + M.addMessage(localize('k_chat_lobby_only'), COLOUR_SYSTEM) else M.addMessage(localize('k_chat_not_enabled'), COLOUR_SYSTEM) end diff --git a/localization/en-us.lua b/localization/en-us.lua index 153cf02..4bf71e6 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -73,6 +73,13 @@ return { k_match_status_completed = 'Completed', k_match_status_abandoned = 'Abandoned', k_match_status_terminated = 'Terminated', + -- Delivery outcomes for a message the player already saw echoed. + -- The frame makes no claim about why or for how long: the reason + -- appended after it is the server's own self-contained sentence, + -- or k_chat_reason_unavailable when the server gave none. + k_chat_not_sent = '[MultiplayerAPI] Not sent', + k_chat_sent_as = '[MultiplayerAPI] Other players saw:', + k_chat_reason_unavailable = 'Something went wrong. Try again.', -- Chat section in account overlay k_chat_section_title = 'Chat', b_chat_on = 'On', diff --git a/networking/api_client/lobby.lua b/networking/api_client/lobby.lua index 81d08bb..b574a0a 100644 --- a/networking/api_client/lobby.lua +++ b/networking/api_client/lobby.lua @@ -96,13 +96,25 @@ function api_client:send_chat_message(jwt_token, code, message, callback) self:_enqueue(function(status, body) if status < 200 or status >= 300 then + -- Only a decoded { error = "..." } body is genuine player-facing + -- copy (chat.lua's k_chat_not_sent guard shows it verbatim). A + -- non-JSON or bare-status response -- a proxy error page, an + -- unexpected 5xx with no body -- carries no usable reason, so it's + -- TRANSPORT rather than SERVER: the caller falls back to its own + -- clean message instead of leaking this raw text to the player. local ok, data = pcall(api_client.json_decode, body) - local emsg = (ok and data and data.error) or ('Server returned status ' .. tostring(status)) - callback(MPAPI.make_error(MPAPI.ErrorKind.SERVER, emsg), nil) + if ok and data and data.error then + callback(MPAPI.make_error(MPAPI.ErrorKind.SERVER, data.error), nil) + else + callback(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'Server returned status ' .. tostring(status)), nil) + end return end - callback(nil, { ok = true }) + -- Pass the response body through: on a moderation rewrite it carries + -- publishText (what other players actually received). + local ok, data = pcall(api_client.json_decode, body) + callback(nil, (ok and type(data) == 'table') and data or { ok = true }) end, function(msg) callback(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'HTTP request failed: ' .. tostring(msg)), nil) end)