[MIRROR] Implementing tgchat to replace old VChat (#7371)

Co-authored-by: Heroman3003 <31296024+Heroman3003@users.noreply.github.com>
Co-authored-by: Selis <selis@xynolabs.com>
This commit is contained in:
CHOMPStation2
2023-12-09 05:03:57 -07:00
committed by GitHub
parent a5c370fb85
commit c2bc0f78c8
46 changed files with 3204 additions and 1622 deletions

View File

@@ -3,6 +3,11 @@
* SPDX-License-Identifier: MIT
*/
/// How many chat payloads to keep in history
#define CHAT_RELIABILITY_HISTORY_SIZE 5
/// How many resends to allow before giving up
#define CHAT_RELIABILITY_MAX_RESENDS 3
#define MESSAGE_TYPE_SYSTEM "system"
#define MESSAGE_TYPE_LOCALCHAT "localchat"
#define MESSAGE_TYPE_PLOCALCHAT "plocalchat"

View File

@@ -517,6 +517,37 @@ GLOBAL_LIST_EMPTY(cached_examine_icons)
return list(rsc_ref, hash, "asset.[hash]")
/// Gets a dummy savefile for usage in icon generation.
/// Savefiles generated from this proc will be empty.
/proc/get_dummy_savefile(from_failure = FALSE)
var/static/next_id = 0
if(next_id++ > 9)
next_id = 0
var/savefile_path = "tmp/dummy-save-[next_id].sav"
try
if(fexists(savefile_path))
fdel(savefile_path)
return new /savefile(savefile_path)
catch(var/exception/error)
// if we failed to create a dummy once, try again; maybe someone slept somewhere they shouldnt have
if(from_failure) // this *is* the retry, something fucked up
CRASH("get_dummy_savefile failed to create a dummy savefile: '[error]'")
return get_dummy_savefile(from_failure = TRUE)
/**
* Converts an icon to base64. Operates by putting the icon in the iconCache savefile,
* exporting it as text, and then parsing the base64 from that.
* (This relies on byond automatically storing icons in savefiles as base64)
*/
/proc/icon2base64(icon/icon)
if (!isicon(icon))
return FALSE
var/savefile/dummySave = get_dummy_savefile()
WRITE_FILE(dummySave["dummy"], icon)
var/iconData = dummySave.ExportText("dummy")
var/list/partial = splittext(iconData, "{")
return replacetext(copytext_char(partial[2], 3, -5), "\n", "") //if cleanup fails we want to still return the correct base64
///given a text string, returns whether it is a valid dmi icons folder path
/proc/is_valid_dmi_file(icon_path)
if(!istext(icon_path) || !length(icon_path))
@@ -671,6 +702,39 @@ GLOBAL_LIST_EMPTY(cached_examine_icons)
return get_asset_url(key)
return "<img class='[extra_classes] icon icon-[icon_state]' src='[get_asset_url(key)]'>"
/proc/icon2base64html(target, var/custom_classes = "")
if (!target)
return
var/static/list/bicon_cache = list()
if (isicon(target))
var/icon/target_icon = target
var/icon_base64 = icon2base64(target_icon)
if (target_icon.Height() > world.icon_size || target_icon.Width() > world.icon_size)
var/icon_md5 = md5(icon_base64)
icon_base64 = bicon_cache[icon_md5]
if (!icon_base64) // Doesn't exist yet, make it.
bicon_cache[icon_md5] = icon_base64 = icon2base64(target_icon)
return "<img class='icon icon-misc [custom_classes]' src='data:image/png;base64,[icon_base64]'>"
// Either an atom or somebody fucked up and is gonna get a runtime, which I'm fine with.
var/atom/target_atom = target
var/key = "[istype(target_atom.icon, /icon) ? "[REF(target_atom.icon)]" : target_atom.icon]:[target_atom.icon_state]"
if (!bicon_cache[key]) // Doesn't exist, make it.
var/icon/target_icon = icon(target_atom.icon, target_atom.icon_state, SOUTH, 1)
if (ishuman(target)) // Shitty workaround for a BYOND issue.
var/icon/temp = target_icon
target_icon = icon()
target_icon.Insert(temp, dir = SOUTH)
bicon_cache[key] = icon2base64(target_icon)
return "<img class='icon icon-[target_atom.icon_state] [custom_classes]' src='data:image/png;base64,[bicon_cache[key]]'>"
//Costlier version of icon2html() that uses getFlatIcon() to account for overlays, underlays, etc. Use with extreme moderation, ESPECIALLY on mobs.
/proc/costly_icon2html(thing, target, sourceonly = FALSE)
if (!thing)

View File

@@ -347,7 +347,7 @@
if(!text_tag_cache[tagname])
var/icon/tag = icon(text_tag_icons, tagname)
text_tag_cache[tagname] = bicon(tag, TRUE, "text_tag")
if(C.chatOutput.broken)
if(!C.tgui_panel.is_ready() || C.tgui_panel.oldchat)
return "<IMG src='\ref[text_tag_icons]' class='text_tag' iconstate='[tagname]'" + (tagdesc ? " alt='[tagdesc]'" : "") + ">"
return text_tag_cache[tagname]

View File

@@ -12,7 +12,7 @@
// #define to_chat(target, message) target << message Not anymore!
//#define to_chat to_chat_filename=__FILE__;to_chat_line=__LINE__;to_chat_src=src;__to_chat
#define to_chat __to_chat
//#define to_chat __to_chat
#define to_world(message) to_chat(world, message)
#define to_world_log(message) world.log << message
// TODO - Baystation has this log to crazy places. For now lets just world.log, but maybe look into it later.

View File

@@ -1,77 +1,100 @@
/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
SUBSYSTEM_DEF(chat)
name = "Chat"
flags = SS_TICKER
wait = 1 // SS_TICKER means this runs every tick
flags = SS_TICKER|SS_NO_INIT
wait = 1
priority = FIRE_PRIORITY_CHAT
init_order = INIT_ORDER_CHAT
var/list/list/msg_queue = list() //List of lists
/// Assosciates a ckey with a list of messages to send to them.
var/list/list/datum/chat_payload/client_to_payloads = list()
/datum/controller/subsystem/chat/Initialize(timeofday)
init_vchat()
..()
/// Associates a ckey with an assosciative list of their last CHAT_RELIABILITY_HISTORY_SIZE messages.
var/list/list/datum/chat_payload/client_to_reliability_history = list()
/// Assosciates a ckey with their next sequence number.
var/list/client_to_sequence_number = list()
/datum/controller/subsystem/chat/proc/generate_payload(client/target, message_data)
var/sequence = client_to_sequence_number[target.ckey]
client_to_sequence_number[target.ckey] += 1
var/datum/chat_payload/payload = new
payload.sequence = sequence
payload.content = message_data
if(!(target.ckey in client_to_reliability_history))
client_to_reliability_history[target.ckey] = list()
var/list/client_history = client_to_reliability_history[target.ckey]
client_history["[sequence]"] = payload
if(length(client_history) > CHAT_RELIABILITY_HISTORY_SIZE)
var/oldest = text2num(client_history[1])
for(var/index in 2 to length(client_history))
var/test = text2num(client_history[index])
if(test < oldest)
oldest = test
client_history -= "[oldest]"
return payload
/datum/controller/subsystem/chat/proc/send_payload_to_client(client/target, datum/chat_payload/payload)
target.tgui_panel.window.send_message("chat/message", payload.into_message())
SEND_TEXT(target, payload.get_content_as_html())
/datum/controller/subsystem/chat/fire()
var/list/msg_queue = src.msg_queue // Local variable for sanic speed.
for(var/client/C as anything in msg_queue)
var/list/messages = msg_queue[C]
msg_queue -= C
if (C)
C << output(jsEncode(messages), "htmloutput:putmessage")
for(var/ckey in client_to_payloads)
var/client/target = GLOB.directory[ckey]
if(isnull(target)) // verify client still exists
LAZYREMOVE(client_to_payloads, ckey)
continue
for(var/datum/chat_payload/payload as anything in client_to_payloads[ckey])
send_payload_to_client(target, payload)
LAZYREMOVE(client_to_payloads, ckey)
if(MC_TICK_CHECK)
return
/datum/controller/subsystem/chat/stat_entry()
..("C:[msg_queue.len]")
/datum/controller/subsystem/chat/proc/queue(queue_target, list/message_data)
var/list/targets = islist(queue_target) ? queue_target : list(queue_target)
for(var/target in targets)
var/client/client = CLIENT_FROM_VAR(target)
if(isnull(client))
continue
LAZYADDASSOCLIST(client_to_payloads, client.ckey, generate_payload(client, message_data))
/datum/controller/subsystem/chat/proc/queue(target, time, message, handle_whitespace = TRUE)
if(!target || !message)
/datum/controller/subsystem/chat/proc/send_immediate(send_target, list/message_data)
var/list/targets = islist(send_target) ? send_target : list(send_target)
for(var/target in targets)
var/client/client = CLIENT_FROM_VAR(target)
if(isnull(client))
continue
send_payload_to_client(client, generate_payload(client, message_data))
/datum/controller/subsystem/chat/proc/handle_resend(client/client, sequence)
var/list/client_history = client_to_reliability_history[client.ckey]
sequence = "[sequence]"
if(isnull(client_history) || !(sequence in client_history))
return
if(!istext(message))
stack_trace("to_chat called with invalid input type")
return
var/datum/chat_payload/payload = client_history[sequence]
if(payload.resends > CHAT_RELIABILITY_MAX_RESENDS)
return // we tried but byond said no
// Currently to_chat(world, ...) gets sent individually to each client. Consider.
if(target == world)
target = GLOB.clients
//Some macros remain in the string even after parsing and fuck up the eventual output
var/original_message = message
message = replacetext(message, "\n", "<br>")
message = replacetext(message, "\improper", "")
message = replacetext(message, "\proper", "")
if(isnull(time))
time = world.time
var/list/messageStruct = list("time" = time, "message" = message);
if(islist(target))
for(var/I in target)
var/client/C = CLIENT_FROM_VAR(I) //Grab us a client if possible
if(!C || !C.chatOutput)
continue // No client? No care.
else if(C.chatOutput.broken)
DIRECT_OUTPUT(C, original_message)
continue
else if(!C.chatOutput.loaded)
continue // If not loaded yet, do nothing and history-sending on load will get it.
LAZYINITLIST(msg_queue[C])
msg_queue[C][++msg_queue[C].len] = messageStruct
else
var/client/C = CLIENT_FROM_VAR(target) //Grab us a client if possible
if(!C || !C.chatOutput)
return // No client? No care.
else if(C.chatOutput.broken)
DIRECT_OUTPUT(C, original_message)
return
else if(!C.chatOutput.loaded)
return // If not loaded yet, do nothing and history-sending on load will get it.
LAZYINITLIST(msg_queue[C])
msg_queue[C][++msg_queue[C].len] = messageStruct
payload.resends += 1
send_payload_to_client(client, client_history[sequence])
/*
SSblackbox.record_feedback(
"nested tally",
"chat_resend_byond_version",
1,
list(
"[client.byond_version]",
"[client.byond_build]",
),
)
*/

View File

@@ -0,0 +1,44 @@
/*!
* Copyright (c) 2022 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
SUBSYSTEM_DEF(ping)
name = "Ping"
priority = FIRE_PRIORITY_PING
// init_stage = INITSTAGE_EARLY
wait = 4 SECONDS
flags = SS_NO_INIT
runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT
var/list/currentrun = list()
/datum/controller/subsystem/ping/stat_entry()
..("P:[GLOB.clients.len]")
/datum/controller/subsystem/ping/fire(resumed = FALSE)
// Prepare the new batch of clients
if (!resumed)
src.currentrun = GLOB.clients.Copy()
// De-reference the list for sanic speeds
var/list/currentrun = src.currentrun
while (currentrun.len)
var/client/client = currentrun[currentrun.len]
currentrun.len--
if(!client.is_preference_enabled(/datum/client_preference/vchat_enable))
winset(client, "output", "on-show=&is-disabled=0&is-visible=1")
winset(client, "browseroutput", "is-disabled=1;is-visible=0")
client.tgui_panel.oldchat = TRUE
if (client?.tgui_panel?.is_ready())
// Send a soft ping
client.tgui_panel.window.send_message("ping/soft", list(
// Slightly less than the subsystem timer (somewhat arbitrary)
// to prevent incoming pings from resetting the afk state
"afk" = client.is_afk(3.5 SECONDS),
))
if (MC_TICK_CHECK)
return

View File

@@ -2,6 +2,7 @@
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/**
* tgui subsystem
*
@@ -37,7 +38,7 @@ SUBSYSTEM_DEF(tgui)
/datum/controller/subsystem/tgui/stat_entry()
..("P:[all_uis.len]")
/datum/controller/subsystem/tgui/fire(resumed = 0)
/datum/controller/subsystem/tgui/fire(resumed = FALSE)
if(!resumed)
src.current_run = all_uis.Copy()
// Cache for sanic speed (lists are references anyways)
@@ -46,8 +47,8 @@ SUBSYSTEM_DEF(tgui)
var/datum/tgui/ui = current_run[current_run.len]
current_run.len--
// TODO: Move user/src_object check to process()
if(ui && ui.user && ui.src_object)
ui.process()
if(ui?.user && ui.src_object)
ui.process(wait * 0.1)
else
ui.close(0)
if(MC_TICK_CHECK)
@@ -86,6 +87,8 @@ SUBSYSTEM_DEF(tgui)
window_found = TRUE
break
if(!window_found)
log_tgui(user, "Error: Pool exhausted",
context = "SStgui/request_pooled_window")
return null
return window
@@ -97,6 +100,7 @@ SUBSYSTEM_DEF(tgui)
* required user mob
*/
/datum/controller/subsystem/tgui/proc/force_close_all_windows(mob/user)
log_tgui(user, context = "SStgui/force_close_all_windows")
if(user.client)
user.client.tgui_windows = list()
for(var/i in 1 to TGUI_WINDOW_HARD_LIMIT)
@@ -112,6 +116,7 @@ SUBSYSTEM_DEF(tgui)
* required window_id string
*/
/datum/controller/subsystem/tgui/proc/force_close_window(mob/user, window_id)
log_tgui(user, context = "SStgui/force_close_window")
// Close all tgui datums based on window_id.
for(var/datum/tgui/ui in user.tgui_open_uis)
if(ui.window && ui.window.id == window_id)
@@ -121,22 +126,23 @@ SUBSYSTEM_DEF(tgui)
// Close window directly just to be sure.
user << browse(null, "window=[window_id]")
/**
* public
*
* Get a open UI given a user, src_object, and ui_key and try to update it with data.
*
* required user mob The mob who opened/is using the UI.
* required src_object datum The object/datum which owns the UI.
* required ui_key string The ui_key of the UI.
*
* return datum/tgui The found UI.
**/
/**
* public
*
* Try to find an instance of a UI, and push an update to it.
*
* required user mob The mob who opened/is using the UI.
* required src_object datum The object/datum which owns the UI.
* optional ui datum/tgui The UI to be updated, if it exists.
* optional force_open bool If the UI should be re-opened instead of updated.
*
* return datum/tgui The found UI.
*/
/datum/controller/subsystem/tgui/proc/try_update_ui(
mob/user,
datum/src_object,
datum/tgui/ui)
// Look up a UI if it wasn't passed.
// Look up a UI if it wasn't passed
if(isnull(ui))
ui = get_open_ui(user, src_object)
// Couldn't find a UI.
@@ -152,17 +158,16 @@ SUBSYSTEM_DEF(tgui)
ui.send_update()
return ui
/**
* private
*
* Get a open UI given a user, src_object, and ui_key.
*
* required user mob The mob who opened/is using the UI.
* required src_object datum The object/datum which owns the UI.
* required ui_key string The ui_key of the UI.
*
* return datum/tgui The found UI.
**/
/**
* public
*
* Get a open UI given a user and src_object.
*
* required user mob The mob who opened/is using the UI.
* required src_object datum The object/datum which owns the UI.
*
* return datum/tgui The found UI.
*/
/datum/controller/subsystem/tgui/proc/get_open_ui(mob/user, datum/src_object)
// No UIs opened for this src_object
if(!LAZYLEN(src_object?.open_tguis))
@@ -171,56 +176,57 @@ SUBSYSTEM_DEF(tgui)
// Make sure we have the right user
if(ui.user == user)
return ui
return null // Couldn't find a UI!
return null
/**
* private
*
* Update all UIs attached to src_object.
*
* required src_object datum The object/datum which owns the UIs.
*
* return int The number of UIs updated.
**/
/**
* public
*
* Update all UIs attached to src_object.
*
* required src_object datum The object/datum which owns the UIs.
*
* return int The number of UIs updated.
*/
/datum/controller/subsystem/tgui/proc/update_uis(datum/src_object)
// No UIs opened for this src_object
if(!LAZYLEN(src_object?.open_tguis))
return 0
var/count = 0
for(var/datum/tgui/ui in src_object.open_tguis)
// Check the UI is valid.
if(ui && ui.src_object && ui.user && ui.src_object.tgui_host(ui.user))
// Check if UI is valid.
if(ui?.src_object && ui.user && ui.src_object.tgui_host(ui.user))
INVOKE_ASYNC(ui, TYPE_PROC_REF(/datum/tgui, process), wait * 0.1, TRUE)
count++ // Count each UI we update.
count++
return count
/**
* private
*
* Close all UIs attached to src_object.
*
* required src_object datum The object/datum which owns the UIs.
*
* return int The number of UIs closed.
**/
/**
* public
*
* Close all UIs attached to src_object.
*
* required src_object datum The object/datum which owns the UIs.
*
* return int The number of UIs closed.
*/
/datum/controller/subsystem/tgui/proc/close_uis(datum/src_object)
// No UIs opened for this src_object
if(!LAZYLEN(src_object?.open_tguis))
return 0
var/count = 0
for(var/datum/tgui/ui in src_object.open_tguis)
if(ui && ui.src_object && ui.user && ui.src_object.tgui_host(ui.user)) // Check the UI is valid.
ui.close() // Close the UI.
count++ // Count each UI we close.
// Check if UI is valid.
if(ui?.src_object && ui.user && ui.src_object.tgui_host(ui.user))
ui.close()
count++
return count
/**
* private
*
* Close all UIs regardless of their attachment to src_object.
*
* return int The number of UIs closed.
**/
/**
* public
*
* Close all UIs regardless of their attachment to src_object.
*
* return int The number of UIs closed.
*/
/datum/controller/subsystem/tgui/proc/close_all_uis()
var/count = 0
for(var/datum/tgui/ui in all_uis)
@@ -230,67 +236,67 @@ SUBSYSTEM_DEF(tgui)
count++
return count
/**
* private
*
* Update all UIs belonging to a user.
*
* required user mob The mob who opened/is using the UI.
* optional src_object datum If provided, only update UIs belonging this src_object.
*
* return int The number of UIs updated.
**/
/**
* public
*
* Update all UIs belonging to a user.
*
* required user mob The mob who opened/is using the UI.
* optional src_object datum If provided, only update UIs belonging this src_object.
*
* return int The number of UIs updated.
*/
/datum/controller/subsystem/tgui/proc/update_user_uis(mob/user, datum/src_object)
var/count = 0
if(length(user?.tgui_open_uis) == 0)
return count
for(var/datum/tgui/ui in user.tgui_open_uis)
if(isnull(src_object) || ui.src_object == src_object)
ui.process(force = 1)
ui.process(wait * 0.1, force = 1)
count++
return count
/**
* private
*
* Close all UIs belonging to a user.
*
* required user mob The mob who opened/is using the UI.
* optional src_object datum If provided, only close UIs belonging this src_object.
*
* return int The number of UIs closed.
**/
/datum/controller/subsystem/tgui/proc/close_user_uis(mob/user, datum/src_object, logout = FALSE)
/**
* public
*
* Close all UIs belonging to a user.
*
* required user mob The mob who opened/is using the UI.
* optional src_object datum If provided, only close UIs belonging this src_object.
*
* return int The number of UIs closed.
*/
/datum/controller/subsystem/tgui/proc/close_user_uis(mob/user, datum/src_object)
var/count = 0
if(length(user?.tgui_open_uis) == 0)
return count
for(var/datum/tgui/ui in user.tgui_open_uis)
if(isnull(src_object) || ui.src_object == src_object)
ui.close(logout = logout)
ui.close()
count++
return count
/**
* private
*
* Add a UI to the list of open UIs.
*
* required ui datum/tgui The UI to be added.
**/
/**
* private
*
* Add a UI to the list of open UIs.
*
* required ui datum/tgui The UI to be added.
*/
/datum/controller/subsystem/tgui/proc/on_open(datum/tgui/ui)
ui.user?.tgui_open_uis |= ui
LAZYOR(ui.src_object.open_tguis, ui)
all_uis |= ui
/**
* private
*
* Remove a UI from the list of open UIs.
*
* required ui datum/tgui The UI to be removed.
*
* return bool If the UI was removed or not.
**/
/**
* private
*
* Remove a UI from the list of open UIs.
*
* required ui datum/tgui The UI to be removed.
*
* return bool If the UI was removed or not.
*/
/datum/controller/subsystem/tgui/proc/on_close(datum/tgui/ui)
// Remove it from the list of processing UIs.
all_uis -= ui
@@ -302,28 +308,28 @@ SUBSYSTEM_DEF(tgui)
LAZYREMOVE(ui.src_object.open_tguis, ui)
return TRUE
/**
* private
*
* Handle client logout, by closing all their UIs.
*
* required user mob The mob which logged out.
*
* return int The number of UIs closed.
**/
/**
* private
*
* Handle client logout, by closing all their UIs.
*
* required user mob The mob which logged out.
*
* return int The number of UIs closed.
*/
/datum/controller/subsystem/tgui/proc/on_logout(mob/user)
return close_user_uis(user, logout = TRUE)
close_user_uis(user)
/**
* private
*
* Handle clients switching mobs, by transferring their UIs.
*
* required user source The client's original mob.
* required user target The client's new mob.
*
* return bool If the UIs were transferred.
**/
/**
* private
*
* Handle clients switching mobs, by transferring their UIs.
*
* required user source The client's original mob.
* required user target The client's new mob.
*
* return bool If the UIs were transferred.
*/
/datum/controller/subsystem/tgui/proc/on_transfer(mob/source, mob/target)
// The old mob had no open UIs.
if(length(source?.tgui_open_uis) == 0)

View File

@@ -0,0 +1,16 @@
/// Stores information about a chat payload
/datum/chat_payload
/// Sequence number of this payload
var/sequence = 0
/// Message we are sending
var/list/content
/// Resend count
var/resends = 0
/// Converts the chat payload into a JSON string
/datum/chat_payload/proc/into_message()
return "{\"sequence\":[sequence],\"content\":[json_encode(content)]}"
/// Returns an HTML-encoded message from our contents.
/datum/chat_payload/proc/get_content_as_html()
return message_to_html(content)

View File

@@ -60,7 +60,6 @@
GLOB.timezoneOffset = get_timezone_offset()
callHook("startup")
init_vchat()
//Emergency Fix
load_mods()
//end-emergency fix

View File

@@ -22,9 +22,9 @@
for(var/client/C in GLOB.admins)
if(R_ADMIN|R_EVENT & C.holder.rights)
if(C.is_preference_enabled(/datum/client_preference/admin/show_chat_prayers))
to_chat(C,msg)
to_chat(C, msg, type = MESSAGE_TYPE_PRAYER, confidential = TRUE)
C << 'sound/effects/ding.ogg'
to_chat(usr, "Your prayers have been received by the gods.")
to_chat(usr, "Your prayers have been received by the gods.", confidential = TRUE)
feedback_add_details("admin_verb","PR") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
log_pray(raw_msg, src)

View File

@@ -0,0 +1,2 @@
/datum/asset/spritesheet/chat
name = "chat"

View File

@@ -5,11 +5,9 @@
"tgui.bundle.css" = file("tgui/public/tgui.bundle.css"),
)
/* Comment will be removed in later part
/datum/asset/simple/tgui_panel
// keep_local_name = TRUE
assets = list(
"tgui-panel.bundle.js" = file("tgui/public/tgui-panel.bundle.js"),
"tgui-panel.bundle.css" = file("tgui/public/tgui-panel.bundle.css"),
)
*/

