Replaces goonchat with a tgui based chat panel

    Fixes #52898
    Fixes #52663

It is as fast as goonchat was (if not faster in certain circumstances), and is very extensible. It has all the necessary code for sorting messages into categories, which means that one of the next features will be multiple tab support.

Additional features that you will get with tgchat right now:
    Massively faster server-side performance compared to goonchat, especially if batching multiple messages to one client.
    Message persistence across rounds and reconnects. (All messages are stored client-side in IndexedDB)
    More robust scroll tracking. If you scroll up, it will not change the scroll position on new messages like goonchat did.
    Multiple message combining. (Currently set to combine up to 5 messages over last 5 seconds).
    If using the highlighting feature, it highlights the whole message as well as the matching word.
    "Now playing" widget, with preview of the song title, a knob for adjusting the volume and a stop button.

Architecture is as following:
```
to_chat() -+
           |
        SSchat
   (queue, batching)
           |
  window.send_message()
           |
           v
+-------------+
| tgui-panel  |
|+-----------+|
|| tgchat    ||
|+-----------+|
+-------------+
```

Subsystem is basically goonchat, but without all the garbage that slows the servers down (string concatenation, double urlencoding, sanitizing, etc). Now, instead of all that, it's being slowed down by json_encode in /datum/tgui_window/proc/send_message, which IMO is completely worth it, and allows sending various templates and widgets to tgchat.

/datum/tgui_window abstracts the whole window away from you, establishes a nice message-passing interface between DM and JS, with two message queues on each side, automatically loads js/css assets for you, basically does everything. You as a developer only have to worry about sending/receiving messages and write javascript.

tgui-panel is a slimmed down version of tgui, and functions as a container for various widgets, and tgchat is one of them. It of course can be expanded with more stuff.

