diff --git a/.github/workflows/generate_client_storage.yml b/.github/workflows/generate_client_storage.yml
new file mode 100644
index 00000000000..fc95e895096
--- /dev/null
+++ b/.github/workflows/generate_client_storage.yml
@@ -0,0 +1,33 @@
+name: "Generate Client Storage"
+on:
+ push:
+ branches:
+ - master
+ paths:
+ - tgui/public/*
+
+jobs:
+ dispatch_repo:
+ if: ( !contains(github.event.head_commit.message, '[ci skip]') )
+ name: Repository Dispatch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate App Token
+ id: app-token-generation
+ uses: actions/create-github-app-token@v2
+ if: env.APP_PRIVATE_KEY != '' && env.APP_ID != ''
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+ owner: tgstation
+ env:
+ APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
+ APP_ID: ${{ secrets.APP_ID }}
+
+ - name: Send Repository Dispatch
+ if: success()
+ uses: peter-evans/repository-dispatch@v4
+ with:
+ token: ${{ steps.app-token-generation.outputs.token }}
+ repository: tgstation/byond-client-storage
+ event-type: on_master_push
diff --git a/cev_eris.dme b/cev_eris.dme
index b79f0b9df93..f7f592d5f28 100644
--- a/cev_eris.dme
+++ b/cev_eris.dme
@@ -365,6 +365,7 @@
#include "code\datums\datum.dm"
#include "code\datums\datum_click_handlers.dm"
#include "code\datums\datum_hud.dm"
+#include "code\datums\datumvars.dm"
#include "code\datums\footsteps.dm"
#include "code\datums\gps_data.dm"
#include "code\datums\hierarchy.dm"
diff --git a/code/__DEFINES/tgui.dm b/code/__DEFINES/tgui.dm
index 60b7bea8547..8cdf731284d 100644
--- a/code/__DEFINES/tgui.dm
+++ b/code/__DEFINES/tgui.dm
@@ -24,6 +24,17 @@
/// Window is free and ready to receive data
#define TGUI_WINDOW_READY 2
+/// Though not the maximum renderable ByondUis within tgui, this is the maximum that the server will manage per-UI
+#define TGUI_MANAGED_BYONDUI_LIMIT 10
+
+
+// These are defines instead of being inline, as they're being sent over
+// from tgui-core, so can't be easily played with
+#define TGUI_MANAGED_BYONDUI_TYPE_RENDER "renderByondUi"
+#define TGUI_MANAGED_BYONDUI_TYPE_UNMOUNT "unmountByondUi"
+
+#define TGUI_MANAGED_BYONDUI_PAYLOAD_ID "renderByondUi"
+
/// Get a window id based on the provided pool index
#define TGUI_WINDOW_ID(index) "tgui-window-[index]"
/// Get a pool index of the provided window id
@@ -36,3 +47,4 @@
#define TGUI_CREATE_MESSAGE(type, payload) ( \
"%7b%22type%22%3a%22[type]%22%2c%22payload%22%3a[url_encode(json_encode(payload))]%7d" \
)
+
diff --git a/code/_compile_options.dm b/code/_compile_options.dm
index eac66e1dab0..24437ce4db9 100644
--- a/code/_compile_options.dm
+++ b/code/_compile_options.dm
@@ -21,6 +21,10 @@
///Used for doing dry runs of the reference finder, to test for feature completeness
//#define REFERENCE_TRACKING_DEBUG
+/// If this is uncommented, additional logging (such as more in-depth tgui logging) will be enabled.alist
+/// These logs prolly don't matter during production.
+// #define EXTENDED_DEBUG_LOGGING
+
///Run a lookup on things hard deleting by default.
//#define GC_FAILURE_HARD_LOOKUP
#ifdef GC_FAILURE_HARD_LOOKUP
diff --git a/code/_global_vars/misc.dm b/code/_global_vars/misc.dm
index 4efd8ebf8a4..d0898c1be02 100644
--- a/code/_global_vars/misc.dm
+++ b/code/_global_vars/misc.dm
@@ -33,4 +33,4 @@ GLOBAL_LIST_INIT(custom_kits, list(
GLOBAL_DATUM(changelog_tgui, /datum/changelog)
// FIXME FIXME FIXME TURN THIS BACK OFF WHEN YOU'RE DONE DIPSHIIIIIIIITTT!
-GLOBAL_VAR_INIT(Debug2, TRUE)
+GLOBAL_VAR_INIT(Debug2, FALSE)
diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm
index a9fe52c17b5..dba9f38b307 100644
--- a/code/controllers/configuration/entries/general.dm
+++ b/code/controllers/configuration/entries/general.dm
@@ -489,6 +489,15 @@
default = -1
min_val = 0
+/datum/config_entry/number/hard_deletes_overrun_threshold
+ integer = FALSE
+ min_val = 0
+ default = 0.5
+
+/datum/config_entry/number/hard_deletes_overrun_limit
+ default = 0
+ min_val = 0
+
/*****************/
/* COOLDOWNS */
/*****************/
diff --git a/code/controllers/configuration/entries/resources.dm b/code/controllers/configuration/entries/resources.dm
index 7aa5c1cac4f..b36166051e8 100644
--- a/code/controllers/configuration/entries/resources.dm
+++ b/code/controllers/configuration/entries/resources.dm
@@ -50,3 +50,7 @@
if (str_var && str_var[length(str_var)] != "/")
str_var += "/"
return ..(str_var)
+
+/datum/config_entry/string/storage_cdn_iframe
+ protection = CONFIG_ENTRY_LOCKED
+ default = "https://tgstation.github.io/byond-client-storage/iframe.html"
diff --git a/code/controllers/subsystems/garbage.dm b/code/controllers/subsystems/garbage.dm
index 68404a1b686..1e726cbafd6 100644
--- a/code/controllers/subsystems/garbage.dm
+++ b/code/controllers/subsystems/garbage.dm
@@ -63,7 +63,6 @@ SUBSYSTEM_DEF(garbage)
#endif
var/list/failed_hard_deletes = list()
-
/datum/controller/subsystem/garbage/PreInit()
InitQueues()
@@ -89,32 +88,38 @@ SUBSYSTEM_DEF(garbage)
/datum/controller/subsystem/garbage/Shutdown()
//Adds the del() log to the qdel log file
- var/list/dellog = list()
+ var/list/del_log = list()
//sort by how long it's wasted hard deleting
sortTim(items, cmp=/proc/cmp_qdel_item_time, associative = TRUE)
for(var/path in items)
var/datum/qdel_item/I = items[path]
- dellog += "Path: [path]"
+ var/list/entry = list()
+ del_log[path] = entry
+
if (I.qdel_flags & QDEL_ITEM_SUSPENDED_FOR_LAG)
- dellog += "\tSUSPENDED FOR LAG"
+ entry["SUSPENDED FOR LAG"] = TRUE
if (I.failures)
- dellog += "\tFailures: [I.failures]"
- dellog += "\tqdel() Count: [I.qdels]"
- dellog += "\tDestroy() Cost: [I.destroy_time]ms"
+ entry["Failures"] = I.failures
+ entry["qdel() Count"] = I.qdels
+ entry["Destroy() Cost (ms)"] = I.destroy_time
+
if (I.hard_deletes)
- dellog += "\tTotal Hard Deletes: [I.hard_deletes]"
- dellog += "\tTime Spent Hard Deleting: [I.hard_delete_time]ms"
- dellog += "\tHighest Time Spent Hard Deleting: [I.hard_delete_max]ms"
+ entry["Total Hard Deletes"] = I.hard_deletes
+ entry["Time Spend Hard Deleting (ms)"] = I.hard_delete_time
+ entry["Highest Time Spend Hard Deleting (ms)"] = I.hard_delete_max
if (I.hard_deletes_over_threshold)
- dellog += "\tHard Deletes Over Threshold: [I.hard_deletes_over_threshold]"
+ entry["Hard Deletes Over Threshold"] = I.hard_deletes_over_threshold
if (I.slept_destroy)
- dellog += "\tSleeps: [I.slept_destroy]"
+ entry["Total Sleeps"] = I.slept_destroy
if (I.no_respect_force)
- dellog += "\tIgnored force: [I.no_respect_force] times"
+ entry["Total Ignored Force"] = I.no_respect_force
if (I.no_hint)
- dellog += "\tNo hint: [I.no_hint] times"
- log_qdel(dellog.Join("\n"))
+ entry["Total No Hint"] = I.no_hint
+ if(LAZYLEN(I.extra_details))
+ entry["Deleted Metadata"] = I.extra_details
+
+ log_qdel("", del_log)
/datum/controller/subsystem/garbage/fire()
//the fact that this resets its processing each fire (rather then resume where it left off) is intentional.
@@ -162,31 +167,34 @@ SUBSYSTEM_DEF(garbage)
lastlevel = level
- //We do this rather then for(var/refID in queue) because that sort of for loop copies the whole list.
+// 1 from the hard reference in the queue, and 1 from the variable used before this
+#define REFS_WE_EXPECT 2
+
+ //We do this rather then for(var/list/ref_info in queue) because that sort of for loop copies the whole list.
//Normally this isn't expensive, but the gc queue can grow to 40k items, and that gets costly/causes overrun.
for (var/i in 1 to length(queue))
var/list/L = queue[i]
- if (length(L) < 2)
+ if (length(L) < GC_QUEUE_ITEM_INDEX_COUNT)
count++
if (MC_TICK_CHECK)
return
continue
- var/GCd_at_time = L[GC_QUEUE_ITEM_QUEUE_TIME]
- if(GCd_at_time > cut_off_time)
+ var/queued_at_time = L[GC_QUEUE_ITEM_QUEUE_TIME]
+ if(queued_at_time > cut_off_time)
break // Everything else is newer, skip them
count++
- var/refID = L[GC_QUEUE_ITEM_REF]
- var/datum/D
- D = locate(refID)
- if (!D || D.gc_destroyed != GCd_at_time) // So if something else coincidently gets the same ref, it's not deleted by mistake
+ var/datum/D = L[GC_QUEUE_ITEM_REF]
+
+ // If that's all we've got, send er off
+ if (refcount(D) == REFS_WE_EXPECT)
++gcedlasttick
++totalgcs
pass_counts[level]++
#ifdef REFERENCE_TRACKING
- reference_find_on_fail -= refID //It's deleted we don't care anymore.
- #endif
+ reference_find_on_fail -= text_ref(D) //It's deleted we don't care anymore.
+ #endif //ifdef REFERENCE_TRACKING
if (MC_TICK_CHECK)
return
continue
@@ -201,36 +209,50 @@ SUBSYSTEM_DEF(garbage)
switch (level)
if (GC_QUEUE_CHECK)
#ifdef REFERENCE_TRACKING
- if(reference_find_on_fail[refID])
- INVOKE_ASYNC(D, TYPE_PROC_REF(/datum, find_references))
- ref_searching = TRUE
- #ifdef GC_FAILURE_HARD_LOOKUP
- else
- INVOKE_ASYNC(D, TYPE_PROC_REF(/datum, find_references))
- ref_searching = TRUE
- #endif
- reference_find_on_fail -= refID
- #endif
+ #ifdef FAST_REFERENCE_TRACKING
+ var/skip = GLOB.reftracker_skip_typecache[D.type]
+ #else
+ var/skip = FALSE
+ #endif //ifdef FAST_REFERENCE_TRACKING
+ if(!skip)
+ // Decides how many refs to look for (potentially)
+ // Based off the remaining and the ones we can account for
+ var/remaining_refs = refcount(D) - REFS_WE_EXPECT
+ if(reference_find_on_fail[text_ref(D)])
+ INVOKE_ASYNC(D, TYPE_PROC_REF(/datum,find_references), remaining_refs)
+ ref_searching = TRUE
+ #ifdef GC_FAILURE_HARD_LOOKUP
+ else
+ INVOKE_ASYNC(D, TYPE_PROC_REF(/datum,find_references), remaining_refs)
+ ref_searching = TRUE
+ #endif //ifdef GC_FAILURE_HARD_LOOKUP
+ reference_find_on_fail -= text_ref(D)
+ #endif //ifdef REFERENCE_TRACKING
var/type = D.type
var/datum/qdel_item/I = items[type]
+ var/detail = D.dump_harddel_info()
var/message = "## TESTING: GC: -- [text_ref(D)] | [type] was unable to be GC'd --"
message = "[message] (ref count of [refcount(D)])"
+ if(detail)
+ message = "[message] | [detail]"
+ LAZYADD(I.extra_details, detail)
log_world(message)
+
#ifdef TESTING
for(var/c in GLOB.admins) //Using testing() here would fill the logs with ADMIN_VV garbage
var/client/admin = c
if(!check_rights_for(admin, R_ADMIN))
continue
to_chat(admin, "## TESTING: GC: -- [ADMIN_VV(D)] | [type] was unable to be GC'd --")
- #endif
+ #endif //ifdef TESTING
I.failures++
if (I.qdel_flags & QDEL_ITEM_SUSPENDED_FOR_LAG)
#ifdef REFERENCE_TRACKING
if(ref_searching)
return //ref searching intentionally cancels all further fires while running so things that hold references don't end up getting deleted, so we want to return here instead of continue
- #endif
+ #endif //ifdef REFERENCE_TRACKING
continue
if (GC_QUEUE_HARDDELETE)
if(!HardDelete(D))
@@ -244,7 +266,7 @@ SUBSYSTEM_DEF(garbage)
#ifdef REFERENCE_TRACKING
if(ref_searching)
return
- #endif
+ #endif //ifdef REFERENCE_TRACKING
if (MC_TICK_CHECK)
return
@@ -252,19 +274,21 @@ SUBSYSTEM_DEF(garbage)
queue.Cut(1,count+1)
count = 0
+#undef REFS_WE_EXPECT
+
/datum/controller/subsystem/garbage/proc/Queue(datum/D, level = GC_QUEUE_FILTER)
if (isnull(D))
return
if (level > GC_QUEUE_COUNT)
HardDelete(D)
return
- var/gctime = world.time
- var/refid = "\ref[D]"
+ var/queue_time = world.time
- D.gc_destroyed = gctime
- var/list/queue = queues[level]
+ if (D.gc_destroyed <= 0)
+ D.gc_destroyed = queue_time
- queue[++queue.len] = list(gctime, refid) // not += for byond reasons
+ var/list/queue = queues[level]
+ queue[++queue.len] = list(queue_time, D, D.gc_destroyed) // not += for byond reasons
//this is mainly to separate things profile wise.
/datum/controller/subsystem/garbage/proc/HardDelete(datum/D, override = FALSE)
@@ -278,17 +302,20 @@ SUBSYSTEM_DEF(garbage)
++delslasttick
++totaldels
var/type = D.type
- var/refID = "\ref[D]"
+ var/refID = text_ref(D)
+ var/datum/qdel_item/type_info = items[type]
+ var/detail = D.dump_harddel_info()
+ if(detail)
+ LAZYADD(type_info.extra_details, detail)
var/tick_usage = TICK_USAGE
del(D)
tick_usage = TICK_USAGE_TO_MS(tick_usage)
- var/datum/qdel_item/I = items[type]
- I.hard_deletes++
- I.hard_delete_time += tick_usage
- if (tick_usage > I.hard_delete_max)
- I.hard_delete_max = tick_usage
+ type_info.hard_deletes++
+ type_info.hard_delete_time += tick_usage
+ if (tick_usage > type_info.hard_delete_max)
+ type_info.hard_delete_max = tick_usage
if (tick_usage > highest_del_ms)
highest_del_ms = tick_usage
highest_del_type_string = "[type]"
@@ -297,16 +324,16 @@ SUBSYSTEM_DEF(garbage)
if (time > 0.1 SECONDS)
postpone(time)
- var/threshold = 0.5 //CONFIG_GET(number/hard_deletes_overrun_threshold)
+ var/threshold = CONFIG_GET(number/hard_deletes_overrun_threshold)
if (threshold && (time > threshold SECONDS))
- if (!(I.qdel_flags & QDEL_ITEM_ADMINS_WARNED))
+ if (!(type_info.qdel_flags & QDEL_ITEM_ADMINS_WARNED))
log_game("Error: [type]([refID]) took longer than [threshold] seconds to delete (took [round(time/10, 0.1)] seconds to delete)")
message_admins("Error: [type]([refID]) took longer than [threshold] seconds to delete (took [round(time/10, 0.1)] seconds to delete).")
- I.qdel_flags |= QDEL_ITEM_ADMINS_WARNED
- I.hard_deletes_over_threshold++
- var/overrun_limit = 0 // CONFIG_GET(number/hard_deletes_overrun_limit)
- if (overrun_limit && I.hard_deletes_over_threshold >= overrun_limit)
- I.qdel_flags |= QDEL_ITEM_SUSPENDED_FOR_LAG
+ type_info.qdel_flags |= QDEL_ITEM_ADMINS_WARNED
+ type_info.hard_deletes_over_threshold++
+ var/overrun_limit = CONFIG_GET(number/hard_deletes_overrun_limit)
+ if (overrun_limit && type_info.hard_deletes_over_threshold >= overrun_limit)
+ type_info.qdel_flags |= QDEL_ITEM_SUSPENDED_FOR_LAG
/datum/controller/subsystem/garbage/Recover()
InitQueues() //We first need to create the queues before recovering data
@@ -328,85 +355,109 @@ SUBSYSTEM_DEF(garbage)
var/no_hint = 0 //!Number of times it's not even bother to give a qdel hint
var/slept_destroy = 0 //!Number of times it's slept in its destroy
var/qdel_flags = 0 //!Flags related to this type's trip thru qdel.
+ var/list/extra_details //!Lazylist of string metadata about the deleted objects
/datum/qdel_item/New(mytype)
name = "[mytype]"
+/proc/non_datum_qdel(to_delete)
+ var/found_type = "unable to determine type"
+ var/delable = FALSE
+
+ if(islist(to_delete))
+ found_type = "list"
+ delable = TRUE
+
+ if(isnum(to_delete))
+ found_type = "number"
+
+ if(ispath(to_delete))
+ found_type = "typepath"
+
+ if(delable)
+ del(to_delete)
+
+ CRASH("Bad qdel ([found_type])")
/// Should be treated as a replacement for the 'del' keyword.
///
/// Datums passed to this will be given a chance to clean up references to allow the GC to collect them.
-/proc/qdel(datum/to_delete, force=FALSE, ...)
+/proc/qdel(datum/to_delete, force = FALSE)
if(!istype(to_delete))
if(isnull(to_delete))
return
- else if(islist(to_delete))
- stack_trace("Lists should not be directly passed to qdel! You likely want either list.Cut(), QDEL_LIST(list), QDEL_LIST_ASSOC(list), or QDEL_LIST_ASSOC_VAL(list)")
- else if(to_delete != world)
- stack_trace("Tried to qdel possibly invalid value: [to_delete]")
- del(to_delete)
+// #ifndef DISABLE_DREAMLUAU
+// DREAMLUAU_CLEAR_REF_USERDATA(to_delete)
+// #endif
+ non_datum_qdel(to_delete)
return
- var/datum/qdel_item/I = SSgarbage.items[to_delete.type]
- if (!I)
- I = SSgarbage.items[to_delete.type] = new /datum/qdel_item(to_delete.type)
- I.qdels++
+ var/datum/qdel_item/trash = SSgarbage.items[to_delete.type]
+ if (isnull(trash))
+ trash = SSgarbage.items[to_delete.type] = new /datum/qdel_item(to_delete.type)
+ trash.qdels++
- if(isnull(to_delete.gc_destroyed))
- if (SEND_SIGNAL(to_delete, COMSIG_PREQDELETED, force)) // Give the components a chance to prevent their parent from being deleted
- return
- to_delete.gc_destroyed = GC_CURRENTLY_BEING_QDELETED
- var/start_time = world.time
- var/start_tick = world.tick_usage
- SEND_SIGNAL(to_delete, COMSIG_QDELETING, force) // Let the (remaining) components know about the result of Destroy
- var/hint = to_delete.Destroy(arglist(args.Copy(2))) // Let our friend know they're about to get fucked up.
- if(world.time != start_time)
- I.slept_destroy++
- else
- I.destroy_time += TICK_USAGE_TO_MS(start_tick)
- if(!to_delete)
+ if(!isnull(to_delete.gc_destroyed))
+ if(to_delete.gc_destroyed == GC_CURRENTLY_BEING_QDELETED)
+ CRASH("[to_delete.type] destroy proc was called multiple times, likely due to a qdel loop in the Destroy logic")
+ return
+
+ if (SEND_SIGNAL(to_delete, COMSIG_PREQDELETED, force)) // Give the components a chance to prevent their parent from being deleted
+ return
+
+ to_delete.gc_destroyed = GC_CURRENTLY_BEING_QDELETED
+ var/start_time = world.time
+ var/start_tick = world.tick_usage
+ SEND_SIGNAL(to_delete, COMSIG_QDELETING, force) // Let the (remaining) components know about the result of Destroy
+ var/hint = to_delete.Destroy(force) // Let our friend know they're about to get fucked up.
+
+ if(world.time != start_time)
+ trash.slept_destroy++
+ else
+ trash.destroy_time += TICK_USAGE_TO_MS(start_tick)
+
+ if(isnull(to_delete))
+ return
+
+ switch(hint)
+ if (QDEL_HINT_QUEUE) //qdel should queue the object for deletion.
+ SSgarbage.Queue(to_delete)
+ if (QDEL_HINT_IWILLGC)
+ to_delete.gc_destroyed = world.time
return
- switch(hint)
- if (QDEL_HINT_QUEUE) //qdel should queue the object for deletion.
- SSgarbage.Queue(to_delete)
- if (QDEL_HINT_IWILLGC)
- to_delete.gc_destroyed = world.time
+ if (QDEL_HINT_LETMELIVE) //qdel should let the object live after calling destory.
+ if(!force)
+ to_delete.gc_destroyed = null //clear the gc variable (important!)
return
- if (QDEL_HINT_LETMELIVE) //qdel should let the object live after calling destory.
- if(!force)
- to_delete.gc_destroyed = null //clear the gc variable (important!)
- return
- // Returning LETMELIVE after being told to force destroy
- // indicates the objects Destroy() does not respect force
- #ifdef TESTING
- if(!I.no_respect_force)
- testing("WARNING: [to_delete.type] has been force deleted, but is \
- returning an immortal QDEL_HINT, indicating it does \
- not respect the force flag for qdel(). It has been \
- placed in the queue, further instances of this type \
- will also be queued.")
- #endif
- I.no_respect_force++
-
- SSgarbage.Queue(to_delete)
- if (QDEL_HINT_HARDDEL) //qdel should assume this object won't gc, and queue a hard delete
- SSgarbage.Queue(to_delete, GC_QUEUE_HARDDELETE)
- if (QDEL_HINT_HARDDEL_NOW) //qdel should assume this object won't gc, and hard del it post haste.
- SSgarbage.HardDelete(to_delete)
- #ifdef REFERENCE_TRACKING
- if (QDEL_HINT_FINDREFERENCE) //qdel will, if REFERENCE_TRACKING is enabled, display all references to this object, then queue the object for deletion.
- SSgarbage.Queue(to_delete)
- to_delete.find_references() //This breaks ci. Consider it insurance against somehow pring reftracking on accident
- if (QDEL_HINT_IFFAIL_FINDREFERENCE) //qdel will, if REFERENCE_TRACKING is enabled and the object fails to collect, display all references to this object.
- SSgarbage.Queue(to_delete)
- SSgarbage.reference_find_on_fail["\ref[to_delete]"] = TRUE
+ // Returning LETMELIVE after being told to force destroy
+ // indicates the objects Destroy() does not respect force
+ #ifdef TESTING
+ if(!trash.no_respect_force)
+ testing("WARNING: [to_delete.type] has been force deleted, but is \
+ returning an immortal QDEL_HINT, indicating it does \
+ not respect the force flag for qdel(). It has been \
+ placed in the queue, further instances of this type \
+ will also be queued.")
#endif
- else
- #ifdef TESTING
- if(!I.no_hint)
- testing("WARNING: [to_delete.type] is not returning a qdel hint. It is being placed in the queue. Further instances of this type will also be queued.")
- #endif
- I.no_hint++
- SSgarbage.Queue(to_delete)
- else if(to_delete.gc_destroyed == GC_CURRENTLY_BEING_QDELETED)
- CRASH("[to_delete.type] destroy proc was called multiple times, likely due to a qdel loop in the Destroy logic")
+ trash.no_respect_force++
+
+ SSgarbage.Queue(to_delete)
+ if (QDEL_HINT_HARDDEL) //qdel should assume this object won't gc, and queue a hard delete
+ SSgarbage.Queue(to_delete, GC_QUEUE_HARDDELETE)
+ if (QDEL_HINT_HARDDEL_NOW) //qdel should assume this object won't gc, and hard del it post haste.
+ SSgarbage.HardDelete(to_delete, override = TRUE)
+ #ifdef REFERENCE_TRACKING
+ if (QDEL_HINT_FINDREFERENCE) //qdel will, if REFERENCE_TRACKING is enabled, display all references to this object, then queue the object for deletion.
+ SSgarbage.HardDelete(to_delete, override = TRUE) // Need to override enable_hard_deletes, stuff like /client uses this
+ INVOKE_ASYNC(to_delete, TYPE_PROC_REF(/datum, find_references))
+ if (QDEL_HINT_IFFAIL_FINDREFERENCE) //qdel will, if REFERENCE_TRACKING is enabled and the object fails to collect, display all references to this object.
+ SSgarbage.Queue(to_delete)
+ SSgarbage.reference_find_on_fail[text_ref(to_delete)] = TRUE
+ #endif
+ else
+ #ifdef TESTING
+ if(!trash.no_hint)
+ testing("WARNING: [to_delete.type] is not returning a qdel hint. It is being placed in the queue. Further instances of this type will also be queued.")
+ #endif
+ trash.no_hint++
+ SSgarbage.Queue(to_delete)
diff --git a/code/controllers/subsystems/tgui.dm b/code/controllers/subsystems/tgui.dm
index e75b9c455a0..0727bf5d3e3 100644
--- a/code/controllers/subsystems/tgui.dm
+++ b/code/controllers/subsystems/tgui.dm
@@ -15,7 +15,7 @@ SUBSYSTEM_DEF(tgui)
wait = 9
flags = SS_NO_INIT
priority = FIRE_PRIORITY_TGUI
- runlevels = RUNLEVEL_INIT | RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT
+ runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT
/// A list of UIs scheduled to process
var/list/current_run = list()
@@ -39,6 +39,25 @@ SUBSYSTEM_DEF(tgui)
basehtml = replacetextEx(basehtml, "", "Nanotrasen NeoTheology (c) 2525-[CURRENT_SHIP_YEAR]")
+/datum/controller/subsystem/tgui/OnConfigLoad()
+ var/storage_iframe = CONFIG_GET(string/storage_cdn_iframe)
+
+ if(storage_iframe && storage_iframe != /datum/config_entry/string/storage_cdn_iframe::default)
+ basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", storage_iframe)
+ return
+
+ if(CONFIG_GET(string/asset_transport) == "webroot")
+ var/datum/asset_transport/webroot/webroot = SSassets.transport
+
+ var/datum/asset_cache_item/item = webroot.register_asset("iframe.html", file("tgui/public/iframe.html"))
+ basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", webroot.get_asset_url("iframe.html", item))
+ return
+
+ if(!storage_iframe)
+ return
+
+ basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", storage_iframe)
+
/datum/controller/subsystem/tgui/Shutdown()
close_all_uis()
@@ -52,14 +71,15 @@ SUBSYSTEM_DEF(tgui)
src.current_run = all_uis.Copy()
// Cache for sanic speed (lists are references anyways)
var/list/current_run = src.current_run
- while(current_run.len)
- var/datum/tgui/ui = current_run[current_run.len]
+ var/seconds_per_tick = wait * 0.1
+ while(length(current_run))
+ var/datum/tgui/ui = current_run[length(current_run)]
current_run.len--
// TODO: Move user/src_object check to process()
if(ui?.user && ui.src_object)
- ui.Process(wait * 0.1)
+ ui.Process(seconds_per_tick)
else
- ui.close(0)
+ ui.close(FALSE)
if(MC_TICK_CHECK)
return
@@ -70,7 +90,7 @@ SUBSYSTEM_DEF(tgui)
* Returns null if pool was exhausted.
*
* required user mob
- * return datum/tgui_window
+ * return datum/tgui
*/
/datum/controller/subsystem/tgui/proc/request_pooled_window(mob/user)
if(!user.client)
@@ -96,8 +116,10 @@ SUBSYSTEM_DEF(tgui)
window_found = TRUE
break
if(!window_found)
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(user, "Error: Pool exhausted",
context = "SStgui/request_pooled_window")
+#endif
return null
return window
@@ -109,7 +131,9 @@ SUBSYSTEM_DEF(tgui)
* required user mob
*/
/datum/controller/subsystem/tgui/proc/force_close_all_windows(mob/user)
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(user, context = "SStgui/force_close_all_windows")
+#endif
if(user.client)
user.client.tgui_windows = list()
for(var/i in 1 to TGUI_WINDOW_HARD_LIMIT)
@@ -125,11 +149,15 @@ SUBSYSTEM_DEF(tgui)
* required window_id string
*/
/datum/controller/subsystem/tgui/proc/force_close_window(mob/user, window_id)
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(user, context = "SStgui/force_close_window")
+#endif
// 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)
ui.close(can_be_suspended = FALSE)
+ // Unset machine just to be sure.
+ user.unset_machine()
// Close window directly just to be sure.
user << browse(null, "window=[window_id]")
diff --git a/code/datums/datum.dm b/code/datums/datum.dm
index f9addfad18e..05ac8324ea9 100644
--- a/code/datums/datum.dm
+++ b/code/datums/datum.dm
@@ -17,7 +17,7 @@
*/
var/gc_destroyed
- /// Open tguis owned by this datum
+ /// Open uis owned by this datum
/// Lazy, since this case is semi rare
var/list/open_uis
@@ -29,16 +29,16 @@
/**
* Components attached to this datum
*
- * Lazy associated list in the structure of `type:component/list of components`
+ * Lazy associated list in the structure of `type -> component/list of components`
*/
var/list/_datum_components
/**
* Any datum registered to receive signals from this datum is in this list
*
- * Lazy associated list in the structure of `[signal] = list(registered_objects)`
+ * Lazy associated list in the structure of `signal -> registree/list of registrees`
*/
var/list/_listen_lookup
- /// Lazy associated list in the structure of `[target][signal] = proc)` that are run when the datum receives that signal
+ /// Lazy associated list in the structure of `target -> list(signal -> proctype)` that are run when the datum receives that signal
var/list/list/datum/callback/_signal_procs
var/signal_enabled = FALSE
@@ -58,15 +58,44 @@
var/list/cooldowns
+ /// List for handling persistent filters.
+ var/list/filter_data
+ /// An accursed beast of a list that contains our filters. Why? Because var/list/filters on atoms/images isn't actually a list
+ /// but a snowflaked skinwalker pretending to be one, which doesn't support half the list procs/operations and the other half behaves weirdly
+ /// so we cut down on filter creation and appearance update costs by editing *this* list, and then assigning ours to it
+ var/list/filter_cache
+
#ifdef REFERENCE_TRACKING
var/running_find_references
+ /// When was this datum last touched by a reftracker?
+ /// If this value doesn't match with the start of the search
+ /// We know this datum has never been seen before, and we should check it
var/last_find_references = 0
+ /// How many references we're trying to find when searching
+ var/references_to_clear = 0
#ifdef REFERENCE_TRACKING_DEBUG
///Stores info about where refs are found, used for sanity checks and testing
var/list/found_refs
#endif
#endif
+ // If we have called dump_harddel_info already. Used to avoid duped calls (since we call it immediately in some cases on failure to process)
+ // Create and destroy is weird and I wanna cover my bases
+ var/harddel_deets_dumped = FALSE
+
+#ifdef DATUMVAR_DEBUGGING_MODE
+ var/list/cached_vars
+#endif
+
+/**
+ * Called when a href for this datum is clicked
+ *
+ * Sends a [COMSIG_TOPIC] signal
+ */
+/datum/Topic(href, href_list[])
+ ..()
+ SEND_SIGNAL(src, COMSIG_TOPIC, usr, href_list)
+
/**
* Default implementation of clean-up code.
*
@@ -143,6 +172,33 @@
for(var/target in _signal_procs)
UnregisterSignal(target, _signal_procs[target])
+#ifdef DATUMVAR_DEBUGGING_MODE
+/datum/proc/save_vars()
+ cached_vars = list()
+ for(var/i in vars)
+ if(i == "cached_vars")
+ continue
+ cached_vars[i] = vars[i]
+
+/datum/proc/check_changed_vars()
+ . = list()
+ for(var/i in vars)
+ if(i == "cached_vars")
+ continue
+ if(cached_vars[i] != vars[i])
+ .[i] = list(cached_vars[i], vars[i])
+
+/datum/proc/txt_changed_vars()
+ var/list/l = check_changed_vars()
+ var/t = "[src]([REF(src)]) changed vars:"
+ for(var/i in l)
+ t += "\"[i]\" \[[l[i][1]]\] --> \[[l[i][2]]\] "
+ t += "."
+
+/datum/proc/to_chat_check_changed_vars(target = world)
+ to_chat(target, txt_changed_vars())
+#endif
+
/// Return a list of data which can be used to investigate the datum, also ensure that you set the semver in the options list
/datum/proc/serialize_list(list/options, list/semvers)
SHOULD_CALL_PARENT(TRUE)
@@ -182,6 +238,37 @@
jsonlist["DATUM_TYPE"] = D.type
return json_encode(jsonlist)
+/// Convert a list of json to datum
+/proc/json_deserialize_datum(list/jsonlist, list/options, target_type, strict_target_type = FALSE)
+ if(!islist(jsonlist))
+ if(!istext(jsonlist))
+ CRASH("Invalid JSON")
+ jsonlist = json_decode(jsonlist)
+ if(!islist(jsonlist))
+ CRASH("Invalid JSON")
+ if(!jsonlist["DATUM_TYPE"])
+ return
+ if(!ispath(jsonlist["DATUM_TYPE"]))
+ if(!istext(jsonlist["DATUM_TYPE"]))
+ return
+ jsonlist["DATUM_TYPE"] = text2path(jsonlist["DATUM_TYPE"])
+ if(!ispath(jsonlist["DATUM_TYPE"]))
+ return
+ if(target_type)
+ if(!ispath(target_type))
+ return
+ if(strict_target_type)
+ if(target_type != jsonlist["DATUM_TYPE"])
+ return
+ else if(!ispath(jsonlist["DATUM_TYPE"], target_type))
+ return
+ var/typeofdatum = jsonlist["DATUM_TYPE"] //BYOND won't directly read if this is just put in the line below, and will instead runtime because it thinks you're trying to make a new list?
+ var/datum/D = new typeofdatum
+ if(!D.deserialize_list(jsonlist, options))
+ qdel(D)
+ else
+ return D
+
/**
* Callback called by a timer to end an associative-list-indexed cooldown.
*
@@ -213,65 +300,22 @@
SEND_SIGNAL(source, COMSIG_CD_RESET(index), S_TIMER_COOLDOWN_TIMELEFT(source, index))
TIMER_COOLDOWN_END(source, index)
-/datum/proc/CanProcCall(procname)
- return TRUE
-
-/datum/proc/can_vv_get(var_name)
- if(var_name == NAMEOF(src, vars))
- return FALSE
- return TRUE
-
-/// Called when a var is edited with the new value to change to
-/datum/proc/vv_edit_var(var_name, var_value)
- if(var_name == NAMEOF(src, vars))
- return FALSE
- vars[var_name] = var_value
- datum_flags |= DF_VAR_EDITED
- return TRUE
-
-/datum/proc/vv_get_var(var_name)
- switch(var_name)
- if (NAMEOF(src, vars))
- return debug_variable(var_name, list(), 0, src)
- return debug_variable(var_name, vars[var_name], 0, src)
-
-/datum/proc/can_vv_mark()
- return TRUE
-
-/**
- * Gets all the dropdown options in the vv menu.
- * When overriding, make sure to call . = ..() first and appent to the result, that way parent items are always at the top and child items are further down.
- * Add seperators by doing VV_DROPDOWN_OPTION("", "---")
- */
-/datum/proc/vv_get_dropdown()
- SHOULD_CALL_PARENT(TRUE)
-
- . = list()
- VV_DROPDOWN_OPTION("", "---")
- VV_DROPDOWN_OPTION(VV_HK_CALLPROC, "Call Proc")
- VV_DROPDOWN_OPTION(VV_HK_MARK, "Mark Object")
- VV_DROPDOWN_OPTION(VV_HK_TAG, "Tag Datum")
- VV_DROPDOWN_OPTION(VV_HK_DELETE, "Delete")
- VV_DROPDOWN_OPTION(VV_HK_EXPOSE, "Show VV To Player")
- VV_DROPDOWN_OPTION(VV_HK_ADDCOMPONENT, "Add Component/Element")
- VV_DROPDOWN_OPTION(VV_HK_REMOVECOMPONENT, "Remove Component/Element")
- VV_DROPDOWN_OPTION(VV_HK_MASS_REMOVECOMPONENT, "Mass Remove Component/Element")
-
-/**
- * This proc is only called if everything topic-wise is verified. The only verifications that should happen here is things like permission checks!
- * href_list is a reference, modifying it in these procs WILL change the rest of the proc in topic.dm of admin/view_variables!
- * This proc is for "high level" actions like admin heal/set species/etc/etc. The low level debugging things should go in admin/view_variables/topic_basic.dm incase this runtimes.
- */
-/datum/proc/vv_do_topic(list/href_list)
- if(!usr || !usr.client || !usr.client.holder || !check_rights(NONE))
- return FALSE //This is VV, not to be called by anything else.
- if(SEND_SIGNAL(src, COMSIG_VV_TOPIC, usr, href_list) & COMPONENT_VV_HANDLED)
- return FALSE
- // if(href_list[VV_HK_MODIFY_TRAITS])
- // usr.client.holder.modify_traits(src)
- return TRUE
-
-/datum/proc/vv_get_header()
- . = list()
- if(("name" in vars) && !isatom(src))
- . += "[vars["name"]]
"
+///Generate a tag for this /datum, if it implements one
+///Should be called as early as possible, best would be in New, to avoid weakref mistargets
+///Really just don't use this, you don't need it, global lists will do just fine MOST of the time
+///We really only use it for mobs to make id'ing people easier
+/datum/proc/GenerateTag()
+ datum_flags |= DF_USE_TAG
+
+/// Return text from this proc to provide extra context to hard deletes that happen to it
+/// Optional, you should use this for cases where replication is difficult and extra context is required
+/// Can be called more then once per object, use harddel_deets_dumped to avoid duplicate calls (I am so sorry)
+/datum/proc/dump_harddel_info()
+ return
+
+///images are pretty generic, this should help a bit with tracking harddels related to them
+/image/dump_harddel_info()
+ if(harddel_deets_dumped)
+ return
+ harddel_deets_dumped = TRUE
+ return "Image icon: [icon] - icon_state: [icon_state] [loc ? "loc: [loc] ([loc.x],[loc.y],[loc.z])" : ""]"
diff --git a/code/datums/datumvars.dm b/code/datums/datumvars.dm
new file mode 100644
index 00000000000..7f8923082c5
--- /dev/null
+++ b/code/datums/datumvars.dm
@@ -0,0 +1,62 @@
+/datum/proc/CanProcCall(procname)
+ return TRUE
+
+/datum/proc/can_vv_get(var_name)
+ if(var_name == NAMEOF(src, vars))
+ return FALSE
+ return TRUE
+
+/// Called when a var is edited with the new value to change to
+/datum/proc/vv_edit_var(var_name, var_value)
+ if(var_name == NAMEOF(src, vars))
+ return FALSE
+ vars[var_name] = var_value
+ datum_flags |= DF_VAR_EDITED
+ return TRUE
+
+/datum/proc/vv_get_var(var_name)
+ switch(var_name)
+ if (NAMEOF(src, vars))
+ return debug_variable(var_name, list(), 0, src)
+ return debug_variable(var_name, vars[var_name], 0, src)
+
+/datum/proc/can_vv_mark()
+ return TRUE
+
+/**
+ * Gets all the dropdown options in the vv menu.
+ * When overriding, make sure to call . = ..() first and appent to the result, that way parent items are always at the top and child items are further down.
+ * Add seperators by doing VV_DROPDOWN_OPTION("", "---")
+ */
+/datum/proc/vv_get_dropdown()
+ SHOULD_CALL_PARENT(TRUE)
+
+ . = list()
+ VV_DROPDOWN_OPTION("", "---")
+ VV_DROPDOWN_OPTION(VV_HK_CALLPROC, "Call Proc")
+ VV_DROPDOWN_OPTION(VV_HK_MARK, "Mark Object")
+ VV_DROPDOWN_OPTION(VV_HK_TAG, "Tag Datum")
+ VV_DROPDOWN_OPTION(VV_HK_DELETE, "Delete")
+ VV_DROPDOWN_OPTION(VV_HK_EXPOSE, "Show VV To Player")
+ VV_DROPDOWN_OPTION(VV_HK_ADDCOMPONENT, "Add Component/Element")
+ VV_DROPDOWN_OPTION(VV_HK_REMOVECOMPONENT, "Remove Component/Element")
+ VV_DROPDOWN_OPTION(VV_HK_MASS_REMOVECOMPONENT, "Mass Remove Component/Element")
+
+/**
+ * This proc is only called if everything topic-wise is verified. The only verifications that should happen here is things like permission checks!
+ * href_list is a reference, modifying it in these procs WILL change the rest of the proc in topic.dm of admin/view_variables!
+ * This proc is for "high level" actions like admin heal/set species/etc/etc. The low level debugging things should go in admin/view_variables/topic_basic.dm incase this runtimes.
+ */
+/datum/proc/vv_do_topic(list/href_list)
+ if(!usr || !usr.client || !usr.client.holder || !check_rights(NONE))
+ return FALSE //This is VV, not to be called by anything else.
+ if(SEND_SIGNAL(src, COMSIG_VV_TOPIC, usr, href_list) & COMPONENT_VV_HANDLED)
+ return FALSE
+ // if(href_list[VV_HK_MODIFY_TRAITS])
+ // usr.client.holder.modify_traits(src)
+ return TRUE
+
+/datum/proc/vv_get_header()
+ . = list()
+ if(("name" in vars) && !isatom(src))
+ . += "[vars["name"]]
"
diff --git a/code/game/atoms.dm b/code/game/atoms.dm
index 1ed733afea9..a7313c42bc9 100644
--- a/code/game/atoms.dm
+++ b/code/game/atoms.dm
@@ -194,10 +194,6 @@
update_openspace()
return ..()
-/// Generate a tag for this atom
-/atom/proc/GenerateTag()
- return
-
/atom/proc/reveal_blood()
return
diff --git a/code/modules/tgui/external.dm b/code/modules/tgui/external.dm
index 6e52af118aa..6a310d976d4 100644
--- a/code/modules/tgui/external.dm
+++ b/code/modules/tgui/external.dm
@@ -42,7 +42,7 @@
*
* required user mob The mob interacting with the UI.
*
- * return list Static Data to be sent to the UI.
+ * return list Statuic Data to be sent to the UI.
*/
/datum/proc/ui_static_data(mob/user)
return list()
@@ -86,14 +86,10 @@
*/
/datum/proc/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
SHOULD_CALL_PARENT(TRUE)
- SEND_SIGNAL(src, COMSIG_UI_ACT, usr, action, params)
+ SEND_SIGNAL(src, COMSIG_UI_ACT, usr, action)
// If UI is not interactive or usr calling Topic is not the UI user, bail.
if(!ui || ui.status != UI_INTERACTIVE)
return TRUE
- // if(action == "change_ui_state")
- // var/mob/living/user = ui.user
- // //write_preferences will make sure it's valid for href exploits.
- // user.client.prefs.write_preference(GLOB.preference_entries[layout_prefs_used], params["new_state"])
/**
* public
@@ -217,9 +213,11 @@
if(window_id)
window = usr.client.tgui_windows[window_id]
if(!window)
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(usr,
"Error: Couldn't find the window datum, force closing.",
context = window_id)
+#endif
SStgui.force_close_window(usr, window_id)
return TRUE
diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm
index 1a966a241b8..7080f45c171 100644
--- a/code/modules/tgui/tgui.dm
+++ b/code/modules/tgui/tgui.dm
@@ -37,6 +37,11 @@
var/datum/ui_state/state = null
/// Rate limit client refreshes to prevent DoS.
COOLDOWN_DECLARE(refresh_cooldown)
+ /// Are byond mouse events beyond the window passed in to the ui
+ var/mouse_hooked = FALSE
+
+ /// The id of any ByondUi elements that we have opened
+ var/list/open_byondui_elements
/**
* public
@@ -53,9 +58,11 @@
* return datum/tgui The requested UI.
*/
/datum/tgui/New(mob/user, datum/src_object, interface, title, ui_x, ui_y)
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(user,
"new [interface] fancy [user?.client?.get_preference_value(/datum/client_preference/tgui_fancy)]",
src_object = src_object)
+#endif
src.user = user
src.src_object = src_object
src.window_key = "[REF(src_object)]-main"
@@ -80,7 +87,7 @@
* return bool - TRUE if a new pooled window is opened, FALSE in all other situations including if a new pooled window didn't open because one already exists.
*/
/datum/tgui/proc/open()
- if(!user.client)
+ if(!user?.client)
return FALSE
if(window)
return FALSE
@@ -105,6 +112,8 @@
window.send_message("update", get_payload(
with_data = TRUE,
with_static_data = TRUE))
+ if(mouse_hooked)
+ window.set_mouse_macro()
SStgui.on_open(src)
return TRUE
@@ -142,9 +151,26 @@
window.close(can_be_suspended)
src_object.ui_close(user)
SStgui.on_close(src)
+
+ if(user.client)
+ terminate_byondui_elements()
+
state = null
qdel(src)
+/**
+ * public
+ *
+ * Closes all ByondUI elements, left dangling by a forceful TGUI exit,
+ * such as via Alt+F4, closing in non-fancy mode, or terminating the process
+ *
+ */
+/datum/tgui/proc/terminate_byondui_elements()
+ set waitfor = FALSE
+
+ for(var/byondui_element in open_byondui_elements)
+ winset(user.client, byondui_element, list("parent" = ""))
+
/**
* public
*
@@ -155,6 +181,18 @@
/datum/tgui/proc/set_autoupdate(autoupdate)
src.autoupdate = autoupdate
+/**
+ * public
+ *
+ * Enable/disable passing through byond mouse events to the window
+ *
+ * required value bool Enable/disable hooking.
+ */
+/datum/tgui/proc/set_mouse_hook(value)
+ src.mouse_hooked = value
+ //Handle unhooking/hooking on already open windows ?
+
+
/**
* public
*
@@ -188,7 +226,7 @@
* optional force bool Send an update even if UI is not interactive.
*/
/datum/tgui/proc/send_full_update(custom_data, force)
- if(!user.client || !initialized || closing)
+ if(!user?.client || !initialized || closing)
return
if(!COOLDOWN_FINISHED(src, refresh_cooldown))
refreshing = TRUE
@@ -211,7 +249,7 @@
* optional force bool Send an update even if UI is not interactive.
*/
/datum/tgui/proc/send_update(custom_data, force)
- if(!user.client || !initialized || closing)
+ if(!user?.client || !initialized || closing)
return
var/should_update_data = force || status >= UI_UPDATE
window.send_message("update", get_payload(
@@ -244,23 +282,23 @@
"scale" = user.client.get_preference_value(/datum/client_preference/ui_scale),
),
"client" = list(
- "ckey" = user.client.ckey,
- "address" = user.client.address,
- "computer_id" = user.client.computer_id,
+ "ckey" = user.client?.ckey,
+ "address" = user.client?.address,
+ "computer_id" = user.client?.computer_id,
),
"user" = list(
"name" = "[user]",
"observer" = isobserver(user),
),
)
- var/data = custom_data || with_data && src_object.ui_data(user)
+ var/data = custom_data || with_data && src_object?.ui_data(user)
if(data)
json_data["data"] = data
- var/static_data = with_static_data && src_object.ui_static_data(user)
+ var/static_data = with_static_data && src_object?.ui_static_data(user)
if(static_data)
json_data["static_data"] = static_data
- if(src_object.tgui_shared_states)
- json_data["shared"] = src_object.tgui_shared_states
+ if(src_object?.tgui_shared_states)
+ json_data["shared"] = src_object?.tgui_shared_states
return json_data
/**
@@ -279,9 +317,11 @@
return
// Validate ping
if(!initialized && world.time - opened_at > TGUI_PING_TIMEOUT)
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(user, "Error: Zombie window detected, closing.",
window = window,
src_object = src_object)
+#endif
close(can_be_suspended = FALSE)
return
// Update through a normal call to ui_interact
@@ -342,6 +382,18 @@
LAZYINITLIST(src_object.tgui_shared_states)
src_object.tgui_shared_states[href_list["key"]] = href_list["value"]
SStgui.update_uis(src_object)
+ if(TGUI_MANAGED_BYONDUI_TYPE_RENDER)
+ var/byond_ui_id = payload[TGUI_MANAGED_BYONDUI_PAYLOAD_ID]
+ if(!byond_ui_id || LAZYLEN(open_byondui_elements) > TGUI_MANAGED_BYONDUI_LIMIT)
+ return
+
+ LAZYOR(open_byondui_elements, byond_ui_id)
+ if(TGUI_MANAGED_BYONDUI_TYPE_UNMOUNT)
+ var/byond_ui_id = payload[TGUI_MANAGED_BYONDUI_PAYLOAD_ID]
+ if(!byond_ui_id)
+ return
+
+ LAZYREMOVE(open_byondui_elements, byond_ui_id)
/// Wrapper for behavior to potentially wait until the next tick if the server is overloaded
/datum/tgui/proc/on_act_message(act_type, payload, state)
diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm
index 38c49548b64..f12075d322f 100644
--- a/code/modules/tgui/tgui_window.dm
+++ b/code/modules/tgui/tgui_window.dm
@@ -25,6 +25,19 @@
var/initial_inline_html
var/initial_inline_js
var/initial_inline_css
+ var/mouse_event_macro_set = FALSE
+
+ /**
+ * Static list used to map in macros that will then emit execute events to the tgui window
+ * A small disclaimer though I'm no tech wiz: I don't think it's possible to map in right or middle
+ * clicks in the current state, as they're keywords rather than modifiers.
+ */
+ var/static/list/byondToTguiEventMap = list(
+ "MouseDown" = "byond/mousedown",
+ "MouseUp" = "byond/mouseup",
+ "Ctrl" = "byond/ctrldown",
+ "Ctrl+UP" = "byond/ctrlup",
+ )
var/list/oversized_payloads = list()
@@ -65,10 +78,12 @@
inline_html = "",
inline_js = "",
inline_css = "")
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(client,
context = "[id]/initialize",
window = src)
- if(!client)
+#endif
+ if(QDELETED(client))
return
src.initial_fancy = fancy
src.initial_assets = assets
@@ -119,7 +134,7 @@
// Detect whether the control is a browser
is_browser = winexists(client, id) == "BROWSER"
// Instruct the client to signal UI when the window is closed.
- if(!is_browser)
+ if(!is_browser && !QDELETED(client)) // monkestation: extra anti-runtime checks
winset(client, id, "on-close=\"uiclose [id]\"")
/**
@@ -222,17 +237,23 @@
/datum/tgui_window/proc/close(can_be_suspended = TRUE)
if(!client)
return
+ if(mouse_event_macro_set)
+ remove_mouse_macro()
if(can_be_suspended && can_be_suspended())
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(client,
context = "[id]/close (suspending)",
window = src)
+#endif
visible = FALSE
status = TGUI_WINDOW_READY
send_message("suspend")
return
+#ifdef EXTENDED_DEBUG_LOGGING
log_tgui(client,
context = "[id]/close",
window = src)
+#endif
release_lock()
visible = FALSE
status = TGUI_WINDOW_CLOSED
@@ -307,7 +328,6 @@
var/datum/asset/spritesheet_batched/spritesheet = asset
send_message("asset/stylesheet", spritesheet.css_filename())
send_raw_message(asset.get_serialized_url_mappings())
-
/**
* private
*
@@ -397,6 +417,35 @@
/datum/tgui_window/vv_edit_var(var_name, var_value)
return var_name != NAMEOF(src, id) && ..()
+
+/datum/tgui_window/proc/set_mouse_macro()
+ if(mouse_event_macro_set)
+ return
+
+ for(var/mouseMacro in byondToTguiEventMap)
+ var/command_template = ".output CONTROL PAYLOAD"
+ var/event_message = TGUI_CREATE_MESSAGE(byondToTguiEventMap[mouseMacro], null)
+ var target_control = is_browser \
+ ? "[id]:update" \
+ : "[id].browser:update"
+ var/with_id = replacetext(command_template, "CONTROL", target_control)
+ var/full_command = replacetext(with_id, "PAYLOAD", event_message)
+
+ var/list/params = list()
+ params["parent"] = "default" //Technically this is external to tgui but whatever
+ params["name"] = mouseMacro
+ params["command"] = full_command
+
+ winset(client, "[mouseMacro]Window[id]Macro", params)
+ mouse_event_macro_set = TRUE
+
+/datum/tgui_window/proc/remove_mouse_macro()
+ if(!mouse_event_macro_set)
+ stack_trace("Unsetting mouse macro on tgui window that has none")
+ for(var/mouseMacro in byondToTguiEventMap)
+ winset(client, null, "[mouseMacro]Window[id]Macro.parent=null")
+ mouse_event_macro_set = FALSE
+
/datum/tgui_window/proc/create_oversized_payload(payload_id, message_type, chunk_count)
if(oversized_payloads[payload_id])
stack_trace("Attempted to create oversized tgui payload with duplicate ID.")
@@ -414,7 +463,7 @@
return
var/list/chunks = payload["chunks"]
chunks += chunk
- if(length(chunks) >= payload["count"])
+ if(chunks.len >= payload["count"])
deltimer(payload["timeout"])
var/message_type = payload["type"]
var/final_payload = chunks.Join()
diff --git a/code/modules/tgui_panel/telemetry.dm b/code/modules/tgui_panel/telemetry.dm
index aceccc7fa9d..5b30d26ebe9 100644
--- a/code/modules/tgui_panel/telemetry.dm
+++ b/code/modules/tgui_panel/telemetry.dm
@@ -12,15 +12,14 @@
/**
* Maximum time allocated for sending a telemetry packet.
*/
-#define TGUI_TELEMETRY_RESPONSE_WINDOW 30 SECONDS
+#define TGUI_TELEMETRY_RESPONSE_WINDOW (30 SECONDS)
/// Time of telemetry request
-/datum/tgui_panel
- var/telemetry_requested_at
- /// Time of telemetry analysis completion
- var/telemetry_analyzed_at
- /// List of previous client connections
- var/list/telemetry_connections
+/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
@@ -109,9 +108,7 @@
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)
- log_admin(msg)
- log_suspicious_login(msg, access_log_mirror = FALSE)
+ 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
@@ -131,7 +128,7 @@
:computer_id,
:round_id,
:round_id
- ) ON DUPLICATE KEY UPDATE [format_table_name("latest_round_id")] = :round_id
+ ) ON DUPLICATE KEY UPDATE latest_round_id = :round_id
"}, list(
"ckey" = ckey,
"telemetry_ckey" = one_query["telemetry_ckey"],
@@ -141,3 +138,6 @@
))
query.Execute()
qdel(query)
+
+#undef TGUI_TELEMETRY_MAX_CONNECTIONS
+#undef TGUI_TELEMETRY_RESPONSE_WINDOW
diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm
index 4baf7f2e18f..899337a1654 100644
--- a/code/modules/tgui_panel/tgui_panel.dm
+++ b/code/modules/tgui_panel/tgui_panel.dm
@@ -62,7 +62,7 @@
*/
/datum/tgui_panel/proc/on_initialize_timed_out()
// Currently does nothing but sending a message to old chat.
- SEND_TEXT(client, "Failed to load fancy chat, click HERE to attempt to reload it.")
+ SEND_TEXT(client, "Failed to load fancy chat, click HERE to attempt to reload it.")
/**
* private
diff --git a/config/example/resources.txt b/config/example/resources.txt
index e20ab1cf518..3e3dac38e5b 100644
--- a/config/example/resources.txt
+++ b/config/example/resources.txt
@@ -67,3 +67,12 @@ SMART_CACHE_ASSETS
## Tgui payloads larger than the 2kb limit for BYOND topic requests are split into roughly 1kb chunks and sent in sequence.
## This config option limits the maximum chunk count for which the server will accept a payload, default is 32
TGUI_MAX_CHUNK_COUNT 32
+
+# If configured, this allows server operators to define the persistent origin used for clientside
+# storage. This must host the same file as available in tgui/public/iframe.html. This is also hosted
+# on the GitHub Pages site for the /tg/station repository, so does not need to be configured.
+# If multiple servers use the same domain name, clientside features such as message saving
+# and chat tabs will be persistent across both.
+# If this setting is not configured, but the webroot CDN is, that will be used instead of GitHub Pages.
+# If this setting is mpty, and the webroot CDN is disabled, byondstorage will be used.
+# STORAGE_CDN_IFRAME https://tgstation.github.io/byond-client-storage/iframe.html
diff --git a/tgui/global.d.ts b/tgui/global.d.ts
index cfc7920e820..0579f0d58b8 100644
--- a/tgui/global.d.ts
+++ b/tgui/global.d.ts
@@ -63,6 +63,11 @@ type ByondType = {
*/
strictMode: boolean;
+ /**
+ * The external URL for the IndexedDB IFrame to use as the origin
+ */
+ storageCdn: string;
+
/**
* Makes a BYOND call.
*
diff --git a/tgui/package.json b/tgui/package.json
index 788a4c5240b..a971b8e9c73 100644
--- a/tgui/package.json
+++ b/tgui/package.json
@@ -36,8 +36,8 @@
"jsdom": "^26.0.0",
"prettier": "^3.2.5",
"sass": "^1.80.6",
- "sass-embedded": "^1.85.1",
- "sass-loader": "^16.0.3",
+ "sass-embedded": "^1.89.2",
+ "sass-loader": "^16.0.5",
"typescript": "^5.6.3",
"url-loader": "^4.1.1"
},
diff --git a/tgui/packages/common/storage.ts b/tgui/packages/common/storage.ts
index 427e8b8ffce..b67b115a9f3 100644
--- a/tgui/packages/common/storage.ts
+++ b/tgui/packages/common/storage.ts
@@ -6,10 +6,12 @@
* @license MIT
*/
-export const IMPL_MEMORY = 0;
export const IMPL_HUB_STORAGE = 1;
+export const IMPL_IFRAME_INDEXED_DB = 2;
-type StorageImplementation = typeof IMPL_MEMORY | typeof IMPL_HUB_STORAGE;
+type StorageImplementation =
+ | typeof IMPL_HUB_STORAGE
+ | typeof IMPL_IFRAME_INDEXED_DB;
type StorageBackend = {
impl: StorageImplementation;
@@ -31,57 +33,104 @@ const testHubStorage = testGeneric(
() => window.hubStorage && !!window.hubStorage.getItem,
);
-class MemoryBackend implements StorageBackend {
- private store: Record;
+class HubStorageBackend implements StorageBackend {
public impl: StorageImplementation;
constructor() {
- this.impl = IMPL_MEMORY;
- this.store = {};
+ this.impl = IMPL_HUB_STORAGE;
}
async get(key: string): Promise {
- return this.store[key];
+ const value = await window.hubStorage.getItem(key);
+ if (typeof value === 'string') {
+ return JSON.parse(value);
+ }
+ return undefined;
}
async set(key: string, value: any): Promise {
- this.store[key] = value;
+ window.hubStorage.setItem(key, JSON.stringify(value));
}
async remove(key: string): Promise {
- this.store[key] = undefined;
+ window.hubStorage.removeItem(key);
}
async clear(): Promise {
- this.store = {};
+ window.hubStorage.clear();
}
}
-class HubStorageBackend implements StorageBackend {
+class IFrameIndexedDbBackend implements StorageBackend {
public impl: StorageImplementation;
+ private documentElement: HTMLIFrameElement;
+ private iframeWindow: Window;
+
constructor() {
- this.impl = IMPL_HUB_STORAGE;
+ this.impl = IMPL_IFRAME_INDEXED_DB;
+ }
+
+ async ready(): Promise {
+ const iframe = document.createElement('iframe');
+ iframe.style.display = 'none';
+ iframe.src = Byond.storageCdn;
+
+ const completePromise: Promise = new Promise((resolve) => {
+ fetch(Byond.storageCdn, { method: 'HEAD' })
+ .then((response) => {
+ if (response.status !== 200) {
+ resolve(false);
+ }
+ })
+ .catch(() => {
+ resolve(false);
+ });
+
+ window.addEventListener('message', (message) => {
+ if (message.data === 'ready') {
+ resolve(true);
+ }
+ });
+ });
+
+ this.documentElement = document.body.appendChild(iframe);
+ if (!this.documentElement.contentWindow) {
+ return new Promise((res) => res(false));
+ }
+
+ this.iframeWindow = this.documentElement.contentWindow;
+
+ return completePromise;
}
async get(key: string): Promise {
- const value = await window.hubStorage.getItem(key);
- if (typeof value === 'string') {
- return JSON.parse(value);
- }
- return undefined;
+ const promise = new Promise((resolve) => {
+ window.addEventListener('message', (message) => {
+ if (message.data.key && message.data.key === key) {
+ resolve(message.data.value);
+ }
+ });
+ });
+
+ this.iframeWindow.postMessage({ type: 'get', key: key }, '*');
+ return promise;
}
async set(key: string, value: any): Promise {
- window.hubStorage.setItem(key, JSON.stringify(value));
+ this.iframeWindow.postMessage({ type: 'set', key: key, value: value }, '*');
}
async remove(key: string): Promise {
- window.hubStorage.removeItem(key);
+ this.iframeWindow.postMessage({ type: 'remove', key: key }, '*');
}
async clear(): Promise {
- window.hubStorage.clear();
+ this.iframeWindow.postMessage({ type: 'clear' }, '*');
+ }
+
+ async destroy(): Promise {
+ document.body.removeChild(this.documentElement);
}
}
@@ -91,19 +140,71 @@ class HubStorageBackend implements StorageBackend {
*/
class StorageProxy implements StorageBackend {
private backendPromise: Promise;
- public impl: StorageImplementation = IMPL_MEMORY;
+ public impl: StorageImplementation = IMPL_IFRAME_INDEXED_DB;
constructor() {
this.backendPromise = (async () => {
- if (testHubStorage()) {
- return new HubStorageBackend();
+ // If we have not enabled byondstorage yet, we need to check
+ // if we can use the IFrame, or if we need to enable byondstorage
+ if (!testHubStorage()) {
+ // If we have an IFrame URL we can use, and we haven't already enabled
+ // byondstorage, we should use the IFrame backend
+ if (Byond.storageCdn) {
+ const iframe = new IFrameIndexedDbBackend();
+
+ if ((await iframe.ready()) === true) {
+ if (await iframe.get('byondstorage-migrated')) return iframe;
+
+ Byond.winset(null, 'browser-options', '+byondstorage');
+
+ await new Promise((resolve) => {
+ document.addEventListener('byondstorageupdated', async () => {
+ setTimeout(() => {
+ const hub = new HubStorageBackend();
+
+ // Migrate these existing settings from byondstorage to the IFrame
+ for (const setting of [
+ 'panel-settings',
+ 'chat-state',
+ 'chat-messages',
+ ]) {
+ hub
+ .get(setting)
+ .then((settings) => iframe.set(setting, settings));
+ }
+
+ iframe.set('byondstorage-migrated', true);
+ Byond.winset(null, 'browser-options', '-byondstorage');
+
+ resolve();
+ }, 1);
+ });
+ });
+
+ return iframe;
+ }
+
+ iframe.destroy();
+ }
+
+ // IFrame hasn't worked out for us, we'll need to enable byondstorage
+ Byond.winset(null, 'browser-options', '+byondstorage');
+
+ return new Promise((resolve) => {
+ const listener = () => {
+ document.removeEventListener('byondstorageupdated', listener);
+
+ // This event is emitted *before* byondstorage is actually created
+ // so we have to wait a little bit before we can use it
+ setTimeout(() => resolve(new HubStorageBackend()), 1);
+ };
+
+ document.addEventListener('byondstorageupdated', listener);
+ });
}
- console.warn(
- 'No supported storage backend found. Using in-memory storage.',
- );
-
- return new MemoryBackend();
+ // byondstorage is already enabled, we can use it straight away
+ return new HubStorageBackend();
})();
}
diff --git a/tgui/packages/tgui-panel/Panel.tsx b/tgui/packages/tgui-panel/Panel.tsx
index d818f0351b8..d79fd985fdb 100644
--- a/tgui/packages/tgui-panel/Panel.tsx
+++ b/tgui/packages/tgui-panel/Panel.tsx
@@ -33,7 +33,7 @@ export const Panel = (props) => {
-
+
diff --git a/tgui/packages/tgui-panel/audio/NowPlayingWidget.jsx b/tgui/packages/tgui-panel/audio/NowPlayingWidget.jsx
index 53f3d5d7048..7b9afea69da 100644
--- a/tgui/packages/tgui-panel/audio/NowPlayingWidget.jsx
+++ b/tgui/packages/tgui-panel/audio/NowPlayingWidget.jsx
@@ -46,7 +46,7 @@ export const NowPlayingWidget = (props) => {
{URL !== 'Song Link Hidden' && (
- URL: {URL}
+ URL: {URL}
)}
@@ -99,7 +99,8 @@ export const NowPlayingWidget = (props) => {
step={0.0025}
stepPixelSize={1}
format={(value) => toFixed(value * 100) + '%'}
- onDrag={(e, value) =>
+ tickWhileDragging
+ onChange={(value) =>
settings.update({
adminMusicVolume: value,
})
diff --git a/tgui/packages/tgui-panel/chat/ChatPageSettings.jsx b/tgui/packages/tgui-panel/chat/ChatPageSettings.jsx
index a4402fc7bf2..463ec0c2dd5 100644
--- a/tgui/packages/tgui-panel/chat/ChatPageSettings.jsx
+++ b/tgui/packages/tgui-panel/chat/ChatPageSettings.jsx
@@ -24,9 +24,10 @@ import {
import { MESSAGE_TYPES } from './constants';
import { selectCurrentChatPage } from './selectors';
-export const ChatPageSettings = (props) => {
+export function ChatPageSettings(props) {
const page = useSelector(selectCurrentChatPage);
const dispatch = useDispatch();
+
return (
@@ -48,9 +49,9 @@ export const ChatPageSettings = (props) => {
)}
+ onBlur={(value) =>
dispatch(
updateChatPage({
pageId: page.id,
@@ -112,7 +113,7 @@ export const ChatPageSettings = (props) => {
)}
-
+
{MESSAGE_TYPES.filter(
(typeDef) => !typeDef.important && !typeDef.admin,
).map((typeDef) => (
@@ -154,4 +155,4 @@ export const ChatPageSettings = (props) => {
);
-};
+}
diff --git a/tgui/packages/tgui-panel/chat/ChatPanel.jsx b/tgui/packages/tgui-panel/chat/ChatPanel.tsx
similarity index 84%
rename from tgui/packages/tgui-panel/chat/ChatPanel.jsx
rename to tgui/packages/tgui-panel/chat/ChatPanel.tsx
index 92d07635834..b42086ccd56 100644
--- a/tgui/packages/tgui-panel/chat/ChatPanel.jsx
+++ b/tgui/packages/tgui-panel/chat/ChatPanel.tsx
@@ -10,7 +10,19 @@ import { shallowDiffers } from 'tgui-core/react';
import { chatRenderer } from './renderer';
-export class ChatPanel extends Component {
+type Props = {
+ fontSize?: string;
+ lineHeight: string;
+};
+
+type State = {
+ scrollTracking: boolean;
+};
+
+export class ChatPanel extends Component {
+ ref: React.RefObject;
+ handleScrollTrackingChange: (value: boolean) => void;
+
constructor(props) {
super(props);
this.ref = createRef();
@@ -29,7 +41,7 @@ export class ChatPanel extends Component {
'scrollTrackingChanged',
this.handleScrollTrackingChange,
);
- this.componentDidUpdate();
+ this.componentDidUpdate(null);
}
componentWillUnmount() {
diff --git a/tgui/packages/tgui-panel/chat/ChatTabs.jsx b/tgui/packages/tgui-panel/chat/ChatTabs.tsx
similarity index 75%
rename from tgui/packages/tgui-panel/chat/ChatTabs.jsx
rename to tgui/packages/tgui-panel/chat/ChatTabs.tsx
index 9614266daea..d20c5f999ca 100644
--- a/tgui/packages/tgui-panel/chat/ChatTabs.jsx
+++ b/tgui/packages/tgui-panel/chat/ChatTabs.tsx
@@ -5,24 +5,25 @@
*/
import { useDispatch, useSelector } from 'tgui/backend';
-import { Box, Button, Flex, Tabs } from 'tgui-core/components';
+import { Box, Button, Stack, Tabs } from 'tgui-core/components';
import { openChatSettings } from '../settings/actions';
import { addChatPage, changeChatPage } from './actions';
import { selectChatPages, selectCurrentChatPage } from './selectors';
-const UnreadCountWidget = ({ value }) => (
- {Math.min(value, 99)}
-);
+function UnreadCountWidget({ value }: { value: number }) {
+ return {Math.min(value, 99)};
+}
-export const ChatTabs = (props) => {
+export function ChatTabs(props) {
const pages = useSelector(selectChatPages);
const currentPage = useSelector(selectCurrentChatPage);
const dispatch = useDispatch();
+
return (
-
-
-
+
+
+
{pages.map((page) => (
{
))}
-
-
+
+
+
);
-};
+}
diff --git a/tgui/packages/tgui-panel/chat/middleware.ts b/tgui/packages/tgui-panel/chat/middleware.ts
index 8afe64b8119..666d3f090e5 100644
--- a/tgui/packages/tgui-panel/chat/middleware.ts
+++ b/tgui/packages/tgui-panel/chat/middleware.ts
@@ -4,7 +4,7 @@
* @license MIT
*/
-import { Store } from 'common/redux';
+import type { Store } from 'common/redux';
import { storage } from 'common/storage';
import DOMPurify from 'dompurify';
@@ -63,7 +63,7 @@ const loadChatFromStorage = async (store: Store) => {
return;
}
if (messages) {
- for (let message of messages) {
+ for (const message of messages) {
if (message.html) {
message.html = DOMPurify.sanitize(message.html, {
FORBID_TAGS,
@@ -124,23 +124,22 @@ export const chatMiddleware = (store: Store) => {
}
const sequence_count = sequences.length;
- seq_check: if (sequence_count > 0) {
+ if (sequence_count > 0) {
if (sequences_requested.includes(sequence)) {
sequences_requested.splice(sequences_requested.indexOf(sequence), 1);
// if we are receiving a message we requested, we can stop reliability checks
- break seq_check;
- }
-
- // cannot do reliability if we don't have any messages
- const expected_sequence = sequences[sequence_count - 1] + 1;
- if (sequence !== expected_sequence) {
- for (
- let requesting = expected_sequence;
- requesting < sequence;
- requesting++
- ) {
- sequences_requested.push(requesting);
- Byond.sendMessage('chat/resend', requesting);
+ } else {
+ // cannot do reliability if we don't have any messages
+ const expected_sequence = sequences[sequence_count - 1] + 1;
+ if (sequence !== expected_sequence) {
+ for (
+ let requesting = expected_sequence;
+ requesting < sequence;
+ requesting++
+ ) {
+ sequences_requested.push(requesting);
+ Byond.sendMessage('chat/resend', requesting);
+ }
}
}
}
diff --git a/tgui/packages/tgui-panel/chat/model.js b/tgui/packages/tgui-panel/chat/model.js
index cb7b9ab4352..9d08f27ab02 100644
--- a/tgui/packages/tgui-panel/chat/model.js
+++ b/tgui/packages/tgui-panel/chat/model.js
@@ -12,9 +12,9 @@ export const canPageAcceptType = (page, type) =>
type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type];
export const createPage = (obj) => {
- let acceptedTypes = {};
+ const acceptedTypes = {};
- for (let typeDef of MESSAGE_TYPES) {
+ for (const typeDef of MESSAGE_TYPES) {
acceptedTypes[typeDef.type] = !!typeDef.important;
}
@@ -32,7 +32,7 @@ export const createPage = (obj) => {
export const createMainPage = () => {
const acceptedTypes = {};
- for (let typeDef of MESSAGE_TYPES) {
+ for (const typeDef of MESSAGE_TYPES) {
acceptedTypes[typeDef.type] = true;
}
return createPage({
diff --git a/tgui/packages/tgui-panel/chat/reducer.ts b/tgui/packages/tgui-panel/chat/reducer.ts
index dda8f860dd2..bf33554fcea 100644
--- a/tgui/packages/tgui-panel/chat/reducer.ts
+++ b/tgui/packages/tgui-panel/chat/reducer.ts
@@ -18,7 +18,7 @@ import {
updateMessageCount,
} from './actions';
import { canPageAcceptType, createMainPage } from './model';
-import { Page } from './types';
+import type { Page } from './types';
const mainPage = createMainPage();
@@ -42,11 +42,11 @@ export const chatReducer = (state = initialState, action) => {
// Enable any filters that are not explicitly set, that are
// enabled by default on the main page.
// NOTE: This mutates acceptedTypes on the state.
- for (let id of Object.keys(payload.pageById)) {
+ for (const id of Object.keys(payload.pageById)) {
const page = payload.pageById[id];
const filters = page.acceptedTypes;
const defaultFilters = mainPage.acceptedTypes;
- for (let type of Object.keys(defaultFilters)) {
+ for (const type of Object.keys(defaultFilters)) {
if (filters[type] === undefined) {
filters[type] = defaultFilters[type];
}
@@ -55,7 +55,7 @@ export const chatReducer = (state = initialState, action) => {
// Reset page message counts
// NOTE: We are mutably changing the payload on the assumption
// that it is a copy that comes straight from the web storage.
- for (let id of Object.keys(payload.pageById)) {
+ for (const id of Object.keys(payload.pageById)) {
const page = payload.pageById[id];
page.unreadCount = 0;
}
@@ -88,9 +88,9 @@ export const chatReducer = (state = initialState, action) => {
const pages = state.pages.map((id) => state.pageById[id]);
const currentPage = state.pageById[state.currentPageId];
const nextPageById = { ...state.pageById };
- for (let page of pages) {
+ for (const page of pages) {
let unreadCount = 0;
- for (let type of Object.keys(countByType)) {
+ for (const type of Object.keys(countByType)) {
// Message does not belong here
if (!canPageAcceptType(page, type)) {
continue;
diff --git a/tgui/packages/tgui-panel/chat/renderer.jsx b/tgui/packages/tgui-panel/chat/renderer.jsx
index 9d74e134b3a..b2bd78f0fcb 100644
--- a/tgui/packages/tgui-panel/chat/renderer.jsx
+++ b/tgui/packages/tgui-panel/chat/renderer.jsx
@@ -5,13 +5,11 @@
*/
import { createRoot } from 'react-dom/client';
-import { globalStore } from 'tgui/backend';
import { createLogger } from 'tgui/logging';
import { Tooltip } from 'tgui-core/components';
import { EventEmitter } from 'tgui-core/events';
import { classes } from 'tgui-core/react';
-import { selectSettings } from '../settings/selectors';
import {
COMBINE_MAX_MESSAGES,
COMBINE_MAX_TIME_WINDOW,
@@ -64,7 +62,7 @@ const findNearestScrollableParent = (startingNode) => {
const createHighlightNode = (text, color) => {
const node = document.createElement('span');
node.className = 'Chat__highlight';
- node.setAttribute('style', 'background-color:' + color);
+ node.setAttribute('style', `background-color:${color}`);
node.textContent = text;
return node;
};
@@ -95,7 +93,7 @@ const handleImageError = (e) => {
}
const src = node.src;
node.src = null;
- node.src = src + '#' + attempts;
+ node.src = `${src}#${attempts}`;
node.setAttribute('data-reload-n', attempts + 1);
}, IMAGE_RETRY_DELAY);
};
@@ -145,8 +143,7 @@ class ChatRenderer {
const height = node.scrollHeight;
const bottom = node.scrollTop + node.offsetHeight;
const scrollTracking =
- Math.abs(height - bottom) <
- selectSettings(globalStore.getState()).scrollTrackingTolerance ||
+ Math.abs(height - bottom) < SCROLL_TRACKING_TOLERANCE ||
this.lastScrollHeight === 0;
if (scrollTracking !== this.scrollTracking) {
this.scrollTracking = scrollTracking;
@@ -199,7 +196,7 @@ class ChatRenderer {
}
assignStyle(style = {}) {
- for (let key of Object.keys(style)) {
+ for (const key of Object.keys(style)) {
this.rootNode.style.setProperty(key, style[key]);
}
}
@@ -221,24 +218,27 @@ class ChatRenderer {
const lines = String(text)
.split(',')
.map((str) => str.trim())
- .filter(
- (str) =>
- // Must be longer than one character
- str &&
- str.length > 1 &&
- // Must be alphanumeric (with some punctuation)
- (allowedRegex.test(str) ||
- (str.charAt(0) === '/' && str.charAt(str.length - 1) === '/')) &&
- // Reset lastIndex so it does not mess up the next word
- ((allowedRegex.lastIndex = 0) || true),
- );
+ .filter((str) => {
+ // Must be longer than one character
+ if (!str || str.length <= 1) return false;
+
+ // Must be alphanumeric (with some punctuation)
+ const isValidFormat =
+ allowedRegex.test(str) ||
+ (str.charAt(0) === '/' && str.charAt(str.length - 1) === '/');
+
+ // Reset lastIndex so it does not mess up the next word
+ allowedRegex.lastIndex = 0;
+
+ return isValidFormat;
+ });
let highlightWords;
let highlightRegex;
// Nothing to match, reset highlighting
if (lines.length === 0) {
return;
}
- let regexExpressions = [];
+ const regexExpressions = [];
// Organize each highlight entry into regex expressions and words
for (let line of lines) {
// Regex expression syntax is /[exp]/
@@ -261,13 +261,13 @@ class ChatRenderer {
}
}
const regexStr = regexExpressions.join('|');
- const flags = 'g' + (matchCase ? '' : 'i');
+ const flags = `g${matchCase ? '' : 'i'}`;
// We wrap this in a try-catch to ensure that broken regex doesn't break
// the entire chat.
try {
// setting regex overrides matchword
if (regexStr) {
- highlightRegex = new RegExp('(' + regexStr + ')', flags);
+ highlightRegex = new RegExp(`(${regexStr})`, flags);
} else {
const pattern = `${matchWord ? '\\b' : ''}(${highlightWords.join(
'|',
@@ -310,7 +310,7 @@ class ChatRenderer {
// Re-add message nodes
const fragment = document.createDocumentFragment();
let node;
- for (let message of this.messages) {
+ for (const message of this.messages) {
if (canPageAcceptType(page, message.type)) {
node = message.node;
fragment.appendChild(node);
@@ -365,7 +365,7 @@ class ChatRenderer {
const fragment = document.createDocumentFragment();
const countByType = {};
let node;
- for (let payload of batch) {
+ for (const payload of batch) {
const message = createMessage(payload);
// Combine messages
const combinable = this.getCombinableMessage(message);
@@ -401,7 +401,7 @@ class ChatRenderer {
const childNode = nodes[i];
const targetName = childNode.getAttribute('data-component');
// Let's pull out the attibute info we need
- let outputProps = {};
+ const outputProps = {};
for (let j = 0; j < childNode.attributes.length; j++) {
const attribute = childNode.attributes[j];
@@ -412,9 +412,9 @@ class ChatRenderer {
working_value = true;
} else if (working_value === '$false') {
working_value = false;
- } else if (!isNaN(working_value)) {
+ } else if (!Number.isNaN(working_value)) {
const parsed_float = parseFloat(working_value);
- if (!isNaN(parsed_float)) {
+ if (!Number.isNaN(parsed_float)) {
working_value = parsed_float;
}
}
@@ -561,7 +561,7 @@ class ChatRenderer {
);
const messages = this.messages.slice(fromIndex);
// Remove existing nodes
- for (let message of messages) {
+ for (const message of messages) {
message.node = undefined;
}
// Fast clear of the root node
@@ -606,16 +606,16 @@ class ChatRenderer {
for (let i = 0; i < cssRules.length; i++) {
const rule = cssRules[i];
if (rule && typeof rule.cssText === 'string') {
- cssText += rule.cssText + '\n';
+ cssText += `${rule.cssText}\n`;
}
}
}
cssText += 'body, html { background-color: #141414 }\n';
// Compile chat log as HTML text
let messagesHtml = '';
- for (let message of this.visibleMessages) {
+ for (const message of this.visibleMessages) {
if (message.node) {
- messagesHtml += message.node.outerHTML + '\n';
+ messagesHtml += `${message.node.outerHTML}\n`;
}
}
// Create a page
diff --git a/tgui/packages/tgui-panel/chat/replaceInTextNode.js b/tgui/packages/tgui-panel/chat/replaceInTextNode.ts
similarity index 60%
rename from tgui/packages/tgui-panel/chat/replaceInTextNode.js
rename to tgui/packages/tgui-panel/chat/replaceInTextNode.ts
index 7148bfe7aa6..f58ca0fe4fd 100644
--- a/tgui/packages/tgui-panel/chat/replaceInTextNode.js
+++ b/tgui/packages/tgui-panel/chat/replaceInTextNode.ts
@@ -4,36 +4,51 @@
* @license MIT
*/
+type NodeCreator = (text: string) => Node;
+
+type ReplaceInTextNodeParams = {
+ node: Node;
+ regex: RegExp;
+ createNode: NodeCreator;
+ captureAdjust?: (str: string) => string;
+};
+
/**
* Replaces text matching a regular expression with a custom node.
*/
-const regexParseNode = (params) => {
+function regexParseNode(params: ReplaceInTextNodeParams): {
+ nodes: Node[];
+ n: number;
+} {
const { node, regex, createNode, captureAdjust } = params;
const text = node.textContent;
+
+ if (!text || !regex) {
+ return { nodes: [], n: 0 };
+ }
+
+ const nodes: Node[] = [];
const textLength = text.length;
- let nodes;
- let new_node;
- let match;
+ let fragment: Node | undefined;
+ let count = 0;
let lastIndex = 0;
- let fragment;
+ let match: RegExpExecArray | null;
let n = 0;
- let count = 0;
- // eslint-disable-next-line no-cond-assign
- while ((match = regex.exec(text))) {
+ let new_node: Node;
+
+ while (true) {
+ match = regex.exec(text);
+ if (!match) break;
n += 1;
- // Safety check to prevent permanent
- // client crashing
+ // Safety check to prevent permanent client crashing
if (++count > 9999) {
- return {};
+ return { nodes: [], n: 0 };
}
// Lazy init fragment
if (!fragment) {
fragment = document.createDocumentFragment();
}
- // Lazy init nodes
- if (!nodes) {
- nodes = [];
- }
+
const matchText = captureAdjust ? captureAdjust(match[0]) : match[0];
const matchLength = matchText.length;
// If matchText is set to be a substring nested within the original
@@ -59,70 +74,72 @@ const regexParseNode = (params) => {
fragment.appendChild(new_node);
}
// Commit the fragment
- node.parentNode.replaceChild(fragment, node);
+ node.parentNode?.replaceChild(fragment, node);
}
return {
nodes: nodes,
n: n,
};
-};
+}
/**
- * Replace text of a node with custom nades if they match
+ * Replace text of a node with custom nodes if they match
* a regex expression or are in a word list
*/
-export const replaceInTextNode = (regex, words, createNode) => (node) => {
- let nodes;
- let result;
- let n = 0;
+export const replaceInTextNode =
+ (regex: RegExp, words: string[] | null, createNode: NodeCreator) =>
+ (node: Node) => {
+ let nodes;
+ let result;
+ let n = 0;
- if (regex) {
- result = regexParseNode({
- node: node,
- regex: regex,
- createNode: createNode,
- });
- nodes = result.nodes;
- n += result.n;
- }
+ if (regex) {
+ result = regexParseNode({
+ node: node,
+ regex: regex,
+ createNode: createNode,
+ });
+ nodes = result.nodes;
+ n += result.n;
+ }
- if (words) {
- let i = 0;
- let wordRegexStr = '(';
- for (let word of words) {
- // Capture if the word is at the beginning, end, middle,
- // or by itself in a message
- wordRegexStr += `^${word}\\s\\W|\\s\\W${word}\\s\\W|\\s\\W${word}$|^${word}\\s\\W$`;
- // Make sure the last character for the expression is NOT '|'
- if (++i !== words.length) {
- wordRegexStr += '|';
+ if (words) {
+ let i = 0;
+ let wordRegexStr = '(';
+ for (const word of words) {
+ // Capture if the word is at the beginning, end, middle,
+ // or by itself in a message
+ wordRegexStr += `^${word}\\s\\W|\\s\\W${word}\\s\\W|\\s\\W${word}$|^${word}\\s\\W$`;
+ // Make sure the last character for the expression is NOT '|'
+ if (++i !== words.length) {
+ wordRegexStr += '|';
+ }
}
- }
- wordRegexStr += ')';
- const wordRegex = new RegExp(wordRegexStr, 'gi');
- if (regex && nodes) {
- for (let a_node of nodes) {
+ wordRegexStr += ')';
+ const wordRegex = new RegExp(wordRegexStr, 'gi');
+ if (regex && nodes) {
+ for (const a_node of nodes) {
+ result = regexParseNode({
+ node: a_node,
+ regex: wordRegex,
+ createNode: createNode,
+ captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
+ });
+ n += result.n;
+ }
+ } else {
result = regexParseNode({
- node: a_node,
+ node: node,
regex: wordRegex,
createNode: createNode,
captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
});
n += result.n;
}
- } else {
- result = regexParseNode({
- node: node,
- regex: wordRegex,
- createNode: createNode,
- captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
- });
- n += result.n;
}
- }
- return n;
-};
+ return n;
+ };
// Highlight
// --------------------------------------------------------
@@ -130,27 +147,25 @@ export const replaceInTextNode = (regex, words, createNode) => (node) => {
/**
* Default highlight node.
*/
-const createHighlightNode = (text) => {
+function createHighlightNode(text: string): Node {
const node = document.createElement('span');
node.setAttribute('style', 'background-color:#fd4;color:#000');
node.textContent = text;
return node;
-};
+}
/**
* Highlights the text in the node based on the provided regular expression.
- *
- * @param {Node} node Node which you want to process
- * @param {RegExp} regex Regular expression to highlight
- * @param {(text: string) => Node} createNode Highlight node creator
- * @returns {number} Number of matches
*/
-export const highlightNode = (
- node,
- regex,
- words,
- createNode = createHighlightNode,
-) => {
+export function highlightNode(
+ /** Node which you want to process */
+ node: Node,
+ /** Regular expression to highlight */
+ regex: RegExp,
+ /** List of words to highlight */
+ words: string[],
+ createNode: NodeCreator = createHighlightNode,
+): number {
if (!createNode) {
createNode = createHighlightNode;
}
@@ -166,7 +181,7 @@ export const highlightNode = (
}
}
return n;
-};
+}
// Linkify
// --------------------------------------------------------
@@ -176,11 +191,8 @@ const URL_REGEX =
/**
* Highlights the text in the node based on the provided regular expression.
- *
- * @param {Node} node Node which you want to process
- * @returns {number} Number of matches
*/
-export const linkifyNode = (node) => {
+export function linkifyNode(node: Node): number {
let n = 0;
const childNodes = node.childNodes;
for (let i = 0; i < childNodes.length; i++) {
@@ -194,7 +206,7 @@ export const linkifyNode = (node) => {
}
}
return n;
-};
+}
const linkifyTextNode = replaceInTextNode(URL_REGEX, null, (text) => {
const node = document.createElement('a');
diff --git a/tgui/packages/tgui-panel/package.json b/tgui/packages/tgui-panel/package.json
index 093011ddc3d..5697e341e33 100644
--- a/tgui/packages/tgui-panel/package.json
+++ b/tgui/packages/tgui-panel/package.json
@@ -8,7 +8,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
- "tgui-core": "^2.1.1",
+ "tgui-core": "^5.3.2",
"tgui-dev-server": "workspace:*"
},
"devDependencies": {
diff --git a/tgui/packages/tgui-panel/settings/SettingsExperimental.tsx b/tgui/packages/tgui-panel/settings/SettingsExperimental.tsx
index b2014aa8d1d..73936417899 100644
--- a/tgui/packages/tgui-panel/settings/SettingsExperimental.tsx
+++ b/tgui/packages/tgui-panel/settings/SettingsExperimental.tsx
@@ -26,7 +26,8 @@ export function ExperimentalSettings(props) {
maxValue={64}
value={scrollTrackingTolerance}
format={(value) => toFixed(value)}
- onDrag={(e, value) =>
+ tickWhileDragging
+ onChange={(value) =>
dispatch(
updateSettings({
scrollTrackingTolerance: value,
diff --git a/tgui/packages/tgui-panel/settings/SettingsGeneral.tsx b/tgui/packages/tgui-panel/settings/SettingsGeneral.tsx
index 8a3c008c3d6..51dc29ac06e 100644
--- a/tgui/packages/tgui-panel/settings/SettingsGeneral.tsx
+++ b/tgui/packages/tgui-panel/settings/SettingsGeneral.tsx
@@ -114,7 +114,7 @@ export function SettingsGeneral(props) {
+ onChange={(value) =>
dispatch(
updateSettings({
fontFamily: value,
@@ -163,7 +163,8 @@ export function SettingsGeneral(props) {
maxValue={5}
value={lineHeight}
format={(value) => toFixed(value, 2)}
- onDrag={(e, value) =>
+ tickWhileDragging
+ onChange={(value) =>
dispatch(
updateSettings({
lineHeight: value,
diff --git a/tgui/packages/tgui-panel/settings/TextHighlight.tsx b/tgui/packages/tgui-panel/settings/TextHighlight.tsx
index 605b7e1e9d9..e43e54d39cd 100644
--- a/tgui/packages/tgui-panel/settings/TextHighlight.tsx
+++ b/tgui/packages/tgui-panel/settings/TextHighlight.tsx
@@ -157,7 +157,7 @@ function TextHighlightSetting(props) {
monospace
placeholder="#ffffff"
value={highlightColor}
- onInput={(e, value) =>
+ onChange={(value) =>
dispatch(
updateHighlightSetting({
id: id,
@@ -172,7 +172,7 @@ function TextHighlightSetting(props) {
height="3em"
value={highlightText}
placeholder="Put words to highlight here. Separate terms with commas, i.e. (term1, term2, term3)"
- onChange={(e, value) =>
+ onChange={(value) =>
dispatch(
updateHighlightSetting({
id: id,
diff --git a/tgui/packages/tgui-panel/styles/main.scss b/tgui/packages/tgui-panel/styles/main.scss
index 28e255773f4..bb71b9dc56c 100644
--- a/tgui/packages/tgui-panel/styles/main.scss
+++ b/tgui/packages/tgui-panel/styles/main.scss
@@ -13,11 +13,11 @@
@include meta.load-css('~tgui/styles/reset.scss');
// Atomic classes
-@include meta.load-css('~tgui/styles/atomic/candystripe.scss');
-@include meta.load-css('~tgui/styles/atomic/color.scss');
-@include meta.load-css('~tgui/styles/atomic/debug-layout.scss');
-@include meta.load-css('~tgui/styles/atomic/outline.scss');
-@include meta.load-css('~tgui/styles/atomic/text.scss');
+@include meta.load-css('~tgui-core/styles/atomic/candystripe.scss');
+@include meta.load-css('~tgui-core/styles/atomic/color.scss');
+@include meta.load-css('~tgui-core/styles/atomic/debug-layout.scss');
+@include meta.load-css('~tgui-core/styles/atomic/outline.scss');
+@include meta.load-css('~tgui-core/styles/atomic/text.scss');
// Components specific to tgui-panel
@include meta.load-css('./components/Chat.scss');
diff --git a/tgui/packages/tgui-say/package.json b/tgui/packages/tgui-say/package.json
index c8edc5cbd8c..68d1e0c91e0 100644
--- a/tgui/packages/tgui-say/package.json
+++ b/tgui/packages/tgui-say/package.json
@@ -7,7 +7,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
- "tgui-core": "^2.1.1"
+ "tgui-core": "^5.3.2"
},
"devDependencies": {
"@types/react": "^19.1.0",
diff --git a/tgui/packages/tgui-setup/helpers.js b/tgui/packages/tgui-setup/helpers.js
index 4ac03c08318..23b0f124da8 100644
--- a/tgui/packages/tgui-setup/helpers.js
+++ b/tgui/packages/tgui-setup/helpers.js
@@ -1,13 +1,11 @@
-/* eslint-disable */
-
(function () {
// Utility functions
- var hasOwn = Object.prototype.hasOwnProperty;
+ let hasOwn = Object.prototype.hasOwnProperty;
- var assign = function (target) {
- for (var i = 1; i < arguments.length; i++) {
- var source = arguments[i];
- for (var key in source) {
+ let assign = function (target) {
+ for (let i = 1; i < arguments.length; i++) {
+ let source = arguments[i];
+ for (let key in source) {
if (hasOwn.call(source, key)) {
target[key] = source[key];
}
@@ -16,8 +14,8 @@
return target;
};
- var parseMetaTag = function (name) {
- var content = document.getElementById(name).getAttribute('content');
+ let parseMetaTag = function (name) {
+ let content = document.getElementById(name).getAttribute('content');
if (content === '[' + name + ']') {
return null;
}
@@ -27,28 +25,29 @@
// BYOND API object
// ------------------------------------------------------
- var Byond = (window.Byond = {});
+ let Byond = (window.Byond = {});
// Expose inlined metadata
Byond.windowId = parseMetaTag('tgui:windowId');
+ Byond.storageCdn = parseMetaTag('tgui:storagecdn');
// Backwards compatibility
window.__windowId__ = Byond.windowId;
// Blink engine version
Byond.BLINK = (function () {
- var groups = navigator.userAgent.match(/Chrome\/(\d+)\./);
- var majorVersion = groups && groups[1];
+ let groups = navigator.userAgent.match(/Chrome\/(\d+)\./);
+ let majorVersion = groups && groups[1];
return majorVersion ? parseInt(majorVersion, 10) : null;
})();
// Basic checks to detect whether this page runs in BYOND
- var isByond =
+ let isByond =
(Byond.BLINK !== null || window.cef_to_byond) &&
location.hostname === '127.0.0.1' &&
location.search !== '?external';
- //As of BYOND 515 the path doesn't seem to include tmp dir anymore if you're trying to open tgui in external browser and looking why it doesn't work
- //&& location.pathname.indexOf('/tmp') === 0
+ // As of BYOND 515 the path doesn't seem to include tmp dir anymore if you're trying to open tgui in external browser and looking why it doesn't work
+ // && location.pathname.indexOf('/tmp') === 0
// Version constants
Byond.IS_BYOND = isByond;
@@ -60,7 +59,7 @@
Byond.__callbacks__ = [];
// Reviver for BYOND JSON
- var byondJsonReviver = function (key, value) {
+ let byondJsonReviver = function (key, value) {
if (typeof value === 'object' && value !== null && value.__number__) {
return parseFloat(value.__number__);
}
@@ -75,15 +74,15 @@
return;
}
// Build the URL
- var url = (path || '') + '?';
- var i = 0;
+ let url = (path || '') + '?';
+ let i = 0;
if (params) {
- for (var key in params) {
+ for (let key in params) {
if (hasOwn.call(params, key)) {
if (i++ > 0) {
url += '&';
}
- var value = params[key];
+ let value = params[key];
if (value === null || value === undefined) {
value = '';
}
@@ -105,7 +104,7 @@
}
// Send an HTTP request to DreamSeeker's HTTP server.
// Allows sending much bigger payloads.
- var xhr = new XMLHttpRequest();
+ let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
};
@@ -114,8 +113,8 @@
if (!window.Promise) {
throw new Error('Async calls require API level of ES2015 or later.');
}
- var index = Byond.__callbacks__.length;
- var promise = new window.Promise(function (resolve) {
+ let index = Byond.__callbacks__.length;
+ let promise = new window.Promise((resolve) => {
Byond.__callbacks__.push(resolve);
});
Byond.call(
@@ -141,14 +140,14 @@
if (id === null) {
id = '';
}
- var isArray = propName instanceof Array;
- var isSpecific = propName && propName !== '*' && !isArray;
- var promise = Byond.callAsync('winget', {
+ let isArray = propName instanceof Array;
+ let isSpecific = propName && propName !== '*' && !isArray;
+ let promise = Byond.callAsync('winget', {
id: id,
property: (isArray && propName.join(',')) || propName || '*',
});
if (isSpecific) {
- promise = promise.then(function (props) {
+ promise = promise.then((props) => {
return props[propName];
});
}
@@ -161,7 +160,7 @@
} else if (typeof id === 'object') {
return Byond.call('winset', id);
}
- var props = {};
+ let props = {};
if (typeof propName === 'string') {
props[propName] = propValue;
} else {
@@ -180,7 +179,7 @@
};
Byond.sendMessage = function (type, payload) {
- var message =
+ let message =
typeof type === 'string' ? { type: type, payload: payload } : type;
// JSON-encode the payload
if (message.payload !== null && message.payload !== undefined) {
@@ -205,7 +204,7 @@
};
Byond.subscribeTo = function (type, listener) {
- var _listener = function (_type, payload) {
+ let _listener = function (_type, payload) {
if (_type === type) {
listener(payload);
}
@@ -217,45 +216,45 @@
// Asset loaders
// ------------------------------------------------------
- var RETRY_ATTEMPTS = 5;
- var RETRY_WAIT_INITIAL = 500;
- var RETRY_WAIT_INCREMENT = 500;
+ let RETRY_ATTEMPTS = 5;
+ let RETRY_WAIT_INITIAL = 500;
+ let RETRY_WAIT_INCREMENT = 500;
- var loadedAssetByUrl = {};
+ let loadedAssetByUrl = {};
- var isStyleSheetLoaded = function (node, url) {
- var styleSheet = node.sheet;
+ let isStyleSheetLoaded = function (node, url) {
+ let styleSheet = node.sheet;
if (styleSheet) {
return styleSheet.rules.length > 0;
}
return false;
};
- var injectNode = function (node) {
+ let injectNode = function (node) {
if (!document.body) {
- setTimeout(function () {
+ setTimeout(() => {
injectNode(node);
});
return;
}
- var refs = document.body.childNodes;
- var ref = refs[refs.length - 1];
+ let refs = document.body.childNodes;
+ let ref = refs[refs.length - 1];
ref.parentNode.insertBefore(node, ref.nextSibling);
};
- var loadAsset = function (options) {
- var url = options.url;
- var type = options.type;
- var sync = options.sync;
- var attempt = options.attempt || 0;
+ let loadAsset = function (options) {
+ let url = options.url;
+ let type = options.type;
+ let sync = options.sync;
+ let attempt = options.attempt || 0;
if (loadedAssetByUrl[url]) {
return;
}
loadedAssetByUrl[url] = options;
// Generic retry function
- var retry = function () {
+ let retry = function () {
if (attempt >= RETRY_ATTEMPTS) {
- var errorMessage =
+ let errorMessage =
'Error: Failed to load the asset ' +
"'" +
url +
@@ -269,7 +268,7 @@
throw new Error(errorMessage);
}
setTimeout(
- function () {
+ () => {
loadedAssetByUrl[url] = null;
options.attempt += 1;
loadAsset(options);
@@ -279,7 +278,7 @@
};
// JS specific code
if (type === 'js') {
- var node = document.createElement('script');
+ let node = document.createElement('script');
node.type = 'text/javascript';
node.crossOrigin = 'anonymous';
node.src = url;
@@ -299,7 +298,7 @@
}
// CSS specific code
if (type === 'css') {
- var node = document.createElement('link');
+ let node = document.createElement('link');
node.type = 'text/css';
node.rel = 'stylesheet';
node.crossOrigin = 'anonymous';
@@ -309,7 +308,7 @@
if (!sync) {
node.media = 'only x';
}
- var removeNodeAndRetry = function () {
+ let removeNodeAndRetry = function () {
node.parentNode.removeChild(node);
node = null;
retry();
@@ -347,10 +346,10 @@
if (window.navigator.msSaveBlob) {
window.navigator.msSaveBlob(blob, filename);
} else if (window.showSaveFilePicker) {
- var accept = {};
+ let accept = {};
accept[blob.type] = [ext];
- var opts = {
+ let opts = {
suggestedName: filename,
types: [
{
@@ -362,15 +361,15 @@
window
.showSaveFilePicker(opts)
- .then(function (file) {
+ .then((file) => {
return file.createWritable();
})
- .then(function (file) {
- return file.write(blob).then(function () {
+ .then((file) => {
+ return file.write(blob).then(() => {
return file.close();
});
})
- .catch(function () {});
+ .catch(() => {});
}
};
@@ -384,7 +383,7 @@
window.onerror = function (msg, url, line, col, error) {
window.onerror.errorCount = (window.onerror.errorCount || 0) + 1;
// Proper stacktrace
- var stack = error && error.stack;
+ let stack = error && error.stack;
// Ghetto stacktrace
if (!stack) {
stack = msg + '\n at ' + url + ':' + line;
@@ -396,8 +395,8 @@ window.onerror = function (msg, url, line, col, error) {
stack = window.__augmentStack__(stack, error);
// Print error to the page
if (Byond.strictMode) {
- var errorRoot = document.getElementById('FatalError');
- var errorStack = document.getElementById('FatalError__stack');
+ let errorRoot = document.getElementById('FatalError');
+ let errorStack = document.getElementById('FatalError__stack');
if (errorRoot) {
errorRoot.className = 'FatalError FatalError--visible';
if (window.onerror.__stack__) {
@@ -405,11 +404,11 @@ window.onerror = function (msg, url, line, col, error) {
} else {
window.onerror.__stack__ = stack;
}
- var textProp = 'textContent';
+ let textProp = 'textContent';
errorStack[textProp] = window.onerror.__stack__;
}
// Set window geometry
- var setFatalErrorGeometry = function () {
+ let setFatalErrorGeometry = function () {
Byond.winset(Byond.windowId, {
titlebar: true,
'is-visible': true,
@@ -444,7 +443,7 @@ window.onerror = function (msg, url, line, col, error) {
// Catch unhandled promise rejections
window.onunhandledrejection = function (e) {
- var msg = 'UnhandledRejection';
+ let msg = 'UnhandledRejection';
if (e.reason) {
msg += ': ' + (e.reason.message || e.reason.description || e.reason);
if (e.reason.stack) {
@@ -470,10 +469,10 @@ window.update = function (rawMessage) {
return;
}
// Parse the message
- var message = Byond.parseJson(rawMessage);
+ let message = Byond.parseJson(rawMessage);
// Notify listeners
- var listeners = window.update.listeners;
- for (var i = 0; i < listeners.length; i++) {
+ let listeners = window.update.listeners;
+ for (let i = 0; i < listeners.length; i++) {
listeners[i](message.type, message.payload);
}
};
@@ -487,25 +486,25 @@ window.update.flushQueue = function (listener) {
if (window.update.queueActive) {
window.update.queueActive = false;
if (window.setTimeout) {
- window.setTimeout(function () {
+ window.setTimeout(() => {
window.update.queue = [];
}, 0);
}
}
// Process queued messages on provided listener
- var queue = window.update.queue;
- for (var i = 0; i < queue.length; i++) {
- var message = Byond.parseJson(queue[i]);
+ let queue = window.update.queue;
+ for (let i = 0; i < queue.length; i++) {
+ let message = Byond.parseJson(queue[i]);
listener(message.type, message.payload);
}
};
window.replaceHtml = function (inline_html) {
- var children = document.body.childNodes;
+ let children = document.body.childNodes;
- for (var i = 0; i < children.length; i++) {
- if (children[i].nodeValue == ' tgui:inline-html-start ') {
- while (children[i].nodeValue != ' tgui:inline-html-end ') {
+ for (let i = 0; i < children.length; i++) {
+ if (children[i].nodeValue === ' tgui:inline-html-start ') {
+ while (children[i].nodeValue !== ' tgui:inline-html-end ') {
children[i].remove();
}
children[i].remove();
diff --git a/tgui/packages/tgui/interfaces/ColorMatrixEditor.tsx b/tgui/packages/tgui/interfaces/ColorMatrixEditor.tsx
index c988b0328e5..cbcd2ec2d49 100644
--- a/tgui/packages/tgui/interfaces/ColorMatrixEditor.tsx
+++ b/tgui/packages/tgui/interfaces/ColorMatrixEditor.tsx
@@ -47,7 +47,7 @@ export const ColorMatrixEditor = (props) => {
step={0.01}
width="50px"
format={(value) => toFixed(value, 2)}
- onDrag={(value) => {
+ onChange={(value) => {
const retColor = currentColor;
retColor[row * 4 + col] = value;
act('transition_color', {
diff --git a/tgui/packages/tgui/interfaces/CommandReport.tsx b/tgui/packages/tgui/interfaces/CommandReport.tsx
index 942d5cffaa5..0e81920fa05 100644
--- a/tgui/packages/tgui/interfaces/CommandReport.tsx
+++ b/tgui/packages/tgui/interfaces/CommandReport.tsx
@@ -84,7 +84,7 @@ const CentComName = (props) => {
mt={1}
value={command_name}
placeholder={command_name}
- onChange={(_, value) =>
+ onChange={(value) =>
act('update_command_name', {
updated_name: value,
})
@@ -114,7 +114,7 @@ const SubHeader = (props) => {
mt={1}
value={subheader}
placeholder={subheader}
- onChange={(_, value) =>
+ onChange={(value) =>
act('set_subheader', {
new_subheader: value,
})
@@ -215,7 +215,7 @@ const ReportText = (props) => {
{searchBarVisible && (
- on_selected(filteredItems[selected])}
+ onChange={onSearch}
+ placeholder="Search..."
+ value={searchQuery}
/>
)}
@@ -166,72 +169,57 @@ export const ListInputModal = (props: ListInputModalProps) => {
);
};
+interface ListDisplayProps {
+ filteredItems: string[];
+ onClick: (itemIndex: number) => void;
+ onDoubleClick: (entry: string) => void;
+ onFocusSearch: () => void;
+ searchBarVisible: boolean;
+ selected: number;
+}
+
/**
* Displays the list of selectable items.
* If a search query is provided, filters the items.
*/
-const ListDisplay = (props) => {
- const { act } = useBackend();
- const { filteredItems, onClick, onFocusSearch, searchBarVisible, selected } =
- props;
+const ListDisplay = (props: ListDisplayProps) => {
+ const {
+ filteredItems,
+ onClick,
+ onDoubleClick,
+ onFocusSearch,
+ searchBarVisible,
+ selected,
+ } = props;
return (
- {filteredItems.map((item, index) => {
- return (
-
+ ))}
);
};
-
-/**
- * Renders a search bar input.
- * Closing the bar defaults input to an empty string.
- */
-const SearchBar = (props) => {
- const { act } = useBackend();
- const { filteredItems, onSearch, searchQuery, selected } = props;
-
- return (
- {
- event.preventDefault();
- act('submit', { entry: filteredItems[selected] });
- }}
- onInput={(_, value) => onSearch(value)}
- placeholder="Search..."
- value={searchQuery}
- />
- );
-};
diff --git a/tgui/packages/tgui/interfaces/NumberInputModal.tsx b/tgui/packages/tgui/interfaces/NumberInputModal.tsx
index 8c2dbc5d0b7..79a71acc95b 100644
--- a/tgui/packages/tgui/interfaces/NumberInputModal.tsx
+++ b/tgui/packages/tgui/interfaces/NumberInputModal.tsx
@@ -7,34 +7,39 @@ import {
Stack,
} from 'tgui-core/components';
import { isEscape, KEY } from 'tgui-core/keys';
+import type { BooleanLike } from 'tgui-core/react';
import { useBackend } from '../backend';
import { Window } from '../layouts';
import { InputButtons } from './common/InputButtons';
import { Loader } from './common/Loader';
-type NumberInputData = {
+type Data = {
init_value: number;
- large_buttons: boolean;
- max_value: number | null;
+ large_buttons: BooleanLike;
+ max_value: number;
message: string;
- min_value: number | null;
+ min_value: number;
+ round_value: BooleanLike;
timeout: number;
title: string;
- round_value: boolean;
};
-export const NumberInputModal = (props) => {
- const { act, data } = useBackend();
- const { init_value, large_buttons, message = '', timeout, title } = data;
- const [input, setInput] = useState(init_value);
+export function NumberInputModal(props) {
+ const { act, data } = useBackend();
+ const {
+ init_value,
+ large_buttons,
+ max_value = 10000,
+ message = '',
+ min_value = 0,
+ round_value,
+ timeout,
+ title,
+ } = data;
- const setValue = (value: number) => {
- if (value === input) {
- return;
- }
- setInput(value);
- };
+ const [value, setValue] = useState(init_value);
+ const [isValid, setIsValid] = useState(true);
// Dynamically changes the window height based on the message.
const windowHeight =
@@ -42,88 +47,89 @@ export const NumberInputModal = (props) => {
(message.length > 30 ? Math.ceil(message.length / 3) : 0) +
(message.length && large_buttons ? 5 : 0);
+ function handleKeyDown(event: React.KeyboardEvent) {
+ if (event.key === KEY.Enter && isValid) {
+ act('submit', { entry: value });
+ }
+ if (isEscape(event.key)) {
+ act('cancel');
+ }
+ }
+
return (
{timeout && }
- {
- if (event.key === KEY.Enter) {
- act('submit', { entry: input });
- }
- if (isEscape(event.key)) {
- act('cancel');
- }
- }}
- >
+
{message}
-
+
+
+ setValue(min_value ?? 0)}
+ tooltip={min_value ? `Min (${min_value})` : 'Min'}
+ />
+
+
+
+ setValue((value) => value - 1)}
+ />
+
+
+
+
+
+
+
+ = max_value}
+ onClick={() => setValue((value) => value + 1)}
+ />
+
+
+
+ setValue(max_value ?? 10000)}
+ tooltip={max_value ? `Max (${max_value})` : 'Max'}
+ />
+
+
+ setValue(init_value ?? 0)}
+ tooltip={init_value ? `Reset (${init_value})` : 'Reset'}
+ />
+
+
-
+
);
-};
-
-/** Gets the user input and invalidates if there's a constraint. */
-const InputArea = (props) => {
- const { act, data } = useBackend();
- const { min_value, max_value, init_value, round_value } = data;
- const { input, onClick, onChange, onBlur } = props;
-
- return (
-
-
- onClick(min_value)}
- tooltip={min_value ? `Min (${min_value})` : 'Min'}
- />
-
-
- onChange(value)}
- onBlur={(_, value) => onBlur(value)}
- onEnter={(_, value) => act('submit', { entry: value })}
- value={input}
- />
-
-
- onClick(max_value)}
- tooltip={max_value ? `Max (${max_value})` : 'Max'}
- />
-
-
- onClick(init_value)}
- tooltip={init_value ? `Reset (${init_value})` : 'Reset'}
- />
-
-
- );
-};
+}
diff --git a/tgui/packages/tgui/interfaces/Processor.tsx b/tgui/packages/tgui/interfaces/Processor.tsx
index 1369fc1177d..c43c26ca9c7 100644
--- a/tgui/packages/tgui/interfaces/Processor.tsx
+++ b/tgui/packages/tgui/interfaces/Processor.tsx
@@ -55,7 +55,7 @@ export const Processor = (props: any, context: any) => {
fillValue={sheet_rate}
step={1}
stepPixelSize={5}
- onDrag={(e, value) =>
+ onChange={(value) =>
act('set_rate', {
sheets: value,
})
diff --git a/tgui/packages/tgui/interfaces/Signaler.tsx b/tgui/packages/tgui/interfaces/Signaler.tsx
index 5591bd2ef82..6aad5a7f31a 100644
--- a/tgui/packages/tgui/interfaces/Signaler.tsx
+++ b/tgui/packages/tgui/interfaces/Signaler.tsx
@@ -143,7 +143,8 @@ const CodeContent = (props: any) => {
maxValue={100}
value={code}
width="80px"
- onDrag={(value: number) => act('adjust', { code: -code + value })}
+ tickWhileDragging
+ onChange={(value: number) => act('adjust', { code: -code + value })}
/>
act('adjust', { code: 1 })} />
{
)}
{{accountname}}
{authorization && (
- {
- if (Number.isInteger(Number(value))) {
- setAccount(Number(value));
- }
+ onChange={(value) => {
+ setAccount(value);
}}
/>
)}
diff --git a/tgui/packages/tgui/interfaces/TextInputModal.tsx b/tgui/packages/tgui/interfaces/TextInputModal.tsx
index 38c709a3aa7..a5f80700135 100644
--- a/tgui/packages/tgui/interfaces/TextInputModal.tsx
+++ b/tgui/packages/tgui/interfaces/TextInputModal.tsx
@@ -1,6 +1,7 @@
-import { KeyboardEvent, useState } from 'react';
+import { useState } from 'react';
import { Box, Section, Stack, TextArea } from 'tgui-core/components';
-import { isEscape, KEY } from 'tgui-core/keys';
+import { isEscape } from 'tgui-core/keys';
+import { KEY } from 'tgui-core/keys';
import { useBackend } from '../backend';
import { Window } from '../layouts';
@@ -38,6 +39,7 @@ export const TextInputModal = (props) => {
} = data;
const [input, setInput] = useState(placeholder || '');
+
const onType = (value: string) => {
if (value === input) {
return;
@@ -56,29 +58,35 @@ export const TextInputModal = (props) => {
(visualMultiline ? 75 : 0) +
(message.length && large_buttons ? 5 : 0);
+ function handleKeyDown(event: React.KeyboardEvent) {
+ if (event.key === KEY.Enter && (!visualMultiline || !event.shiftKey)) {
+ act('submit', { entry: input });
+ }
+ if (isEscape(event.key)) {
+ act('cancel');
+ }
+ }
return (
{timeout && }
- {
- if (
- event.key === KEY.Enter &&
- (!visualMultiline || !event.shiftKey)
- ) {
- act('submit', { entry: input });
- }
- if (isEscape(event.key)) {
- act('cancel');
- }
- }}
- >
+
);
};
-
-/** Gets the user input and invalidates if there's a constraint. */
-const InputArea = (props: {
- input: string;
- onType: (value: string) => void;
-}) => {
- const { act, data } = useBackend();
- const { max_length, multiline } = data;
- const { input, onType } = props;
-
- const visualMultiline = multiline || input.length >= 30;
-
- return (
-