View File

@@ -41,8 +41,10 @@
var/datum/admins/deadmin_holder = null
var/buildmode = 0
var/last_message = "" //Contains the last message sent by this client - used to protect against copy-paste spamming.
var/last_message_count = 0 //contins a number of how many times a message identical to last_message was sent.
///Contains the last message sent by this client - used to protect against copy-paste spamming.
var/last_message = ""
///contins a number of how many times a message identical to last_message was sent.
var/last_message_count = 0
var/ircreplyamount = 0
var/entity_narrate_holder //Holds /datum/entity_narrate when using the relevant admin verbs.
@@ -56,9 +58,7 @@
var/area = null
var/time_died_as_mouse = null //when the client last died as a mouse
var/datum/tooltip/tooltips = null
var/datum/chatOutput/chatOutput
var/datum/volume_panel/volume_panel = null // Initialized by /client/verb/volume_panel()
var/chatOutputLoadedAt
var/seen_news = 0
var/adminhelped = 0

View File

@@ -135,7 +135,6 @@
if("usr") hsrc = mob
if("prefs") return prefs.process_link(usr,href_list)
if("vars") return view_var_Topic(href,href_list,hsrc)
if("chat") return chatOutput.Topic(href, href_list)
switch(href_list["action"])
if("openLink")
@@ -174,11 +173,6 @@
del(src)
return
chatOutput = new /datum/chatOutput(src) //veechat
chatOutput.send_resources()
spawn()
chatOutput.start()
//Only show this if they are put into a new_player mob. Otherwise, "what title screen?"
if(isnewplayer(src.mob))
to_chat(src, "<font color='red'>If the title screen is black, resources are still downloading. Please be patient until the title screen appears.</font>")
@@ -186,6 +180,9 @@
GLOB.clients += src
GLOB.directory[ckey] = src
// Instantiate tgui panel
tgui_panel = new(src, "browseroutput")
GLOB.tickets.ClientLogin(src) // CHOMPedit - Tickets System
//Admin Authorisation
@@ -214,6 +211,9 @@
if(prefs)
prefs.selecting_slots = FALSE
// Initialize tgui panel
tgui_panel.initialize()
connection_time = world.time
connection_realtime = world.realtime
connection_timeofday = world.timeofday
@@ -492,29 +492,6 @@
return FALSE
return ..()
/client/verb/reload_vchat()
set name = "Reload VChat"
set category = "OOC"
//Timing
if(src.chatOutputLoadedAt > (world.time - 10 SECONDS))
tgui_alert_async(src, "You can only try to reload VChat every 10 seconds at most.")
return
// YW EDIT: disabled until we can fix the lag: verbs -= /client/proc/vchat_export_log
//Log, disable
log_debug("[key_name(src)] reloaded VChat.")
winset(src, null, "outputwindow.htmloutput.is-visible=false;outputwindow.oldoutput.is-visible=false;outputwindow.chatloadlabel.is-visible=true")
//The hard way
qdel_null(src.chatOutput)
chatOutput = new /datum/chatOutput(src) //veechat
chatOutput.send_resources()
spawn()
chatOutput.start()
//This is for getipintel.net.
//You're welcome to replace this proc with your own that does your own cool stuff.
//Just set the client's ip_reputation var and make sure it makes sense with your config settings (higher numbers are worse results)

