From 304ddaf0bd5fbb85cda0b55f2c86345d7c39af33 Mon Sep 17 00:00:00 2001 From: S34N <12197162+S34NW@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:08:02 +0000 Subject: [PATCH 01/34] initial stuff (broken) --- code/__DEFINES/keybindings_defines.dm | 1 + code/__DEFINES/preferences_defines.dm | 45 +-- code/__DEFINES/speech_channels.dm | 10 + code/_globalvars/lists/keybindings_lists.dm | 1 + code/datums/keybindings/admin_keybinds.dm | 24 +- code/datums/keybindings/client.dm | 32 -- .../keybindings/communication_keybinds.dm | 22 ++ code/modules/admin/admin_verbs.dm | 2 + code/modules/client/client_procs.dm | 8 +- code/modules/client/preference/preferences.dm | 1 + .../client/preference/preferences_toggles.dm | 8 + code/modules/input/keybindings_procs.dm | 46 +++ code/modules/mob/mob_login_base.dm | 1 + .../mob/new_player/new_player_login.dm | 1 + code/modules/tgui_input/say_modal/modal.dm | 125 ++++++ code/modules/tgui_input/say_modal/speech.dm | 100 +++++ code/modules/tgui_input/say_modal/typing.dm | 48 +++ interface/skin.dmf | 19 + paradise.dme | 5 + tgui/packages/common/keys.ts | 39 ++ tgui/packages/common/timer.js | 37 -- tgui/packages/common/timer.ts | 68 ++++ .../packages/tgui-say/ChannelIterator.test.ts | 47 +++ tgui/packages/tgui-say/ChannelIterator.ts | 50 +++ tgui/packages/tgui-say/ChatHistory.test.ts | 50 +++ tgui/packages/tgui-say/ChatHistory.ts | 59 +++ tgui/packages/tgui-say/TguiSay.tsx | 356 ++++++++++++++++++ tgui/packages/tgui-say/constants.ts | 32 ++ .../packages/tgui-say/fonts/VT323-Regular.ttf | Bin 0 -> 147320 bytes tgui/packages/tgui-say/helpers.ts | 46 +++ tgui/packages/tgui-say/index.tsx | 19 + tgui/packages/tgui-say/package.json | 11 + tgui/packages/tgui-say/styles/button.scss | 29 ++ tgui/packages/tgui-say/styles/colors.scss | 40 ++ tgui/packages/tgui-say/styles/content.scss | 14 + tgui/packages/tgui-say/styles/dragzone.scss | 39 ++ tgui/packages/tgui-say/styles/main.scss | 70 ++++ tgui/packages/tgui-say/styles/textarea.scss | 11 + tgui/packages/tgui-say/styles/window.scss | 29 ++ tgui/packages/tgui-say/timers.ts | 19 + tgui/packages/tgui/components/TextArea.js | 45 ++- tgui/public/tgui-panel.bundle.js | 194 +++++----- tgui/public/tgui-say.bundle.css | 1 + tgui/public/tgui-say.bundle.js | 43 +++ tgui/public/tgui.bundle.js | 150 ++++---- tgui/webpack.config.js | 4 + tgui/yarn.lock | 11 + 47 files changed, 1721 insertions(+), 291 deletions(-) create mode 100644 code/__DEFINES/speech_channels.dm create mode 100644 code/datums/keybindings/communication_keybinds.dm create mode 100644 code/modules/tgui_input/say_modal/modal.dm create mode 100644 code/modules/tgui_input/say_modal/speech.dm create mode 100644 code/modules/tgui_input/say_modal/typing.dm create mode 100644 tgui/packages/common/keys.ts delete mode 100644 tgui/packages/common/timer.js create mode 100644 tgui/packages/common/timer.ts create mode 100644 tgui/packages/tgui-say/ChannelIterator.test.ts create mode 100644 tgui/packages/tgui-say/ChannelIterator.ts create mode 100644 tgui/packages/tgui-say/ChatHistory.test.ts create mode 100644 tgui/packages/tgui-say/ChatHistory.ts create mode 100644 tgui/packages/tgui-say/TguiSay.tsx create mode 100644 tgui/packages/tgui-say/constants.ts create mode 100644 tgui/packages/tgui-say/fonts/VT323-Regular.ttf create mode 100644 tgui/packages/tgui-say/helpers.ts create mode 100644 tgui/packages/tgui-say/index.tsx create mode 100644 tgui/packages/tgui-say/package.json create mode 100644 tgui/packages/tgui-say/styles/button.scss create mode 100644 tgui/packages/tgui-say/styles/colors.scss create mode 100644 tgui/packages/tgui-say/styles/content.scss create mode 100644 tgui/packages/tgui-say/styles/dragzone.scss create mode 100644 tgui/packages/tgui-say/styles/main.scss create mode 100644 tgui/packages/tgui-say/styles/textarea.scss create mode 100644 tgui/packages/tgui-say/styles/window.scss create mode 100644 tgui/packages/tgui-say/timers.ts create mode 100644 tgui/public/tgui-say.bundle.css create mode 100644 tgui/public/tgui-say.bundle.js diff --git a/code/__DEFINES/keybindings_defines.dm b/code/__DEFINES/keybindings_defines.dm index cc1c80d83c56..04debd1a2447 100644 --- a/code/__DEFINES/keybindings_defines.dm +++ b/code/__DEFINES/keybindings_defines.dm @@ -14,6 +14,7 @@ #define KB_CATEGORY_EMOTE_SILICON 14 #define KB_CATEGORY_EMOTE_ANIMAL 15 #define KB_CATEGORY_EMOTE_CUSTOM 16 +#define KB_CATEGORY_COMMUNICATION 17 #define KB_CATEGORY_UNSORTED 1000 ///Max length of a keypress command before it's considered to be a forged packet/bogus command diff --git a/code/__DEFINES/preferences_defines.dm b/code/__DEFINES/preferences_defines.dm index 8272092d330d..8e2698680f8b 100644 --- a/code/__DEFINES/preferences_defines.dm +++ b/code/__DEFINES/preferences_defines.dm @@ -43,29 +43,30 @@ #define TOGGLES_DEFAULT (PREFTOGGLE_CHAT_OOC|PREFTOGGLE_CHAT_DEAD|PREFTOGGLE_CHAT_GHOSTEARS|PREFTOGGLE_CHAT_GHOSTSIGHT|PREFTOGGLE_CHAT_PRAYER|PREFTOGGLE_CHAT_RADIO|PREFTOGGLE_CHAT_DEBUGLOGS|PREFTOGGLE_CHAT_LOOC|PREFTOGGLE_MEMBER_PUBLIC|PREFTOGGLE_DONATOR_PUBLIC|PREFTOGGLE_AMBIENT_OCCLUSION|PREFTOGGLE_CHAT_GHOSTPDA|PREFTOGGLE_NUMPAD_TARGET) // toggles_2 variables. These MUST be prefixed with PREFTOGGLE_2 -#define PREFTOGGLE_2_RANDOMSLOT (1<<0) // 1 -#define PREFTOGGLE_2_FANCYUI (1<<1) // 2 -#define PREFTOGGLE_2_ITEMATTACK (1<<2) // 4 -#define PREFTOGGLE_2_WINDOWFLASHING (1<<3) // 8 -#define PREFTOGGLE_2_ANON (1<<4) // 16 -#define PREFTOGGLE_2_AFKWATCH (1<<5) // 32 -#define PREFTOGGLE_2_RUNECHAT (1<<6) // 64 -#define PREFTOGGLE_2_DEATHMESSAGE (1<<7) // 128 -#define PREFTOGGLE_2_EMOTE_BUBBLE (1<<8) // 256 +#define PREFTOGGLE_2_RANDOMSLOT (1<<0) // 1 +#define PREFTOGGLE_2_FANCYUI (1<<1) // 2 +#define PREFTOGGLE_2_ITEMATTACK (1<<2) // 4 +#define PREFTOGGLE_2_WINDOWFLASHING (1<<3) // 8 +#define PREFTOGGLE_2_ANON (1<<4) // 16 +#define PREFTOGGLE_2_AFKWATCH (1<<5) // 32 +#define PREFTOGGLE_2_RUNECHAT (1<<6) // 64 +#define PREFTOGGLE_2_DEATHMESSAGE (1<<7) // 128 +#define PREFTOGGLE_2_EMOTE_BUBBLE (1<<8) // 256 // Yes I know this being an "enable to disable" is misleading, but it avoids having to tweak all existing pref entries -#define PREFTOGGLE_2_REVERB_DISABLE (1<<9) // 512 -#define PREFTOGGLE_2_FORCE_WHITE_RUNECHAT (1<<10) // 1024 -#define PREFTOGGLE_2_SIMPLE_STAT_PANEL (1<<11) // 2048 -#define PREFTOGGLE_2_SEE_ITEM_OUTLINES (1<<12) // 4096 -#define PREFTOGGLE_2_HIDE_ITEM_TOOLTIPS (1<<13) // 8192 -#define PREFTOGGLE_2_THOUGHT_BUBBLE (1<<14) // 16384 -#define PREFTOGGLE_2_MC_TABS (1<<15) // 32768 -#define PREFTOGGLE_2_DANCE_DISCO (1<<16) // 65536 -#define PREFTOGGLE_2_MOD_ACTIVATION_METHOD (1<<17) // 131072 -#define PREFTOGGLE_2_PARALLAX_IN_DARKNESS (1<<18) // 262144 -#define PREFTOGGLE_2_DISABLE_TGUI_LISTS (1<<19) // 524288 - -#define TOGGLES_2_TOTAL 1048575 // If you add or remove a preference toggle above, make sure you update this define with the total value of the toggles combined. +#define PREFTOGGLE_2_REVERB_DISABLE (1<<9) // 512 +#define PREFTOGGLE_2_FORCE_WHITE_RUNECHAT (1<<10) // 1024 +#define PREFTOGGLE_2_SIMPLE_STAT_PANEL (1<<11) // 2048 +#define PREFTOGGLE_2_SEE_ITEM_OUTLINES (1<<12) // 4096 +#define PREFTOGGLE_2_HIDE_ITEM_TOOLTIPS (1<<13) // 8192 +#define PREFTOGGLE_2_THOUGHT_BUBBLE (1<<14) // 16384 +#define PREFTOGGLE_2_MC_TABS (1<<15) // 32768 +#define PREFTOGGLE_2_DANCE_DISCO (1<<16) // 65536 +#define PREFTOGGLE_2_MOD_ACTIVATION_METHOD (1<<17) // 131072 +#define PREFTOGGLE_2_PARALLAX_IN_DARKNESS (1<<18) // 262144 +#define PREFTOGGLE_2_DISABLE_TGUI_LISTS (1<<19) // 524288 +#define PREFTOGGLE_2_ENABLE_TGUI_SAY_LIGHT_MODE (1<<20) // 1048576 + +#define TOGGLES_2_TOTAL 2097151 // If you add or remove a preference toggle above, make sure you update this define with the total value of the toggles combined. #define TOGGLES_2_DEFAULT (PREFTOGGLE_2_FANCYUI|PREFTOGGLE_2_ITEMATTACK|PREFTOGGLE_2_WINDOWFLASHING|PREFTOGGLE_2_RUNECHAT|PREFTOGGLE_2_DEATHMESSAGE|PREFTOGGLE_2_EMOTE_BUBBLE|PREFTOGGLE_2_SEE_ITEM_OUTLINES|PREFTOGGLE_2_THOUGHT_BUBBLE|PREFTOGGLE_2_DANCE_DISCO|PREFTOGGLE_2_MOD_ACTIVATION_METHOD) diff --git a/code/__DEFINES/speech_channels.dm b/code/__DEFINES/speech_channels.dm new file mode 100644 index 000000000000..4d308dc5ef54 --- /dev/null +++ b/code/__DEFINES/speech_channels.dm @@ -0,0 +1,10 @@ +// Used to direct channels to speak into. +#define SAY_CHANNEL "Say" +#define RADIO_CHANNEL "Radio" +#define ME_CHANNEL "Me" +#define OOC_CHANNEL "OOC" +#define LOOC_CHANNEL "LOOC" +#define MENTOR_CHANNEL "msay" +#define ADMIN_CHANNEL "asay" +#define DSAY_CHANNEL "dsay" + diff --git a/code/_globalvars/lists/keybindings_lists.dm b/code/_globalvars/lists/keybindings_lists.dm index 350c5c3ec75e..836c1a0cbb5d 100644 --- a/code/_globalvars/lists/keybindings_lists.dm +++ b/code/_globalvars/lists/keybindings_lists.dm @@ -2,6 +2,7 @@ GLOBAL_LIST_EMPTY(keybindings) GLOBAL_LIST_INIT(keybindings_groups, list( "Movement" = KB_CATEGORY_MOVEMENT, + "Communication" = KB_CATEGORY_COMMUNICATION, "Living" = KB_CATEGORY_LIVING, "General" = KB_CATEGORY_MOB, "General Emote" = KB_CATEGORY_EMOTE_GENERIC, diff --git a/code/datums/keybindings/admin_keybinds.dm b/code/datums/keybindings/admin_keybinds.dm index 19cb750a2d71..1b76ac6702a8 100644 --- a/code/datums/keybindings/admin_keybinds.dm +++ b/code/datums/keybindings/admin_keybinds.dm @@ -21,20 +21,20 @@ SSdebugview.start_processing(C) /datum/keybinding/admin/msay - name = "Msay" + name = MENTOR_CHANNEL keys = list("F4") -/datum/keybinding/admin/msay/down(client/C) - . = ..() - C.get_mentor_say() - /datum/keybinding/admin/asay - name = "Asay" + name = ADMIN_CHANNEL keys = list("F5") -/datum/keybinding/admin/asay/down(client/C) +/datum/keybinding/admin/dsay + name = "Dsay" + keys = list("F10") + +/datum/keybinding/admin/dsay/down(client/C) . = ..() - C.get_admin_say() + C.get_dead_say() /datum/keybinding/admin/aghost name = "Aghost" @@ -68,11 +68,3 @@ /datum/keybinding/admin/invisimin/down(client/C) . = ..() C.invisimin() - -/datum/keybinding/admin/dsay - name = "Dsay" - keys = list("F10") - -/datum/keybinding/admin/dsay/down(client/C) - . = ..() - C.get_dead_say() diff --git a/code/datums/keybindings/client.dm b/code/datums/keybindings/client.dm index b9f6cc79a62b..23a89404931d 100644 --- a/code/datums/keybindings/client.dm +++ b/code/datums/keybindings/client.dm @@ -9,38 +9,6 @@ . = ..() C.adminhelp() -/datum/keybinding/client/ooc - name = "OOC" - keys = list("O") - -/datum/keybinding/client/ooc/down(client/C) - . = ..() - C.ooc() - -/datum/keybinding/client/looc - name = "Local OOC" - keys = list("L") - -/datum/keybinding/client/looc/down(client/C) - . = ..() - C.looc() - -/datum/keybinding/client/say - name = "Say" - keys = list("T") - -/datum/keybinding/client/say/down(client/C) - . = ..() - C.mob.say_wrapper() - -/datum/keybinding/client/me - name = "Me" - keys = list("M") - -/datum/keybinding/client/me/down(client/C) - . = ..() - C.mob.me_wrapper() - /datum/keybinding/client/toggle_min_hud name = "Toggle Minimal HUD" keys = list("F12") diff --git a/code/datums/keybindings/communication_keybinds.dm b/code/datums/keybindings/communication_keybinds.dm new file mode 100644 index 000000000000..4eabbef89c92 --- /dev/null +++ b/code/datums/keybindings/communication_keybinds.dm @@ -0,0 +1,22 @@ +/datum/keybinding/client/communication + category = KB_CATEGORY_COMMUNICATION + +/datum/keybinding/client/communication/ooc + name = OOC_CHANNEL + keys = list("O") + +/datum/keybinding/client/communication/looc + name = LOOC_CHANNEL + keys = list("L") + +/datum/keybinding/client/communication/say + name = SAY_CHANNEL + keys = list("T") + +/datum/keybinding/client/communication/me + name = ME_CHANNEL + keys = list("M") + +/datum/keybinding/client/communication/radio + name = RADIO_CHANNEL + keys = list("Y") diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index caa9ed538908..bce09a4b2d6b 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -660,6 +660,7 @@ GLOBAL_LIST_INIT(view_runtimes_verbs, list( GLOB.de_mentors += ckey deadmin() verbs += /client/proc/readmin + update_special_keybinds() to_chat(src, "You are now a normal player.") SSblackbox.record_feedback("tally", "admin_verb", 1, "De-admin") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! @@ -749,6 +750,7 @@ GLOBAL_LIST_INIT(view_runtimes_verbs, list( var/client/C = GLOB.directory[ckey] D.associate(C) + update_special_keybinds() message_admins("[key_name_admin(usr)] re-adminned themselves.") log_admin("[key_name(usr)] re-adminned themselves.") GLOB.de_admins -= ckey diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index a2323c887485..cfaf204ab4db 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -254,7 +254,10 @@ /////////// /client/New(TopicData) var/tdata = TopicData //save this for later use - tgui_panel = new(src) + + tgui_panel = new(src, "browseroutput") + tgui_say = new(src, "tgui_say") + TopicData = null //Prevent calls to client.Topic from connect if(connection != "seeker") //Invalid connection type. @@ -392,6 +395,9 @@ // Initialize tgui panel tgui_panel.initialize() + // Initialize tgui say + tgui_say.initialize() + check_ip_intel() send_resources() SSchangelog.UpdatePlayerChangelogButton(src) diff --git a/code/modules/client/preference/preferences.dm b/code/modules/client/preference/preferences.dm index bb6df8b3aa86..03ccb4818692 100644 --- a/code/modules/client/preference/preferences.dm +++ b/code/modules/client/preference/preferences.dm @@ -728,6 +728,7 @@ GLOBAL_LIST_INIT(special_role_times, list( //minimum age (in days) for accounts LAZYADD(keybindings[key], kb) parent?.update_active_keybindings() + parent?.update_special_keybinds() return keybindings /datum/preferences/proc/capture_keybinding(mob/user, datum/keybinding/KB, old) diff --git a/code/modules/client/preference/preferences_toggles.dm b/code/modules/client/preference/preferences_toggles.dm index 1e9129bc774c..b8bdd9add2cf 100644 --- a/code/modules/client/preference/preferences_toggles.dm +++ b/code/modules/client/preference/preferences_toggles.dm @@ -234,6 +234,14 @@ prefs.save_preferences(src) to_chat(src, "You will [(prefs.toggles2 & PREFTOGGLE_2_DISABLE_TGUI_LISTS) ? "no longer" : "now"] use TGUI Input Lists.") +/client/verb/toggle_say_light_mode() + set name = "Toggle TGUI Say Light Mode" + set category = "Preferences" + set desc = "Switches TGUI Say between light and dark modes" + prefs.toggles2 ^= PREFTOGGLE_2_ENABLE_TGUI_SAY_LIGHT_MODE + prefs.save_preferences(src) + to_chat(src, "You will [(prefs.toggles2 & PREFTOGGLE_2_ENABLE_TGUI_SAY_LIGHT_MODE) ? "no longer" : "now"] use light mode for TGUI Say.") + /client/verb/Toggle_disco() //to toggle off the disco machine locally, in case it gets too annoying set name = "Hear/Silence Dance Machine" set category = "Preferences" diff --git a/code/modules/input/keybindings_procs.dm b/code/modules/input/keybindings_procs.dm index 8ae07693672c..a7425baccfe7 100644 --- a/code/modules/input/keybindings_procs.dm +++ b/code/modules/input/keybindings_procs.dm @@ -20,3 +20,49 @@ active_keybindings[linked_bind.binded_to] += list(linked_bind) return active_keybindings + +/** + * Updates the keybinds for special keys + * + * Handles adding macros for the keys that need it + * At the time of writing this, communication(OOC, Say, IC, LOOC, ASAY, MSAY) require macros + */ +/client/proc/update_special_keybinds() + if(!length(prefs?.keybindings) || !mob) + return + for(var/key in prefs.keybindings) + for(var/kb in prefs.keybindings[key]) + switch("[kb]") + if(SAY_CHANNEL) + var/say = tgui_say_create_open_command(SAY_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[say]") + if(RADIO_CHANNEL) + var/radio = tgui_say_create_open_command(RADIO_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[radio]") + if(ME_CHANNEL) + var/me = tgui_say_create_open_command(ME_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[me]") + if(OOC_CHANNEL) + var/ooc = tgui_say_create_open_command(OOC_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[ooc]") + if(LOOC_CHANNEL) + var/looc = tgui_say_create_open_command(LOOC_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[looc]") + if(ADMIN_CHANNEL) + if(check_rights(R_ADMIN, FALSE)) + var/asay = tgui_say_create_open_command(ADMIN_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[asay]") + else + winset(src, "default-[key]", "parent=default;name=[key];command=") + if(DSAY_CHANNEL) + if(check_rights(R_ADMIN, FALSE)) + var/dsay = tgui_say_create_open_command(DSAY_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[dsay]") + else + winset(src, "default-[key]", "parent=default;name=[key];command=") + if(MENTOR_CHANNEL) + if(check_rights(R_MENTOR, FALSE)) + var/msay = tgui_say_create_open_command(MENTOR_CHANNEL) + winset(src, "default-[key]", "parent=default;name=[key];command=[msay]") + else + winset(src, "default-[key]", "parent=default;name=[key];command=") diff --git a/code/modules/mob/mob_login_base.dm b/code/modules/mob/mob_login_base.dm index 69bbf42c3b85..abce946f18dd 100644 --- a/code/modules/mob/mob_login_base.dm +++ b/code/modules/mob/mob_login_base.dm @@ -67,6 +67,7 @@ client.verbs += /client/proc/readmin client.update_active_keybindings() + client.update_special_keybinds() //HUD updates (antag hud, etc) //readd this mob's HUDs (antag, med, etc) diff --git a/code/modules/mob/new_player/new_player_login.dm b/code/modules/mob/new_player/new_player_login.dm index 5d90cac4337b..3067c1c934e3 100644 --- a/code/modules/mob/new_player/new_player_login.dm +++ b/code/modules/mob/new_player/new_player_login.dm @@ -31,6 +31,7 @@ client.playtitlemusic() client.update_active_keybindings() + client.update_special_keybinds() //Overflow rerouting, if set, forces players to be moved to a different server once a player cap is reached. Less rough than a pure kick. if(GLOB.configuration.overflow.reroute_cap && GLOB.configuration.overflow.overflow_server_location) diff --git a/code/modules/tgui_input/say_modal/modal.dm b/code/modules/tgui_input/say_modal/modal.dm new file mode 100644 index 000000000000..145e925e1f88 --- /dev/null +++ b/code/modules/tgui_input/say_modal/modal.dm @@ -0,0 +1,125 @@ +/** Assigned say modal of the client */ +/client/var/datum/tgui_say/tgui_say + +/** + * Creates a JSON encoded message to open TGUI say modals properly. + * + * Arguments: + * channel - The channel to open the modal in. + * Returns: + * string - A JSON encoded message to open the modal. + */ +/client/proc/tgui_say_create_open_command(channel) + var/message = TGUI_CREATE_MESSAGE("open", list( + channel = channel, + )) + return "\".output tgui_say.browser:update [message]\"" + +/** + * The tgui say modal. This initializes an input window which hides until + * the user presses one of the speech hotkeys. Once something is entered, it will + * delegate the speech to the proper channel. + */ +/datum/tgui_say + /// The user who opened the window + var/client/client + /// Injury phrases to blurt out + var/list/hurt_phrases = list("GACK!", "GLORF!", "OOF!", "AUGH!", "OW!", "URGH!", "HRNK!") + /// Max message length + var/max_length = MAX_MESSAGE_LEN + /// The modal window + var/datum/tgui_window/window + /// Boolean for whether the tgui_say was opened by the user. + var/window_open + +/** Creates the new input window to exist in the background. */ +/datum/tgui_say/New(client/client, id) + src.client = client + window = new(client, id) + winset(client, "tgui_say", "size=1,1;is-visible=0;") + window.subscribe(src, PROC_REF(on_message)) + window.is_browser = TRUE + +/** + * After a brief period, injects the scripts into + * the window to listen for open commands. + */ +/datum/tgui_say/proc/initialize() + set waitfor = FALSE + // Sleep to defer initialization to after client constructor + sleep(3 SECONDS) + window.initialize( + fancy = TRUE, + inline_css = file("tgui/public/tgui-say.bundle.css"), + inline_js = file("tgui/public/tgui-say.bundle.js"), + ); + +/** + * Ensures nothing funny is going on window load. + * Minimizes the window, sets max length, closes all + * typing and thinking indicators. This is triggered + * as soon as the window sends the "ready" message. + */ +/datum/tgui_say/proc/load() + window_open = FALSE + winset(client, "tgui_say", "pos=848,500;size=231,30;is-visible=0;") + window.send_message("props", list( + lightMode = (client.prefs?.toggles2 & PREFTOGGLE_2_ENABLE_TGUI_SAY_LIGHT_MODE), + maxLength = max_length, + )) + stop_thinking() + return TRUE + +/** + * Sets the window as "opened" server side, though it is already + * visible to the user. We do this to set local vars & + * start typing (if enabled and in an IC channel). + * + * Arguments: + * payload - A list containing the channel the window was opened in. + */ +/datum/tgui_say/proc/open(payload) + if(!payload?["channel"]) + CRASH("No channel provided to an open TGUI-Say") + window_open = TRUE + if(payload["channel"] != (OOC_CHANNEL || LOOC_CHANNEL || ADMIN_CHANNEL)) + start_thinking() + return TRUE + +/** + * Closes the window serverside. Closes any open chat bubbles + * regardless of preference. + */ +/datum/tgui_say/proc/close() + window_open = FALSE + stop_thinking() + +/** + * The equivalent of ui_act, this waits on messages from the window + * and delegates actions. + */ +/datum/tgui_say/proc/on_message(type, payload) + if(type == "ready") + load() + return TRUE + if(type == "open") + open(payload) + return TRUE + if(type == "close") + close() + return TRUE + if(type == "thinking") + if(payload["visible"] == TRUE) + start_thinking() + return TRUE + if(payload["visible"] == FALSE) + stop_thinking() + return TRUE + return FALSE + if(type == "typing") + start_typing() + return TRUE + if(type == "entry" || type == "force") + handle_entry(type, payload) + return TRUE + return FALSE diff --git a/code/modules/tgui_input/say_modal/speech.dm b/code/modules/tgui_input/say_modal/speech.dm new file mode 100644 index 000000000000..62c52d957648 --- /dev/null +++ b/code/modules/tgui_input/say_modal/speech.dm @@ -0,0 +1,100 @@ +/** + * Alters text when players are injured. + * Adds text, trims left and right side + * + * Arguments: + * payload - a string list containing entry & channel + * Returns: + * string - the altered entry + */ +/datum/tgui_say/proc/alter_entry(payload) + var/entry = payload["entry"] + /// No OOC leaks + if(!entry || payload["channel"] == OOC_CHANNEL || payload["channel"] == ME_CHANNEL) + return pick(hurt_phrases) + /// Random trimming for larger sentences + if(length(entry) > 50) + entry = trim(entry, rand(40, 50)) + else + /// Otherwise limit trim to just last letter + if(length(entry) > 1) + entry = trim(entry, length(entry)) + return entry + "-" + pick(hurt_phrases) + +/** + * Delegates the speech to the proper channel. + * + * Arguments: + * entry - the text to broadcast + * channel - the channel to broadcast in + * Returns: + * boolean - on success or failure + */ +/datum/tgui_say/proc/delegate_speech(entry, channel) + switch(channel) + if(SAY_CHANNEL) + client.mob.say_verb(entry) + return TRUE + if(RADIO_CHANNEL) + client.mob.say_verb(";" + entry) + return TRUE + if(ME_CHANNEL) + client.mob.me_verb(entry) + return TRUE + if(OOC_CHANNEL) + client.ooc(entry) + return TRUE + if(LOOC_CHANNEL) + client.looc(entry) + return TRUE + if(ADMIN_CHANNEL) + client.cmd_admin_say(entry) + return TRUE + if(MENTOR_CHANNEL) + client.cmd_mentor_say(entry) + return TRUE + if(DSAY_CHANNEL) + client.dsay(entry) + return TRUE + return FALSE + +/** + * Force say handler. + * Sends a message to the say modal to send its current value. + */ +/datum/tgui_say/proc/force_say() + window.send_message("force") + stop_typing() + +/** + * Makes the player force say what's in their current input box. + */ +/mob/living/carbon/human/proc/force_say() + if(stat != CONSCIOUS || !client?.tgui_say?.window_open) + return FALSE + client.tgui_say.force_say() + +/** + * Handles text entry and forced speech. + * + * Arguments: + * type - a string "entry" or "force" based on how this function is called + * payload - a string list containing entry & channel + * Returns: + * boolean - success or failure + */ +/datum/tgui_say/proc/handle_entry(type, payload) + if(!payload?["channel"] || !payload["entry"]) + CRASH("[usr] entered in a null payload to the chat window.") + if(length(payload["entry"]) > max_length) + CRASH("[usr] has entered more characters than allowed into a TGUI-Say") + if(type == "entry") + delegate_speech(payload["entry"], payload["channel"]) + return TRUE + if(type == "force") + var/target_channel = payload["channel"] + if(target_channel == ME_CHANNEL || target_channel == OOC_CHANNEL) + target_channel = SAY_CHANNEL // No ooc leaks + delegate_speech(alter_entry(payload), target_channel) + return TRUE + return FALSE diff --git a/code/modules/tgui_input/say_modal/typing.dm b/code/modules/tgui_input/say_modal/typing.dm new file mode 100644 index 000000000000..211fb01969ec --- /dev/null +++ b/code/modules/tgui_input/say_modal/typing.dm @@ -0,0 +1,48 @@ +/mob/set_stat(new_stat) + . = ..() + if(.) + set_typing_indicator(FALSE) + +/mob/Logout() + set_typing_indicator(FALSE) + return ..() + +/** Sets the mob as "thinking" - with indicator and variable thinking_IC */ +/datum/tgui_say/proc/start_thinking() + if(!client?.mob || !window_open) + return FALSE + /// Special exemptions + if(isabductor(client.mob)) + return FALSE + client.mob.set_typing_indicator(TRUE, TRUE) + +/** Removes typing/thinking indicators and flags the mob as not thinking */ +/datum/tgui_say/proc/stop_thinking() + if(!client?.mob) + return FALSE + client.mob.set_typing_indicator(FALSE) + +/** + * Handles the user typing. After a brief period of inactivity, + * signals the client mob to revert to the "thinking" icon. + */ +/datum/tgui_say/proc/start_typing() + if(!client?.mob) + return FALSE + client.mob.set_typing_indicator(FALSE) + if(!window_open) + return FALSE + client.mob.set_typing_indicator(TRUE) + addtimer(CALLBACK(src, PROC_REF(stop_typing)), 5 SECONDS, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_STOPPABLE) + +/** + * Callback to remove the typing indicator after a brief period of inactivity. + * If the user was typing IC, the thinking indicator is shown. + */ +/datum/tgui_say/proc/stop_typing() + if(!client?.mob) + return FALSE + client.mob.set_typing_indicator(FALSE) + if(!window_open) + return FALSE + client.mob.set_typing_indicator(TRUE, TRUE) diff --git a/interface/skin.dmf b/interface/skin.dmf index 4f6d2b1d7a5d..3c87aee45da2 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -380,3 +380,22 @@ window "infowindow" is-default = true highlight-color = #00aa00 on-show = ".winset \"rpane.infob.is-checked=true?rpane.rpanewindow.top=infowindow:rpane.rpanewindow.top=\"" + +window "tgui_say" + elem "tgui_say" + type = MAIN + pos = 848,500 + size = 231x30 + anchor1 = 50,50 + anchor2 = 50,50 + is-visible = false + saved-params = "" + statusbar = false + can-minimize = false + elem "browser" + type = BROWSER + pos = 0,0 + size = 231x30 + anchor1 = 0,0 + anchor2 = 0,0 + saved-params = "" diff --git a/paradise.dme b/paradise.dme index bb26fe9f3eaf..a45f9361724c 100644 --- a/paradise.dme +++ b/paradise.dme @@ -100,6 +100,7 @@ #include "code\__DEFINES\shuttle_defines.dm" #include "code\__DEFINES\sight.dm" #include "code\__DEFINES\sound_defines.dm" +#include "code\__DEFINES\speech_channels.dm" #include "code\__DEFINES\spell_defines.dm" #include "code\__DEFINES\stat.dm" #include "code\__DEFINES\station_defines.dm" @@ -480,6 +481,7 @@ #include "code\datums\keybindings\ai_keybinds.dm" #include "code\datums\keybindings\carbon_keybinds.dm" #include "code\datums\keybindings\client.dm" +#include "code\datums\keybindings\communication_keybinds.dm" #include "code\datums\keybindings\emote_keybinds.dm" #include "code\datums\keybindings\human_keybinds.dm" #include "code\datums\keybindings\living_keybinds.dm" @@ -2808,6 +2810,9 @@ #include "code\modules\tgui\tgui_panel\tgui_panel_external.dm" #include "code\modules\tgui\tgui_panel\tgui_panel_message.dm" #include "code\modules\tgui\tgui_panel\to_chat.dm" +#include "code\modules\tgui_input\say_modal\modal.dm" +#include "code\modules\tgui_input\say_modal\speech.dm" +#include "code\modules\tgui_input\say_modal\typing.dm" #include "code\modules\tooltip\tooltip.dm" #include "code\modules\unit_tests\_unit_tests.dm" #include "code\modules\vehicle\ambulance.dm" diff --git a/tgui/packages/common/keys.ts b/tgui/packages/common/keys.ts new file mode 100644 index 000000000000..61b79992b486 --- /dev/null +++ b/tgui/packages/common/keys.ts @@ -0,0 +1,39 @@ +/** + * ### Key codes. + * event.keyCode is deprecated, use this reference instead. + * + * Handles modifier keys (Shift, Alt, Control) and arrow keys. + * + * For alphabetical keys, use the actual character (e.g. 'a') instead of the key code. + * + * Something isn't here that you want? Just add it: + * @url https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + * @usage + * ```ts + * import { KEY } from 'tgui/common/keys'; + * + * if (event.key === KEY.Enter) { + * // do something + * } + * ``` + */ +export enum KEY { + Alt = 'Alt', + Backspace = 'Backspace', + Control = 'Control', + Delete = 'Delete', + Down = 'Down', + End = 'End', + Enter = 'Enter', + Escape = 'Esc', + Home = 'Home', + Insert = 'Insert', + Left = 'Left', + PageDown = 'PageDown', + PageUp = 'PageUp', + Right = 'Right', + Shift = 'Shift', + Space = ' ', + Tab = 'Tab', + Up = 'Up', +} diff --git a/tgui/packages/common/timer.js b/tgui/packages/common/timer.js deleted file mode 100644 index 0b1c68199e9f..000000000000 --- a/tgui/packages/common/timer.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -/** - * Returns a function, that, as long as it continues to be invoked, will - * not be triggered. The function will be called after it stops being - * called for N milliseconds. If `immediate` is passed, trigger the - * function on the leading edge, instead of the trailing. - */ -export const debounce = (fn, time, immediate = false) => { - let timeout; - return (...args) => { - const later = () => { - timeout = null; - if (!immediate) { - fn(...args); - } - }; - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, time); - if (callNow) { - fn(...args); - } - }; -}; - -/** - * Suspends an asynchronous function for N milliseconds. - * - * @param {number} time - */ -export const sleep = (time) => - new Promise((resolve) => setTimeout(resolve, time)); diff --git a/tgui/packages/common/timer.ts b/tgui/packages/common/timer.ts new file mode 100644 index 000000000000..21c433110104 --- /dev/null +++ b/tgui/packages/common/timer.ts @@ -0,0 +1,68 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +/** + * Returns a function, that, as long as it continues to be invoked, will + * not be triggered. The function will be called after it stops being + * called for N milliseconds. If `immediate` is passed, trigger the + * function on the leading edge, instead of the trailing. + */ +export const debounce = any>( + fn: F, + time: number, + immediate = false +): ((...args: Parameters) => void) => { + let timeout: ReturnType | null; + return (...args: Parameters) => { + const later = () => { + timeout = null; + if (!immediate) { + fn(...args); + } + }; + const callNow = immediate && !timeout; + clearTimeout(timeout!); + timeout = setTimeout(later, time); + if (callNow) { + fn(...args); + } + }; +}; + +/** + * Suspends an asynchronous function for N milliseconds. + * + * @param {number} time + */ +export const sleep = (time: number): Promise => + new Promise((resolve) => setTimeout(resolve, time)); + +/** + * Returns a function, that, when invoked, will only be triggered at most once + * during a given window of time. + */ +export const throttle = any>( + fn: F, + time: number +): ((...args: Parameters) => void) => { + let previouslyRun: number | null, + queuedToRun: ReturnType | null; + return function invokeFn(...args: Parameters) { + const now = Date.now(); + if (queuedToRun) { + clearTimeout(queuedToRun); + } + if (!previouslyRun || now - previouslyRun >= time) { + fn.apply(null, args); + previouslyRun = now; + } else { + queuedToRun = setTimeout( + () => invokeFn(...args), + time - (now - (previouslyRun ?? 0)) + ); + } + }; +}; diff --git a/tgui/packages/tgui-say/ChannelIterator.test.ts b/tgui/packages/tgui-say/ChannelIterator.test.ts new file mode 100644 index 000000000000..15e9812e702e --- /dev/null +++ b/tgui/packages/tgui-say/ChannelIterator.test.ts @@ -0,0 +1,47 @@ +import { ChannelIterator } from './ChannelIterator'; + +describe('ChannelIterator', () => { + let channelIterator: ChannelIterator; + + beforeEach(() => { + channelIterator = new ChannelIterator(); + }); + + it('should cycle through channels properly', () => { + expect(channelIterator.current()).toBe('Say'); + expect(channelIterator.next()).toBe('Radio'); + expect(channelIterator.next()).toBe('Me'); + expect(channelIterator.next()).toBe('OOC'); + expect(channelIterator.next()).toBe('Say'); // Admin is blacklisted so it should be skipped + }); + + it('should set a channel properly', () => { + channelIterator.set('OOC'); + expect(channelIterator.current()).toBe('OOC'); + }); + + it('should return true when current channel is "Say"', () => { + channelIterator.set('Say'); + expect(channelIterator.isSay()).toBe(true); + }); + + it('should return false when current channel is not "Say"', () => { + channelIterator.set('Radio'); + expect(channelIterator.isSay()).toBe(false); + }); + + it('should return true when current channel is visible', () => { + channelIterator.set('Say'); + expect(channelIterator.isVisible()).toBe(true); + }); + + it('should return false when current channel is not visible', () => { + channelIterator.set('OOC'); + expect(channelIterator.isVisible()).toBe(false); + }); + + it('should not leak a message from a blacklisted channel', () => { + channelIterator.set('Admin'); + expect(channelIterator.next()).toBe('Admin'); + }); +}); diff --git a/tgui/packages/tgui-say/ChannelIterator.ts b/tgui/packages/tgui-say/ChannelIterator.ts new file mode 100644 index 000000000000..136806927e95 --- /dev/null +++ b/tgui/packages/tgui-say/ChannelIterator.ts @@ -0,0 +1,50 @@ +export type Channel = 'Say' | 'Radio' | 'Me' | 'OOC' | 'Admin'; + +/** + * ### ChannelIterator + * Cycles a predefined list of channels, + * skipping over blacklisted ones, + * and providing methods to manage and query the current channel. + */ +export class ChannelIterator { + private index: number = 0; + private readonly channels: Channel[] = ['Say', 'Radio', 'Me', 'OOC', 'Admin']; + private readonly blacklist: Channel[] = ['Admin']; + private readonly quiet: Channel[] = ['OOC', 'Admin']; + + public next(): Channel { + if (this.blacklist.includes(this.channels[this.index])) { + return this.channels[this.index]; + } + + for (let index = 1; index <= this.channels.length; index++) { + let nextIndex = (this.index + index) % this.channels.length; + if (!this.blacklist.includes(this.channels[nextIndex])) { + this.index = nextIndex; + break; + } + } + + return this.channels[this.index]; + } + + public set(channel: Channel): void { + this.index = this.channels.indexOf(channel) || 0; + } + + public current(): Channel { + return this.channels[this.index]; + } + + public isSay(): boolean { + return this.channels[this.index] === 'Say'; + } + + public isVisible(): boolean { + return !this.quiet.includes(this.channels[this.index]); + } + + public reset(): void { + this.index = 0; + } +} diff --git a/tgui/packages/tgui-say/ChatHistory.test.ts b/tgui/packages/tgui-say/ChatHistory.test.ts new file mode 100644 index 000000000000..c6d8c1c2e27c --- /dev/null +++ b/tgui/packages/tgui-say/ChatHistory.test.ts @@ -0,0 +1,50 @@ +import { ChatHistory } from './ChatHistory'; + +describe('ChatHistory', () => { + let chatHistory: ChatHistory; + + beforeEach(() => { + chatHistory = new ChatHistory(); + }); + + it('should add a message to the history', () => { + chatHistory.add('Hello'); + expect(chatHistory.getOlderMessage()).toEqual('Hello'); + }); + + it('should retrieve older and newer messages', () => { + chatHistory.add('Hello'); + chatHistory.add('World'); + expect(chatHistory.getOlderMessage()).toEqual('World'); + expect(chatHistory.getOlderMessage()).toEqual('Hello'); + expect(chatHistory.getNewerMessage()).toEqual('World'); + expect(chatHistory.getNewerMessage()).toBeNull(); + expect(chatHistory.getOlderMessage()).toEqual('World'); + }); + + it('should limit the history to 5 messages', () => { + for (let i = 1; i <= 6; i++) { + chatHistory.add(`Message ${i}`); + } + + expect(chatHistory.getOlderMessage()).toEqual('Message 6'); + for (let i = 5; i >= 2; i--) { + expect(chatHistory.getOlderMessage()).toEqual(`Message ${i}`); + } + expect(chatHistory.getOlderMessage()).toBeNull(); + }); + + it('should handle temp message correctly', () => { + chatHistory.saveTemp('Temp message'); + expect(chatHistory.getTemp()).toEqual('Temp message'); + expect(chatHistory.getTemp()).toBeNull(); + }); + + it('should reset correctly', () => { + chatHistory.add('Hello'); + chatHistory.getOlderMessage(); + chatHistory.reset(); + expect(chatHistory.isAtLatest()).toBe(true); + expect(chatHistory.getOlderMessage()).toEqual('Hello'); + }); +}); diff --git a/tgui/packages/tgui-say/ChatHistory.ts b/tgui/packages/tgui-say/ChatHistory.ts new file mode 100644 index 000000000000..b5490b1887f4 --- /dev/null +++ b/tgui/packages/tgui-say/ChatHistory.ts @@ -0,0 +1,59 @@ +/** + * ### ChatHistory + * A class to manage a chat history, + * maintaining a maximum of five messages and supporting navigation, + * temporary message storage, and query operations. + */ +export class ChatHistory { + private messages: string[] = []; + private index: number = -1; // Initialize index at -1 + private temp: string | null = null; + + public add(message: string): void { + this.messages.unshift(message); + this.index = -1; // Reset index + if (this.messages.length > 5) { + this.messages.pop(); + } + } + + public getIndex(): number { + return this.index + 1; + } + + public getOlderMessage(): string | null { + if (this.messages.length === 0 || this.index >= this.messages.length - 1) { + return null; + } + this.index++; + return this.messages[this.index]; + } + + public getNewerMessage(): string | null { + if (this.index <= 0) { + this.index = -1; + return null; + } + this.index--; + return this.messages[this.index]; + } + + public isAtLatest(): boolean { + return this.index === -1; + } + + public saveTemp(message: string): void { + this.temp = message; + } + + public getTemp(): string | null { + const temp = this.temp; + this.temp = null; + return temp; + } + + public reset(): void { + this.index = -1; + this.temp = null; + } +} diff --git a/tgui/packages/tgui-say/TguiSay.tsx b/tgui/packages/tgui-say/TguiSay.tsx new file mode 100644 index 000000000000..f57e7692a217 --- /dev/null +++ b/tgui/packages/tgui-say/TguiSay.tsx @@ -0,0 +1,356 @@ +import { Channel, ChannelIterator } from './ChannelIterator'; +import { ChatHistory } from './ChatHistory'; +import { Component, createRef, InfernoKeyboardEvent, RefObject } from 'inferno'; +import { LINE_LENGTHS, RADIO_PREFIXES, WINDOW_SIZES } from './constants'; +import { byondMessages } from './timers'; +import { dragStartHandler } from 'tgui/drag'; +import { windowOpen, windowClose, windowSet } from './helpers'; +import { BooleanLike } from 'common/react'; +import { KEY } from 'common/keys'; + +type ByondOpen = { + channel: Channel; +}; + +type ByondProps = { + maxLength: number; + lightMode: BooleanLike; +}; + +type State = { + buttonContent: string | number; + size: WINDOW_SIZES; +}; + +const CHANNEL_REGEX = /^:\w\s/; + +export class TguiSay extends Component<{}, State> { + private channelIterator: ChannelIterator; + private chatHistory: ChatHistory; + private currentPrefix: keyof typeof RADIO_PREFIXES | null; + private innerRef: RefObject; + private lightMode: boolean; + private maxLength: number; + private messages: typeof byondMessages; + // eslint-disable-next-line react/state-in-constructor + state: State; + + constructor(props: never) { + super(props); + + this.channelIterator = new ChannelIterator(); + this.chatHistory = new ChatHistory(); + this.currentPrefix = null; + this.innerRef = createRef(); + this.lightMode = false; + this.maxLength = 1024; + this.messages = byondMessages; + this.state = { + buttonContent: '', + size: WINDOW_SIZES.small, + }; + + this.handleArrowKeys = this.handleArrowKeys.bind(this); + this.handleBackspaceDelete = this.handleBackspaceDelete.bind(this); + this.handleClose = this.handleClose.bind(this); + this.handleEnter = this.handleEnter.bind(this); + this.handleForceSay = this.handleForceSay.bind(this); + this.handleIncrementChannel = this.handleIncrementChannel.bind(this); + this.handleInput = this.handleInput.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleOpen = this.handleOpen.bind(this); + this.handleProps = this.handleProps.bind(this); + this.reset = this.reset.bind(this); + this.setSize = this.setSize.bind(this); + this.setValue = this.setValue.bind(this); + } + + componentDidMount() { + Byond.subscribeTo('props', this.handleProps); + Byond.subscribeTo('force', this.handleForceSay); + Byond.subscribeTo('open', this.handleOpen); + } + + handleArrowKeys(direction: KEY.Up | KEY.Down) { + const currentValue = this.innerRef.current?.value; + + if (direction === KEY.Up) { + if (this.chatHistory.isAtLatest() && currentValue) { + // Save current message to temp history if at the most recent message + this.chatHistory.saveTemp(currentValue); + } + // Try to get the previous message, fall back to the current value if none + const prevMessage = this.chatHistory.getOlderMessage(); + + if (prevMessage) { + this.setState({ buttonContent: this.chatHistory.getIndex() }); + this.setSize(prevMessage.length); + this.setValue(prevMessage); + } + } else { + const nextMessage = + this.chatHistory.getNewerMessage() || this.chatHistory.getTemp() || ''; + + const buttonContent = this.chatHistory.isAtLatest() + ? this.channelIterator.current() + : this.chatHistory.getIndex(); + + this.setState({ buttonContent }); + this.setSize(nextMessage.length); + this.setValue(nextMessage); + } + } + + handleBackspaceDelete() { + const typed = this.innerRef.current?.value; + + // User is on a chat history message + if (!this.chatHistory.isAtLatest()) { + this.chatHistory.reset(); + this.setState({ + buttonContent: this.currentPrefix ?? this.channelIterator.current(), + }); + // Empty input, resets the channel + } else if ( + !!this.currentPrefix && + this.channelIterator.isSay() && + typed?.length === 0 + ) { + this.currentPrefix = null; + this.setState({ buttonContent: this.channelIterator.current() }); + } + + this.setSize(typed?.length); + } + + handleClose() { + const current = this.innerRef.current; + + if (current) { + current.blur(); + } + + this.reset(); + this.chatHistory.reset(); + this.channelIterator.reset(); + this.currentPrefix = null; + windowClose(); + } + + handleEnter() { + const prefix = this.currentPrefix ?? ''; + const value = this.innerRef.current?.value; + + if (value?.length && value.length < this.maxLength) { + this.chatHistory.add(value); + Byond.sendMessage('entry', { + channel: this.channelIterator.current(), + entry: this.channelIterator.isSay() ? prefix + value : value, + }); + } + + this.handleClose(); + } + + handleForceSay() { + const currentValue = this.innerRef.current?.value; + // Only force say if we're on a visible channel and have typed something + if (!currentValue || !this.channelIterator.isVisible()) return; + + const prefix = this.currentPrefix ?? ''; + const grunt = this.channelIterator.isSay() + ? prefix + currentValue + : currentValue; + + this.messages.forceSayMsg(grunt); + this.reset(); + } + + handleIncrementChannel() { + // Binary talk is a special case, tell byond to show thinking indicators + if (this.channelIterator.isSay() && this.currentPrefix === ':b ') { + this.messages.channelIncrementMsg(true); + } + + this.currentPrefix = null; + + this.channelIterator.next(); + + // If we've looped onto a quiet channel, tell byond to hide thinking indicators + if (!this.channelIterator.isVisible()) { + this.messages.channelIncrementMsg(false); + } + + this.setState({ buttonContent: this.channelIterator.current() }); + } + + handleInput() { + const typed = this.innerRef.current?.value; + + // If we're typing, send the message + if (this.channelIterator.isVisible() && this.currentPrefix !== ':b ') { + this.messages.typingMsg(); + } + + this.setSize(typed?.length); + + // Is there a value? Is it long enough to be a prefix? + if (!typed || typed.length < 3) { + return; + } + + if (!CHANNEL_REGEX.test(typed)) { + return; + } + + // Is it a valid prefix? + const prefix = typed + .slice(0, 3) + ?.toLowerCase() as keyof typeof RADIO_PREFIXES; + if (!RADIO_PREFIXES[prefix] || prefix === this.currentPrefix) { + return; + } + + // If we're in binary, hide the thinking indicator + if (prefix === ':b ') { + Byond.sendMessage('thinking', { visible: false }); + } + + this.channelIterator.set('Say'); + this.currentPrefix = prefix; + this.setState({ buttonContent: RADIO_PREFIXES[prefix] }); + this.setValue(typed.slice(3)); + } + + handleKeyDown(event: InfernoKeyboardEvent) { + switch (event.key) { + case KEY.Up: + case KEY.Down: + event.preventDefault(); + this.handleArrowKeys(event.key); + break; + + case KEY.Delete: + case KEY.Backspace: + this.handleBackspaceDelete(); + break; + + case KEY.Enter: + event.preventDefault(); + this.handleEnter(); + break; + + case KEY.Tab: + event.preventDefault(); + this.handleIncrementChannel(); + break; + + case KEY.Escape: + this.handleClose(); + break; + } + } + + handleOpen = (data: ByondOpen) => { + setTimeout(() => { + this.innerRef.current?.focus(); + }, 0); + + const { channel } = data; + // Catches the case where the modal is already open + if (this.channelIterator.isSay()) { + this.channelIterator.set(channel); + } + this.setState({ buttonContent: this.channelIterator.current() }); + + windowOpen(this.channelIterator.current()); + }; + + handleProps = (data: ByondProps) => { + const { maxLength, lightMode } = data; + this.maxLength = maxLength; + this.lightMode = !!lightMode; + }; + + reset() { + this.setValue(''); + this.setSize(); + this.setState({ + buttonContent: this.channelIterator.current(), + }); + } + + setSize(length = 0) { + let newSize: WINDOW_SIZES; + + if (length > LINE_LENGTHS.medium) { + newSize = WINDOW_SIZES.large; + } else if (length <= LINE_LENGTHS.medium && length > LINE_LENGTHS.small) { + newSize = WINDOW_SIZES.medium; + } else { + newSize = WINDOW_SIZES.small; + } + + if (this.state.size !== newSize) { + this.setState({ size: newSize }); + windowSet(newSize); + } + } + + setValue(value: string) { + const textArea = this.innerRef.current; + if (textArea) { + textArea.value = value; + } + } + + render() { + const theme = + (this.lightMode && 'lightMode') || + (this.currentPrefix && RADIO_PREFIXES[this.currentPrefix]) || + this.channelIterator.current(); + + return ( +
+ +
+ +
+ +