It's also a separate entry point and a JS bundle, so it's not bloating the main tgui bundle, and is currently sitting at about 230kB.
This commit is contained in:
Aleksej Komarov
2020-08-14 13:21:40 +12:00
committed by GitHub
parent 0278f4ead0
commit 0e1b20e6db
201 changed files with 7927 additions and 4015 deletions
+42
View File
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/// Admin music volume, from 0 to 1.
/client/var/admin_music_volume = 1
/**
* public
*
* Sends music data to the browser.
*
* Optional settings:
* - pitch: the playback rate
* - start: the start time of the sound
* - end: when the musics stops playing
*
* required url string Must be an https URL.
* optional extra_data list Optional settings.
*/
/datum/tgui_panel/proc/play_music(url, extra_data)
if(!is_ready())
return
if(!findtext(url, GLOB.is_http_protocol))
return
var/list/payload = list()
if(length(extra_data) > 0)
for(var/key in extra_data)
payload[key] = extra_data[key]
payload["url"] = url
window.send_message("audio/playMusic", payload)
/**
* public
*
* Stops playing music through the browser.
*/
/datum/tgui_panel/proc/stop_music()
if(!is_ready())
return
window.send_message("audio/stopMusic")
+53
View File
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/client/var/datum/tgui_panel/tgui_panel
/**
* tgui panel / chat troubleshooting verb
*/
/client/verb/fix_chat()
set name = "Fix chat"
set category = "OOC"
var/action
log_tgui(src, "tgui_panel: Started fix_chat.")
// Not initialized
if(!tgui_panel || !istype(tgui_panel))
log_tgui(src, "tgui_panel: datum is missing")
action = alert(src, "tgui panel was not initialized!\nSet it up again?", "", "OK", "Cancel")
if(action != "OK")
return
tgui_panel = new(src)
tgui_panel.initialize()
action = alert(src, "Wait a bit and tell me if it's fixed", "", "Fixed", "Nope")
if(action == "Fixed")
log_tgui(src, "tgui_panel: Fixed by calling 'new' + 'initialize'")
return
// Not ready
if(!tgui_panel?.is_ready())
log_tgui(src, "tgui_panel: not ready")
action = alert(src, "tgui panel looks like it's waiting for something.\nSend it a ping?", "", "OK", "Cancel")
if(action != "OK")
return
tgui_panel.window.send_message("ping", force = TRUE)
action = alert(src, "Wait a bit and tell me if it's fixed", "", "Fixed", "Nope")
if(action == "Fixed")
log_tgui(src, "tgui_panel: Fixed by sending a ping")
return
// Catch all solution
action = alert(src, "Looks like tgui panel was already setup, but we can always try again.\nSet it up again?", "", "OK", "Cancel")
if(action != "OK")
return
tgui_panel.initialize(force = TRUE)
action = alert(src, "Wait a bit and tell me if it's fixed", "", "Fixed", "Nope")
if(action == "Fixed")
log_tgui(src, "tgui_panel: Fixed by calling 'initialize'")
return
// Failed to fix
action = alert(src, "Welp, I'm all out of ideas. Try closing BYOND and reconnecting.\nWe could also disable tgui_panel and re-enable the old UI", "", "Thanks anyways", "Switch to old UI")
if (action == "Switch to old UI")
winset(src, "output", "on-show=&is-disabled=0&is-visible=1")
winset(src, "browseroutput", "is-disabled=1;is-visible=0")
log_tgui(src, "tgui_panel: Failed to fix.")
+80
View File
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/**
* Maximum number of connection records allowed to analyze.
* Should match the value set in the browser.
*/
#define TGUI_TELEMETRY_MAX_CONNECTIONS 5
/**
* Maximum time allocated for sending a telemetry packet.
*/
#define TGUI_TELEMETRY_RESPONSE_WINDOW 30 SECONDS
/// Time of telemetry request
/datum/tgui_panel/var/telemetry_requested_at
/// Time of telemetry analysis completion
/datum/tgui_panel/var/telemetry_analyzed_at
/// List of previous client connections
/datum/tgui_panel/var/list/telemetry_connections
/**
* private
*
* Requests some telemetry from the client.
*/
/datum/tgui_panel/proc/request_telemetry()
telemetry_requested_at = world.time
telemetry_analyzed_at = null
window.send_message("telemetry/request", list(
"limits" = list(
"connections" = TGUI_TELEMETRY_MAX_CONNECTIONS,
),
))
/**
* private
*
* Analyzes a telemetry packet.
*
* Is currently only useful for detecting ban evasion attempts.
*/
/datum/tgui_panel/proc/analyze_telemetry(payload)
if(world.time > telemetry_requested_at + TGUI_TELEMETRY_RESPONSE_WINDOW)
message_admins("[key_name(client)] sent telemetry outside of the allocated time window.")
return
if(telemetry_analyzed_at)
message_admins("[key_name(client)] sent telemetry more than once.")
return
telemetry_analyzed_at = world.time
if(!payload)
return
telemetry_connections = payload["connections"]
var/len = length(telemetry_connections)
if(len == 0)
return
if(len > TGUI_TELEMETRY_MAX_CONNECTIONS)
message_admins("[key_name(client)] was kicked for sending a huge telemetry payload")
qdel(client)
return
var/list/found
for(var/i in 1 to len)
if(QDELETED(client))
// He got cleaned up before we were done
return
var/list/row = telemetry_connections[i]
// Check for a malformed history object
if (!row || row.len < 3 || (!row["ckey"] || !row["address"] || !row["computer_id"]))
return
if (world.IsBanned(row["ckey"], row["address"], row["computer_id"], real_bans_only = TRUE))
found = row
break
CHECK_TICK
// This fucker has a history of playing on a banned account.
if(found)
var/msg = "[key_name(client)] has a banned account in connection history! (Matched: [found["ckey"]], [found["address"]], [found["computer_id"]])"
message_admins(msg)
log_admin_private(msg)
+95
View File
@@ -0,0 +1,95 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/**
* tgui_panel datum
* Hosts tgchat and other nice features.
*/
/datum/tgui_panel
var/client/client
var/datum/tgui_window/window
var/broken = FALSE
var/initialized_at
/datum/tgui_panel/New(client/client)
src.client = client
window = new(client, "browseroutput")
window.subscribe(src, .proc/on_message)
/datum/tgui_panel/Del()
window.unsubscribe(src)
window.close()
return ..()
/**
* public
*
* TRUE if panel is initialized and ready to receive messages.
*/
/datum/tgui_panel/proc/is_ready()
return !broken && window.is_ready()
/**
* public
*
* Initializes tgui panel.
*/
/datum/tgui_panel/proc/initialize(force = FALSE)
initialized_at = world.time
// Perform a clean initialization
window.initialize(inline_assets = list(
get_asset_datum(/datum/asset/simple/tgui_common),
get_asset_datum(/datum/asset/simple/tgui_panel),
))
window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/fontawesome))
window.send_asset(get_asset_datum(/datum/asset/spritesheet/chat))
request_telemetry()
addtimer(CALLBACK(src, .proc/on_initialize_timed_out), 2 SECONDS)
/**
* private
*
* Called when initialization has timed out.
*/
/datum/tgui_panel/proc/on_initialize_timed_out()
// Currently does nothing but sending a message to old chat.
SEND_TEXT(client, "<span class=\"userdanger\">Failed to load fancy chat, reverting to old chat. Certain features won't work.</span>")
/**
* private
*
* Callback for handling incoming tgui messages.
*/
/datum/tgui_panel/proc/on_message(type, payload)
if(type == "ready")
broken = FALSE
window.send_message("update", list(
"config" = list(
"client" = list(
"ckey" = client.ckey,
"address" = client.address,
"computer_id" = client.computer_id,
),
"window" = list(
"fancy" = FALSE,
"locked" = FALSE,
),
),
))
return TRUE
if(type == "audio/setAdminMusicVolume")
client.admin_music_volume = payload["volume"]
return TRUE
if(type == "telemetry")
analyze_telemetry(payload)
return TRUE
/**
* public
*
* Sends a round restart notification.
*/
/datum/tgui_panel/proc/send_roundrestart()
window.send_message("roundrestart")
+71
View File
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/**
* global
*
* Circumvents the message queue and sends the message
* to the recipient (target) as soon as possible.
*/
/proc/to_chat_immediate(
target,
text,
handle_whitespace = TRUE,
trailing_newline = TRUE,
confidential = FALSE)
if(!target || !text)
return
if(target == world)
target = GLOB.clients
var/flags = handle_whitespace \
| trailing_newline << 1 \
| confidential << 2
var/message = TGUI_CREATE_MESSAGE("chat/message", list(
"text" = text,
"flags" = flags,
))
if(islist(target))
for(var/_target in target)
var/client/client = CLIENT_FROM_VAR(_target)
if(client)
// Send to tgchat
client.tgui_panel?.window.send_raw_message(message)
// Send to old chat
SEND_TEXT(client, text)
return
var/client/client = CLIENT_FROM_VAR(target)
if(client)
// Send to tgchat
client.tgui_panel?.window.send_raw_message(message)
// Send to old chat
SEND_TEXT(client, text)
/**
* global
*
* Sends the message to the recipient (target).
*/
/proc/to_chat(
target,
text,
handle_whitespace = TRUE,
trailing_newline = TRUE,
confidential = FALSE)
if(Master.current_runlevel == RUNLEVEL_INIT || !SSchat?.initialized)
to_chat_immediate(
target,
text,
handle_whitespace,
trailing_newline,
confidential)
return
if(!target || !text)
return
if(target == world)
target = GLOB.clients
var/flags = handle_whitespace \
| trailing_newline << 1 \
| confidential << 2
SSchat.queue(target, text, flags)