View File

@@ -296,7 +296,7 @@ var/list/_client_preferences_by_type
key = "SOUND_INSTRUMENT"
/datum/client_preference/vchat_enable
description = "Enable/Disable VChat"
description = "Enable/Disable TGChat"
key = "VCHAT_ENABLE"
enabled_description = "Enabled"
disabled_description = "Disabled"

View File

@@ -367,17 +367,17 @@
feedback_add_details("admin_verb","THInstm") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
/client/verb/toggle_vchat()
set name = "Toggle VChat"
set name = "Toggle TGChat"
set category = "Preferences"
set desc = "Toggles VChat. Reloading VChat and/or reconnecting required to affect changes."
set desc = "Toggles TGChat. Reloading TGChat and/or reconnecting required to affect changes."
var/pref_path = /datum/client_preference/vchat_enable
toggle_preference(pref_path)
SScharacter_setup.queue_preferences_save(prefs)
to_chat(src, "You have toggled VChat [is_preference_enabled(pref_path) ? "on" : "off"]. \
You will have to reload VChat and/or reconnect to the server for these changes to take place. \
VChat message persistence is not guaranteed if you change this again before the start of the next round.")
to_chat(src, "You have toggled TGChat [is_preference_enabled(pref_path) ? "on" : "off"]. \
You will have to reload TGChat and/or reconnect to the server for these changes to take place. \
TGChat message persistence is not guaranteed if you change this again before the start of the next round.")
/client/verb/toggle_chat_timestamps()
set name = "Toggle Chat Timestamps"

View File

@@ -0,0 +1,30 @@
## /TG/ Chat
/TG/ Chat, which will be referred to as TgChat from this point onwards, is a system in which we can send messages to clients in a controlled and semi-reliable manner. The standard way of sending messages to BYOND clients simply dumps whatever you output to them directly into their chat window, however BYOND allows us to load our own code on the client to change this behaviour in a way that allows us to do some pretty neat things.
### Message Format
TgChat handles sending messages from the server to the client through the use of JSON payloads, of which the format will change depending on the type of message and the intended client endpoint. An example of the payload for chat messages is as follows:
```json
{
"sequence": 0,
"content": {
"type": ". . .", // ?optional
"text": ". . .", // ?optional !atleast-one
"html": ". . .", // ?optional !atleast-one
"avoidHighlighting": 0 // ?optional
},
}
```
### Reliability
In the past there have been issues where BYOND will silently and without reason lose a message we sent to the client, to detect this and recover from it seamlessly TgChat also has a baked in reliability layer. This reliability layer is very primitive, and simply keeps track of received sequence numbers. Should the client receive an unexpected sequence number TgChat asks the server to resend any missing packets.
### Ping System
TgChat supports a round trip time ping measurement, which is displayed to the client so they can know how long it takes for their commands and inputs to reach the server. This is done by sending the client a ping request, `ping/soft`, which tells the client to send a ping to the server. When the server receives said ping it sends a reply, `ping/reply`, to the client with a payload containing the current DateTime which the client can reference against the initial ping request.
### Chat Tabs, Local Storage, and Highlighting
To make organizing and managing chat easier and more functional for both players and admins, TgChat has the ability to filter out messages based on their primary tag, such as individual departmental radios, to a dedicated chat tab for easier reading and comprehension. These tabs can also be configured to highlist messages based on a simple keyword search. You can set a multitude of different keywords to search for and they will be highlighting for instant alerting of the client. Said tabs, highlighting rules, and your chat history will persist thanks to use of local storage on the client. Using local storage TgChat can ensure that your preferences are saved and maintained between client restarts and switching between other /TG/ servers. Local Storage is also used to keep your chat history aswell, should you need to scroll through your chat logs.

View File

@@ -0,0 +1,48 @@
/// Old VChat Code Stuff
/* Old bicon code
/proc/expire_bicon_cache(key)
if(GLOB.bicon_cache[key])
GLOB.bicon_cache -= key
return TRUE
return FALSE
GLOBAL_LIST_EMPTY(bicon_cache) // Cache of the <img> tag results, not the icons
*/
/proc/bicon(var/obj, var/use_class = 1, var/custom_classes = "")
return icon2base64html(obj, custom_classes)
/* Old bicon code
var/class = use_class ? "class='icon misc [custom_classes]'" : null
if(!obj)
return
// Try to avoid passing bicon an /icon directly. It is better to pass it an atom so it can cache.
if(isicon(obj)) // Passed an icon directly, nothing to cache-key on, as icon refs get reused *often*
return "<img [class] src='data:image/png;base64,[icon2base64(obj)]'>"
// Either an atom or somebody fucked up and is gonna get a runtime, which I'm fine with.
var/atom/A = obj
var/key
var/changes_often = ishuman(A) || isobserver(A) // If this ends up with more, move it into a proc or var on atom.
if(changes_often)
key = "\ref[A]"
else
key = "[istype(A.icon, /icon) ? "\ref[A.icon]" : A.icon]:[A.icon_state]"
var/base64 = GLOB.bicon_cache[key]
// Non-human atom, no cache
if(!base64) // Doesn't exist, make it.
base64 = icon2base64(A.examine_icon(), key)
GLOB.bicon_cache[key] = base64
if(changes_often)
addtimer(CALLBACK(GLOBAL_PROC, .proc/expire_bicon_cache, key), 50 SECONDS, TIMER_UNIQUE)
// May add a class to the img tag created by bicon
if(use_class)
class = "class='icon [A.icon_state] [custom_classes]'"
return "<IMG [class] src='data:image/png;base64,[base64]'>"
*/

View File

@@ -0,0 +1,17 @@
/**
* Message-related procs
*
* Message format (/list):
* - type - Message type, must be one of defines in `code/__DEFINES/chat.dm`
* - text - Plain message text
* - html - HTML message text
* - Optional metadata, can be any key/value pair.
*
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/proc/message_to_html(message)
// Here it is possible to add a switch statement
// to custom-handle various message types.
return message["html"] || message["text"]

View File

@@ -0,0 +1,85 @@
/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/**
* Circumvents the message queue and sends the message
* to the recipient (target) as soon as possible.
*/
/proc/to_chat_immediate(
target,
html,
type = null,
text = null,
avoid_highlighting = FALSE,
// FIXME: These flags are now pointless and have no effect
handle_whitespace = TRUE,
trailing_newline = TRUE,
confidential = FALSE
)
// Useful where the integer 0 is the entire message. Use case is enabling to_chat(target, some_boolean) while preventing to_chat(target, "")
html = "[html]"
text = "[text]"
if(!target)
return
if(!html && !text)
CRASH("Empty or null string in to_chat proc call.")
if(target == world)
target = GLOB.clients
// Build a message
var/message = list()
if(type) message["type"] = type
if(text) message["text"] = text
if(html) message["html"] = html
if(avoid_highlighting) message["avoidHighlighting"] = avoid_highlighting
// send it immediately
SSchat.send_immediate(target, message)
/**
* Sends the message to the recipient (target).
*
* Recommended way to write to_chat calls:
* ```
* to_chat(client,
* type = MESSAGE_TYPE_INFO,
* html = "You have found <strong>[object]</strong>")
* ```
*/
/proc/to_chat(
target,
html,
type = null,
text = null,
avoid_highlighting = FALSE,
// FIXME: These flags are now pointless and have no effect
handle_whitespace = TRUE,
trailing_newline = TRUE,
confidential = FALSE
)
//if(isnull(Master) || !SSchat?.initialized || !MC_RUNNING(SSchat.init_stage))
if(isnull(Master) || !SSchat?.subsystem_initialized)
to_chat_immediate(target, html, type, text, avoid_highlighting)
return
// Useful where the integer 0 is the entire message. Use case is enabling to_chat(target, some_boolean) while preventing to_chat(target, "")
html = "[html]"
text = "[text]"
if(!target)
return
if(!html && !text)
CRASH("Empty or null string in to_chat proc call.")
if(target == world)
target = GLOB.clients
// Build a message
var/message = list()
if(type) message["type"] = type
if(text) message["text"] = text
if(html) message["html"] = html
if(avoid_highlighting) message["avoidHighlighting"] = avoid_highlighting
SSchat.queue(target, message)

View File

@@ -380,6 +380,8 @@
// Resend the assets
for(var/asset in sent_assets)
send_asset(asset)
if("chat/resend")
SSchat.handle_resend(client, payload)
/datum/tgui_window/vv_edit_var(var_name, var_value)
return var_name != NAMEOF(src, id) && ..()

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")

View File

@@ -0,0 +1,45 @@
/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
/client/var/datum/tgui_panel/tgui_panel
/**
* tgui panel / chat troubleshooting verb
*/
/client/verb/fix_tgui_panel()
set name = "Fix chat"
set category = "OOC"
var/action
log_tgui(src, "Started fixing.", context = "verb/fix_tgui_panel")
nuke_chat()
// Failed to fix, using tgalert as fallback
action = tgalert(src, "Did that work?", "", "Yes", "No, switch to old ui")
if (action == "No, 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, "Failed to fix.", context = "verb/fix_tgui_panel")
/client/proc/nuke_chat()
// Catch all solution (kick the whole thing in the pants)
winset(src, "output", "on-show=&is-disabled=0&is-visible=1")
winset(src, "browseroutput", "is-disabled=1;is-visible=0")
if(!tgui_panel || !istype(tgui_panel))
log_tgui(src, "tgui_panel datum is missing",
context = "verb/fix_tgui_panel")
tgui_panel = new(src)
tgui_panel.initialize(force = TRUE)
// Force show the panel to see if there are any errors
winset(src, "output", "is-disabled=1&is-visible=0")
winset(src, "browseroutput", "is-disabled=0;is-visible=1")
/client/verb/refresh_tgui()
set name = "Refresh TGUI"
set category = "OOC"
for(var/window_id in tgui_windows)
var/datum/tgui_window/window = tgui_windows[window_id]
window.reinitialize()

View File

@@ -0,0 +1,146 @@
/*!
* 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/ckey = client?.ckey
if (!ckey)
return
/* FIXME: Stuff we dont have > Can be reimplemented later on
var/list/all_known_alts = GLOB.known_alts.load_known_alts()
var/list/our_known_alts = list()
for (var/known_alt in all_known_alts)
if (known_alt[1] == ckey)
our_known_alts += known_alt[2]
else if (known_alt[2] == ckey)
our_known_alts += known_alt[1]
var/list/found
var/list/query_data = list()
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 (!isnull(GLOB.round_id))
query_data += list(list(
"telemetry_ckey" = row["ckey"],
"address" = row["address"],
"computer_id" = row["computer_id"],
))
if (row["ckey"] in our_known_alts)
continue
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)
send2tgs_adminless_only("Banned-user", msg)
log_admin_private(msg)
log_suspicious_login(msg, access_log_mirror = FALSE)
// Only log them all at the end, since it's not as important as reporting an evader
for (var/list/one_query as anything in query_data)
var/datum/db_query/query = SSdbcore.NewQuery({"
INSERT INTO [format_table_name("telemetry_connections")] (
ckey,
telemetry_ckey,
address,
computer_id,
first_round_id,
latest_round_id
) VALUES(
:ckey,
:telemetry_ckey,
INET_ATON(:address),
:computer_id,
:round_id,
:round_id
) ON DUPLICATE KEY UPDATE latest_round_id = :round_id
"}, list(
"ckey" = ckey,
"telemetry_ckey" = one_query["telemetry_ckey"],
"address" = one_query["address"],
"computer_id" = one_query["computer_id"],
"round_id" = GLOB.round_id,
))
query.Execute()
qdel(query)
*/
#undef TGUI_TELEMETRY_MAX_CONNECTIONS
#undef TGUI_TELEMETRY_RESPONSE_WINDOW

View File

@@ -0,0 +1,102 @@
/*!
* 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
var/oldchat = FALSE
/datum/tgui_panel/New(client/client, id)
src.client = client
window = new(client, id)
window.subscribe(src, PROC_REF(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)
set waitfor = FALSE
// Minimal sleep to defer initialization to after client constructor
sleep(1 TICKS)
initialized_at = world.time
// Perform a clean initialization
window.initialize(
strict_mode = TRUE,
assets = list(
get_asset_datum(/datum/asset/simple/tgui_panel),
))
window.send_asset(get_asset_datum(/datum/asset/simple/fontawesome))
window.send_asset(get_asset_datum(/datum/asset/simple/tgfont))
window.send_asset(get_asset_datum(/datum/asset/spritesheet/chat))
// Other setup
request_telemetry()
addtimer(CALLBACK(src, PROC_REF(on_initialize_timed_out)), 5 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, click <a href='?src=[REF(src)];reload_tguipanel=1'>HERE</a> to attempt to reload it.</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")

File diff suppressed because one or more lines are too long

View File

@@ -1250,46 +1250,28 @@ window "outputwindow"
size = 640x480
anchor1 = -1,-1
anchor2 = -1,-1
background-color = none
saved-params = "pos;size;is-minimized;is-maximized"
titlebar = false
statusbar = false
can-close = false
can-minimize = false
can-resize = false
is-pane = true
elem "chatloadlabel"
type = LABEL
pos = 0,0
size = 640x480
anchor1 = 0,0
anchor2 = 100,100
font-family = "sans-serif"
font-size = 24
font-style = "bold"
text-color = #ffffff
background-color = #222222
saved-params = ""
text = "Chat is loading.\nIf nothing happens after 20s,\nuse OOC > \"Reload VChat\"."
elem "htmloutput"
elem "browseroutput"
type = BROWSER
pos = 0,0
size = 640x480
anchor1 = 0,0
anchor2 = 100,100
is-visible = false
is-disabled = true
saved-params = ""
elem "oldoutput"
elem "output"
type = OUTPUT
pos = 0,0
size = 640x480
anchor1 = 0,0
anchor2 = 100,100
is-visible = false
is-default = true
saved-params = ""
style = ".system {color:#FF0000;}"
enable-http-images = true
max-lines = 0
window "prefs_markings_subwindow"
elem "prefs_markings_subwindow"

View File

@@ -1,3 +1,11 @@
/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
/// !!!!!!!!!!HEY LISTEN!!!!!!!!!!!!!!!!!!!!!!!!
/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// If you modify this file you ALSO need to modify tgui/packages/tgui-panel/styles/tgchat/chat-light.scss and chat-dark.scss
// BUT you have to use PX font sizes with are on a x8 scale of these font sizes
// Sample font-size: DM: 8 CSS: 64px
/client/script = {"<style>
body {font-family: Verdana, sans-serif;}
@@ -11,6 +19,10 @@ em {font-style: normal;font-weight: bold;}
.motd a, .motd a:link, .motd a:visited, .motd a:active, .motd a:hover
{color: #638500;}
.italics { font-style: italic;}
.bold { font-weight: bold;}
.prefix {font-weight: bold;}
.log_message {color: #386AFF; font-weight: bold;}

View File

@@ -649,7 +649,7 @@ rules:
## Enforce ES5 or ES6 class for React Components
react/prefer-es6-class: error
## Enforce that props are read-only
react/prefer-read-only-props: error
## react/prefer-read-only-props: error TODO: Find out why this suddenly errors?
## Enforce stateless React Components to be written as a pure function
react/prefer-stateless-function: error
## Prevent missing props validation in a React component definition

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "tgui-workspace",
"version": "4.4.1",
"version": "4.3.1",
"packageManager": "yarn@3.3.1",
"workspaces": [
"packages/*"

View File

@@ -19,3 +19,4 @@ export const toggleAcceptedType = createAction('chat/toggleAcceptedType');
export const removeChatPage = createAction('chat/removePage');
export const changeScrollTracking = createAction('chat/changeScrollTracking');
export const saveChatToDisk = createAction('chat/saveToDisk');
export const purgeChatMessageArchive = createAction('chat/purgeMessageArchive');

View File

@@ -23,15 +23,20 @@ export const MESSAGE_TYPE_INTERNAL = 'internal';
// Must match the set of defines in code/__DEFINES/chat.dm
export const MESSAGE_TYPE_SYSTEM = 'system';
export const MESSAGE_TYPE_LOCALCHAT = 'localchat';
export const MESSAGE_TYPE_PLOCALCHAT = 'plocalchat';
export const MESSAGE_TYPE_RADIO = 'radio';
export const MESSAGE_TYPE_NIF = 'nif';
export const MESSAGE_TYPE_INFO = 'info';
export const MESSAGE_TYPE_WARNING = 'warning';
export const MESSAGE_TYPE_DEADCHAT = 'deadchat';
export const MESSAGE_TYPE_OOC = 'ooc';
export const MESSAGE_TYPE_LOOC = 'looc';
export const MESSAGE_TYPE_ADMINPM = 'adminpm';
export const MESSAGE_TYPE_MENTORPM = 'mentorpm';
export const MESSAGE_TYPE_COMBAT = 'combat';
export const MESSAGE_TYPE_ADMINCHAT = 'adminchat';
export const MESSAGE_TYPE_MODCHAT = 'modchat';
export const MESSAGE_TYPE_RLOOC = 'rlooc';
export const MESSAGE_TYPE_PRAYER = 'prayer';
export const MESSAGE_TYPE_EVENTCHAT = 'eventchat';
export const MESSAGE_TYPE_ADMINLOG = 'adminlog';
@@ -53,14 +58,26 @@ export const MESSAGE_TYPES = [
type: MESSAGE_TYPE_LOCALCHAT,
name: 'Local',
description: 'In-character local messages (say, emote, etc)',
selector: '.say, .emote',
selector: '.say, .emote, .emotesubtle',
},
{
type: MESSAGE_TYPE_PLOCALCHAT,
name: 'Local (Pred/Prey)',
description: 'Messages from / to absorbed or dominated prey',
selector: '.psay, .pemote',
},
{
type: MESSAGE_TYPE_RADIO,
name: 'Radio',
description: 'All departments of radio messages',
selector:
'.alert, .minorannounce, .syndradio, .centcomradio, .aiprivradio, .comradio, .secradio, .gangradio, .engradio, .medradio, .sciradio, .suppradio, .servradio, .radio, .deptradio, .binarysay, .newscaster, .resonate, .abductor, .alien, .changeling',
'.alert, .minorannounce, .syndradio, .centradio, .airadio, .comradio, .secradio, .gangradio, .engradio, .medradio, .sciradio, .supradio, .srvradio, .expradio, .radio, .deptradio, .binarysay, .newscaster, .resonate, .abductor, .alien, .changeling',
},
{
type: MESSAGE_TYPE_NIF,
name: 'NIF',
description: 'Messages from the NIF itself and people inside',
selector: '.nif',
},
{
type: MESSAGE_TYPE_INFO,
@@ -88,12 +105,25 @@ export const MESSAGE_TYPES = [
description: 'The bluewall of global OOC messages',
selector: '.ooc, .adminooc, .adminobserverooc, .oocplain',
},
{
type: MESSAGE_TYPE_LOOC,
name: 'Local OOC',
description: 'Local OOC messages, always enabled',
selector: '.looc',
important: true,
},
{
type: MESSAGE_TYPE_ADMINPM,
name: 'Admin PMs',
description: 'Messages to/from admins (adminhelp)',
selector: '.pm, .adminhelp',
},
{
type: MESSAGE_TYPE_MENTORPM,
name: 'Mentor PMs',
description: 'Mentorchat and mentor pms',
selector: '.mentor_channel, .mentor',
},
{
type: MESSAGE_TYPE_COMBAT,
name: 'Combat Log',
@@ -120,6 +150,20 @@ export const MESSAGE_TYPES = [
selector: '.mod_channel',
admin: true,
},
{
type: MESSAGE_TYPE_EVENTCHAT,
name: 'Event Chat',
description: 'ESAY messages',
selector: '.event_channel',
admin: true,
},
{
type: MESSAGE_TYPE_RLOOC,
name: 'Remote LOOC',
description: 'Remote LOOC messages',
selector: '.rlooc',
admin: true,
},
{
type: MESSAGE_TYPE_PRAYER,
name: 'Prayers',

View File

@@ -8,7 +8,7 @@ import DOMPurify from 'dompurify';
import { storage } from 'common/storage';
import { loadSettings, updateSettings, addHighlightSetting, removeHighlightSetting, updateHighlightSetting } from '../settings/actions';
import { selectSettings } from '../settings/selectors';
import { addChatPage, changeChatPage, changeScrollTracking, loadChat, rebuildChat, removeChatPage, saveChatToDisk, toggleAcceptedType, updateMessageCount } from './actions';
import { addChatPage, changeChatPage, changeScrollTracking, loadChat, rebuildChat, removeChatPage, saveChatToDisk, purgeChatMessageArchive, toggleAcceptedType, updateMessageCount } from './actions';
import { MAX_PERSISTED_MESSAGES, MESSAGE_SAVE_INTERVAL } from './constants';
import { createMessage, serializeMessage } from './model';
import { chatRenderer } from './renderer';
@@ -28,12 +28,17 @@ const saveChatToStorage = async (store) => {
.map((message) => serializeMessage(message));
storage.set('chat-state', state);
storage.set('chat-messages', messages);
storage.set(
'chat-messages-archive',
chatRenderer.archivedMessages.map((message) => serializeMessage(message))
); // FIXME: Better chat history
};
const loadChatFromStorage = async (store) => {
const [state, messages] = await Promise.all([
const [state, messages, archivedMessages] = await Promise.all([
storage.get('chat-state'),
storage.get('chat-messages'),
storage.get('chat-messages-archive'), // FIXME: Better chat history
]);
// Discard incompatible versions
if (state && state.version <= 4) {
@@ -56,8 +61,12 @@ const loadChatFromStorage = async (store) => {
];
chatRenderer.processBatch(batch, {
prepend: true,
noarchive: true,
});
}
if (archivedMessages) {
chatRenderer.archivedMessages = archivedMessages;
}
store.dispatch(loadChat(state));
};
@@ -173,6 +182,10 @@ export const chatMiddleware = (store) => {
chatRenderer.saveToDisk();
return;
}
if (type === purgeChatMessageArchive.type) {
chatRenderer.purgeMessageArchive();
return;
}
return next(action);
};
};

View File

@@ -43,10 +43,10 @@ export const createMessage = (payload) => ({
...payload,
});
export const serializeMessage = (message) => ({
export const serializeMessage = (message, archive = false) => ({
type: message.type,
text: message.text,
html: message.html,
html: archive ? message.node.outerHTML : message.html,
times: message.times,
createdAt: message.createdAt,
});

View File

@@ -9,7 +9,7 @@ import { classes } from 'common/react';
import { createLogger } from 'tgui_ch/logging'; // CHOMPEdit - tgui_ch
import { COMBINE_MAX_MESSAGES, COMBINE_MAX_TIME_WINDOW, IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, IMAGE_RETRY_MESSAGE_AGE, MAX_PERSISTED_MESSAGES, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL, MESSAGE_TYPE_UNKNOWN } from './constants';
import { render } from 'inferno';
import { canPageAcceptType, createMessage, isSameMessage } from './model';
import { canPageAcceptType, createMessage, isSameMessage, serializeMessage } from './model';
import { highlightNode, linkifyNode } from './replaceInTextNode';
import { Tooltip } from '../../tgui_ch/components'; // CHOMPEdit - tgui_ch
@@ -111,6 +111,7 @@ class ChatRenderer {
this.rootNode = null;
this.queue = [];
this.messages = [];
this.archivedMessages = [];
this.visibleMessages = [];
this.page = null;
this.events = new EventEmitter();
@@ -322,7 +323,7 @@ class ChatRenderer {
}
processBatch(batch, options = {}) {
const { prepend, notifyListeners = true } = options;
const { prepend, notifyListeners = true, noArchive = false } = options;
const now = Date.now();
// Queue up messages until chat is ready
if (!this.isReady()) {
@@ -459,6 +460,9 @@ class ChatRenderer {
countByType[message.type] += 1;
// TODO: Detect duplicates
this.messages.push(message);
if (!noArchive) {
this.archivedMessages.push(serializeMessage(message, true)); // TODO: Actually having a better message archiving maybe for exports?
}
if (canPageAcceptType(this.page, message.type)) {
fragment.appendChild(node);
this.visibleMessages.push(message);
@@ -568,10 +572,12 @@ class ChatRenderer {
cssText += 'body, html { background-color: #141414 }\n';
// Compile chat log as HTML text
let messagesHtml = '';
for (let message of this.visibleMessages) {
if (message.node) {
messagesHtml += message.node.outerHTML + '\n';
}
// for (let message of this.visibleMessages) { // TODO: Actually having a better message archiving maybe for exports?
for (let message of this.archivedMessages) {
// if (message.node) {
// messagesHtml += message.node.outerHTML + '\n';
// }
messagesHtml += message.html + '\n';
}
// Create a page
// prettier-ignore
@@ -596,6 +602,10 @@ class ChatRenderer {
.replace('T', '-');
window.navigator.msSaveBlob(blob, `ss13-chatlog-${timestamp}.html`);
}
purgeMessageArchive() {
this.archivedMessages = [];
}
}
// Make chat renderer global so that we can continue using the same

View File

@@ -9,7 +9,7 @@ import { useLocalState } from 'tgui_ch/backend'; // CHOMPEdit - tgui_ch
import { useDispatch, useSelector } from 'common/redux';
import { Box, Button, ColorBox, Divider, Dropdown, Flex, Input, LabeledList, NumberInput, Section, Stack, Tabs, TextArea } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
import { ChatPageSettings } from '../chat';
import { rebuildChat, saveChatToDisk } from '../chat/actions';
import { rebuildChat, saveChatToDisk, purgeChatMessageArchive } from '../chat/actions';
import { THEMES } from '../themes';
import { changeSettingsTab, updateSettings, addHighlightSetting, removeHighlightSetting, updateHighlightSetting } from './actions';
import { SETTINGS_TABS, FONTS, MAX_HIGHLIGHT_SETTINGS } from './constants';
@@ -56,6 +56,11 @@ export const SettingsGeneral = (props, context) => {
);
const dispatch = useDispatch(context);
const [freeFont, setFreeFont] = useLocalState(context, 'freeFont', false);
const [purgeConfirm, setPurgeConfirm] = useLocalState(
context,
'purgeConfirm',
0
);
return (
<Section>
<LabeledList>
@@ -155,6 +160,29 @@ export const SettingsGeneral = (props, context) => {
<Button icon="save" onClick={() => dispatch(saveChatToDisk())}>
Save chat log
</Button>
{purgeConfirm > 0 ? (
<Button
icon="trash"
color="red"
onClick={() => {
dispatch(purgeChatMessageArchive());
setPurgeConfirm(2);
}}>
{purgeConfirm > 1 ? 'Purged!' : 'Are you sure?'}
</Button>
) : (
<Button
icon="trash"
color="red"
onClick={() => {
setPurgeConfirm(1);
setTimeout(() => {
setPurgeConfirm(false);
}, 5000);
}}>
Purge message archive
</Button>
)}
</Section>
);
};

View File

@@ -274,6 +274,31 @@ em {
font-weight: bold;
}
img.text_tag {
width: 32px;
height: 10px;
min-height: 10px;
}
/*BIG IMG.icon {width: 32px; height: 32px;}*/
img.icon {
vertical-align: middle;
max-height: 1em;
}
img.icon.bigicon {
max-height: 32px;
}
.looc {
color: #3a9696;
font-weight: bold;
}
.rlooc {
color: #3abb96;
font-weight: bold;
}
.adminobserverooc {
color: #0099cc;
font-weight: bold;
@@ -305,6 +330,19 @@ em {
.warningplain {
}
.nif {
}
.psay {
color: #e300e4;
font-style: italic;
}
.pemote {
color: #e300e4;
font-style: italic;
}
.deadsay {
color: #e2c1ff;
}
@@ -334,7 +372,7 @@ em {
}
.comradio {
color: #fcdf03;
color: #57b8f0;
}
.secradio {
@@ -342,21 +380,25 @@ em {
}
.medradio {
color: #57b8f0;
color: #57f09e;
}
.engradio {
color: #f37746;
color: #fcdf03;
}
.suppradio {
.supradio {
color: #b88646;
}
.servradio {
.srvradio {
color: #6ca729;
}
.expradio {
color: #8a8a8a;
}
.syndradio {
color: #8f4a4b;
}
@@ -365,11 +407,11 @@ em {
color: #ac2ea1;
}
.centcomradio {
.centradio {
color: #2681a5;
}
.aiprivradio {
.airadio {
color: #d65d95;
}
@@ -693,7 +735,7 @@ em {
}
.changeling {
color: #059223;
color: #b000b1;
font-style: italic;
}
@@ -1109,3 +1151,124 @@ $border-width-px: $border-width * 1px;
background-color: darken(map.get($alert-stripe-colors, $color-name), 5);
}
}
// Extra Languages
.tajaran {
color: #803b56;
}
.tajaran_signlang {
color: #941c1c;
}
.akhani {
color: #ac398c;
}
.skrell {
color: #00b0b3;
}
.skrellfar {
color: #70fcff;
}
.soghun {
color: #50ba6c;
}
.solcom {
color: #6da6f0;
}
.sergal {
color: #0077ff;
}
.birdsongc {
color: #cc9900;
}
.vulpkanin {
color: #b97a57;
}
.tavan {
color: #f54298;
font-family: Arial;
}
.echosong {
color: #826d8c;
}
.enochian {
color: #848a33;
letter-spacing: -1pt;
word-spacing: 4pt;
font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
}
.daemon {
color: #5e339e;
letter-spacing: -1pt;
word-spacing: 0pt;
font-family: 'Courier New', Courier, monospace;
}
.drudakar {
color: #bb2463;
word-spacing: 0pt;
font-family: 'High Tower Text', monospace;
}
.bug {
color: #9e9e39;
}
.vox {
color: #aa00aa;
}
.promethean {
color: #a5a5a5;
font-family: 'Comic Sans MS', 'Comic Sans', cursive;
}
.zaddat {
color: #941c1c;
}
.rough {
font-family: 'Trebuchet MS', cursive, sans-serif;
}
.say_quote {
font-family: Georgia, Verdana, sans-serif;
}
.say_quote_italics {
font-style: italic;
font-family: Georgia, Verdana, sans-serif;
}
.terminus {
font-family: 'Times New Roman', Times, serif, sans-serif;
}
.spacer {
color: #9c660b;
}
.teppi {
color: #816540;
word-spacing: 4pt;
font-family: 'Segoe Script Bold', 'Segoe Script', sans-serif, Verdana;
}
.shadekin {
color: #be3cc5;
font-size: 150%;
font-weight: bold;
font-family: 'Gabriola', cursive, sans-serif;
}

View File

@@ -292,6 +292,31 @@ em {
font-weight: bold;
}
img.text_tag {
width: 32px;
height: 10px;
min-height: 10px;
}
/*BIG IMG.icon {width: 32px; height: 32px;}*/
img.icon {
vertical-align: middle;
max-height: 1em;
}
img.icon.bigicon {
max-height: 32px;
}
.looc {
color: #3a9696;
font-weight: bold;
}
.rlooc {
color: #3abb96;
font-weight: bold;
}
.adminobserverooc {
color: #0099cc;
font-weight: bold;
@@ -323,6 +348,19 @@ em {
.warningplain {
}
.nif {
}
.psay {
color: #800080;
font-style: italic;
}
.pemote {
color: #800080;
font-style: italic;
}
.deadsay {
color: #5c00e6;
}
@@ -351,7 +389,7 @@ em {
}
.comradio {
color: #948f02;
color: #337296;
}
.secradio {
@@ -359,21 +397,25 @@ em {
}
.medradio {
color: #337296;
color: #339661;
}
.engradio {
color: #fb5613;
color: #948f02;
}
.suppradio {
.supradio {
color: #a8732b;
}
.servradio {
.srvradio {
color: #6eaa2c;
}
.expradio {
color: #555555;
}
.syndradio {
color: #6d3f40;
}
@@ -382,11 +424,11 @@ em {
color: #ac2ea1;
}
.centcomradio {
.centradio {
color: #686868;
}
.aiprivradio {
.airadio {
color: #ff00ff;
}
@@ -1142,3 +1184,124 @@ $border-width-px: $border-width * 1px;
);
}
}
// Extra Languages
.tajaran {
color: #803b56;
}
.tajaran_signlang {
color: #941c1c;
}
.akhani {
color: #ac398c;
}
.skrell {
color: #00b0b3;
}
.skrellfar {
color: #70fcff;
}
.soghun {
color: #50ba6c;
}
.solcom {
color: #3333ce;
}
.sergal {
color: #0077ff;
}
.birdsongc {
color: #cc9900;
}
.vulpkanin {
color: #b97a57;
}
.tavan {
color: #f54298;
font-family: Arial;
}
.echosong {
color: #826d8c;
}
.enochian {
color: #848a33;
letter-spacing: -1pt;
word-spacing: 4pt;
font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
}
.daemon {
color: #5e339e;
letter-spacing: -1pt;
word-spacing: 0pt;
font-family: 'Courier New', Courier, monospace;
}
.drudakar {
color: #bb2463;
word-spacing: 0pt;
font-family: 'High Tower Text', monospace;
}
.bug {
color: #9e9e39;
}
.vox {
color: #aa00aa;
}
.promethean {
color: #5a5a5a;
font-family: 'Comic Sans MS', 'Comic Sans', cursive;
}
.zaddat {
color: #941c1c;
}
.rough {
font-family: 'Trebuchet MS', cursive, sans-serif;
}
.say_quote {
font-family: Georgia, Verdana, sans-serif;
}
.say_quote_italics {
font-style: italic;
font-family: Georgia, Verdana, sans-serif;
}
.terminus {
font-family: 'Times New Roman', Times, serif, sans-serif;
}
.spacer {
color: #9c660b;
}
.teppi {
color: #816540;
word-spacing: 4pt;
font-family: 'Segoe Script Bold', 'Segoe Script', sans-serif, Verdana;
}
.shadekin {
color: #be3cc5;
font-size: 150%;
font-weight: bold;
font-family: 'Gabriola', cursive, sans-serif;
}

View File

@@ -81,6 +81,24 @@ export const setClientTheme = (name) => {
'tooltip.text-color': '#000000',
'input.background-color': '#FFFFFF',
'input.text-color': '#000000',
// VOREStation
'rpane.background-color': 'none',
'rpane.text-color': '#000000',
'rpanewindow.background-color': 'none',
'rpanewindow.text-color': '#000000',
'mainvsplit.background-color': 'none',
'info.tab-background-color': 'none',
'info.tab-text-color': '#000000',
'discord.background-color': 'none',
'discord.text-color': '#000000',
'mapb.background-color': 'none',
'mapb.text-color': '#000000',
'rulesb.background-color': 'none',
'rulesb.text-color': '#000000',
'wikib.background-color': 'none',
'wikib.text-color': '#000000',
'forumb.background-color': 'none',
'forumb.text-color': '#000000',
});
}
if (name === 'dark') {
@@ -133,6 +151,24 @@ export const setClientTheme = (name) => {
'tooltip.text-color': COLOR_DARK_TEXT,
'input.background-color': COLOR_DARK_BG_DARKER,
'input.text-color': COLOR_DARK_TEXT,
// VOREStation
'rpane.background-color': COLOR_DARK_BG_DARKER,
'rpane.text-color': COLOR_DARK_TEXT,
'rpanewindow.background-color': COLOR_DARK_BG_DARKER,
'rpanewindow.text-color': COLOR_DARK_TEXT,
'mainvsplit.background-color': COLOR_DARK_BG,
'info.tab-background-color': COLOR_DARK_BG,
'info.tab-text-color': COLOR_DARK_TEXT,
'mapb.background-color': '#494949',
'mapb.text-color': COLOR_DARK_TEXT,
'discord.background-color': '#494949',
'discord.text-color': COLOR_DARK_TEXT,
'rulesb.background-color': '#494949',
'rulesb.text-color': COLOR_DARK_TEXT,
'wikib.background-color': '#494949',
'wikib.text-color': COLOR_DARK_TEXT,
'forumb.background-color': '#494949',
'forumb.text-color': COLOR_DARK_TEXT,
});
}
};

View File

@@ -154,6 +154,14 @@ export const backendMiddleware = (store) => {
globalEvents.emit('byond/mouseup');
}
if (type === 'byond/ctrldown') {
globalEvents.emit('byond/ctrldown');
}
if (type === 'byond/ctrlup') {
globalEvents.emit('byond/ctrlup');
}
if (type === 'backend/suspendStart' && !suspendInterval) {
logger.log(`suspending (${Byond.windowId})`);
// Keep sending suspend messages until it succeeds.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,6 @@
#include "code\global_init.dm"
#include "code\global_vr.dm"
#include "code\names.dm"
#include "code\stylesheet.dm"
#include "code\world.dm"
#include "code\__datastructures\globals.dm"
#include "code\__defines\_compile_options.dm"
@@ -307,6 +306,7 @@
#include "code\controllers\subsystems\overmap_renamer_vr.dm"
#include "code\controllers\subsystems\persist_vr.dm"
#include "code\controllers\subsystems\persistence.dm"
#include "code\controllers\subsystems\ping.dm"
#include "code\controllers\subsystems\planets.dm"
#include "code\controllers\subsystems\plants.dm"
#include "code\controllers\subsystems\player_tips.dm"
@@ -349,6 +349,7 @@
#include "code\datums\callback.dm"
#include "code\datums\category.dm"
#include "code\datums\chat_message.dm"
#include "code\datums\chat_payload.dm"
#include "code\datums\datacore.dm"
#include "code\datums\datum.dm"
#include "code\datums\datumvars.dm"
@@ -1957,6 +1958,7 @@
#include "code\modules\asset_cache\asset_cache_item.dm"
#include "code\modules\asset_cache\asset_list.dm"
#include "code\modules\asset_cache\asset_list_items.dm"
#include "code\modules\asset_cache\assets\chat.dm"
#include "code\modules\asset_cache\assets\fontawesome.dm"
#include "code\modules\asset_cache\assets\jquery.dm"
#include "code\modules\asset_cache\assets\tgfont.dm"
@@ -4293,6 +4295,9 @@
#include "code\modules\telesci\telepad.dm"
#include "code\modules\telesci\telesci_computer.dm"
#include "code\modules\tension\tension.dm"
#include "code\modules\tgchat\_legacy.dm"
#include "code\modules\tgchat\message.dm"
#include "code\modules\tgchat\to_chat.dm"
#include "code\modules\tgs\includes.dm"
#include "code\modules\tgui\external.dm"
#include "code\modules\tgui\modal.dm"
@@ -4349,6 +4354,10 @@
#include "code\modules\tgui_input\list.dm"
#include "code\modules\tgui_input\number.dm"
#include "code\modules\tgui_input\text.dm"
#include "code\modules\tgui_panel\audio.dm"
#include "code\modules\tgui_panel\external.dm"
#include "code\modules\tgui_panel\telemetry.dm"
#include "code\modules\tgui_panel\tgui_panel.dm"
#include "code\modules\tooltip\tooltip.dm"
#include "code\modules\turbolift\_turbolift.dm"
#include "code\modules\turbolift\turbolift.dm"
@@ -4360,8 +4369,6 @@
#include "code\modules\turbolift\turbolift_floor.dm"
#include "code\modules\turbolift\turbolift_map.dm"
#include "code\modules\turbolift\turbolift_turfs.dm"
#include "code\modules\vchat\vchat_client.dm"
#include "code\modules\vchat\vchat_db.dm"
#include "code\modules\vehicles\bike.dm"
#include "code\modules\vehicles\boat.dm"
#include "code\modules\vehicles\cargo_train.dm"
@@ -4562,6 +4569,7 @@
#include "code\ZAS\Zone.dm"
#include "interface\fonts.dm"
#include "interface\interface.dm"
#include "interface\stylesheet.dm"
#include "interface\skin.dmf"
#include "maps\atoll\atoll_decals.dm"
#include "maps\atoll\atoll_objs.dm"