From 7a7920f29ec4d3e8d714866fd00b71177fb287d2 Mon Sep 17 00:00:00 2001 From: ShadowLarkens Date: Sun, 11 Aug 2024 12:48:06 -0700 Subject: [PATCH] TGUI Say (#16160) * TGUI Say * Add icon_ref_map.json to make tgui-dev-server stop screaming * Update tgui.bundle.js * bundle recompile --------- Co-authored-by: Heroman3003 <31296024+Heroman3003@users.noreply.github.com> Co-authored-by: Heroman --- .vscode/tasks.json | 16 + code/__defines/assert.dm | 13 + code/__defines/speech_channels.dm | 9 + code/__defines/traits/_traits.dm | 122 ++++++ code/__defines/traits/declarations.dm | 11 + code/__defines/traits/sources.dm | 7 + code/_global_vars/traits/_traits.dm | 12 + code/_helpers/traits.dm | 45 ++ code/datums/datum.dm | 2 + .../asset_cache/assets/icon_ref_map.dm | 28 ++ code/modules/asset_cache/assets/tgui.dm | 4 +- code/modules/client/client procs.dm | 6 + .../preference_setup/global/setting_datums.dm | 21 +- code/modules/client/verbs/typing.dm | 77 ++++ code/modules/mob/living/carbon/brain/brain.dm | 27 -- code/modules/mob/mob_defines.dm | 4 - code/modules/mob/say.dm | 8 +- code/modules/mob/say_vr.dm | 5 +- code/modules/mob/typing_indicator.dm | 86 ++-- code/modules/tgui/tgui.dm | 2 + code/modules/tgui/tgui_window.dm | 7 +- code/modules/tgui_input/say_modal/modal.dm | 129 ++++++ code/modules/tgui_input/say_modal/speech.dm | 106 +++++ code/modules/tgui_input/say_modal/typing.dm | 75 ++++ html/typing_indicator.html | 46 +++ icons/mob/talk_vr.dmi | Bin 11850 -> 15518 bytes interface/skin.dmf | 64 ++- .../packages/tgui-say/ChannelIterator.test.ts | 61 +++ tgui/packages/tgui-say/ChannelIterator.ts | 89 ++++ tgui/packages/tgui-say/ChatHistory.test.ts | 50 +++ tgui/packages/tgui-say/ChatHistory.ts | 59 +++ tgui/packages/tgui-say/TguiSay.tsx | 389 ++++++++++++++++++ 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 | 18 + tgui/packages/tgui-say/package.json | 14 + tgui/packages/tgui-say/styles/button.scss | 29 ++ tgui/packages/tgui-say/styles/colors.scss | 44 ++ 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 | 23 ++ .../tgui/interfaces/TextInputModal.tsx | 2 +- tgui/public/tgui-panel.bundle.js | 284 ++++++------- tgui/public/tgui-say.bundle.css | 1 + tgui/public/tgui-say.bundle.js | 377 +++++++++++++++++ tgui/public/tgui.bundle.js | 182 ++++---- tgui/webpack.config.js | 1 + tgui/yarn.lock | 33 ++ vorestation.dme | 12 + 53 files changed, 2502 insertions(+), 339 deletions(-) create mode 100644 code/__defines/assert.dm create mode 100644 code/__defines/speech_channels.dm create mode 100644 code/__defines/traits/_traits.dm create mode 100644 code/__defines/traits/declarations.dm create mode 100644 code/__defines/traits/sources.dm create mode 100644 code/_global_vars/traits/_traits.dm create mode 100644 code/_helpers/traits.dm create mode 100644 code/modules/asset_cache/assets/icon_ref_map.dm create mode 100644 code/modules/client/verbs/typing.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 html/typing_indicator.html 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/.vscode/tasks.json b/.vscode/tasks.json index c86b64cece..2b0aed0e81 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -46,6 +46,22 @@ ], "group": "build", "label": "tgui: prettybuild" + }, + { + "type": "shell", + "command": "bin/tgui --dev", + "windows": { + "command": ".\\bin\\tgui.bat --dev" + }, + "problemMatcher": [ + "$tsc", + "$eslint-stylish" + ], + "options": { + "cwd": ".\\tgui\\", + }, + "group": "build", + "label": "tgui: dev server" } ] } diff --git a/code/__defines/assert.dm b/code/__defines/assert.dm new file mode 100644 index 0000000000..cff7810771 --- /dev/null +++ b/code/__defines/assert.dm @@ -0,0 +1,13 @@ +#undef ASSERT + +/// Override BYOND's native ASSERT to optionally specify a message +#define ASSERT(condition, message...) \ + if (!(condition)) { \ + CRASH(assertion_message(__FILE__, __LINE__, #condition, ##message)) \ + } + +/proc/assertion_message(file, line, condition, message) + if (!isnull(message)) + message = " - [message]" + + return "[file]:[line]:Assertion failed: [condition][message]" diff --git a/code/__defines/speech_channels.dm b/code/__defines/speech_channels.dm new file mode 100644 index 0000000000..3680eb52c9 --- /dev/null +++ b/code/__defines/speech_channels.dm @@ -0,0 +1,9 @@ +// Used to direct channels to speak into. +#define SAY_CHANNEL "Say" +#define RADIO_CHANNEL "Radio" +#define ME_CHANNEL "Me" +#define OOC_CHANNEL "OOC" +#define ADMIN_CHANNEL "Admin" +#define LOOC_CHANNEL "LOOC" +#define WHIS_CHANNEL "Whis" +#define SUBTLE_CHANNEL "Subtle" diff --git a/code/__defines/traits/_traits.dm b/code/__defines/traits/_traits.dm new file mode 100644 index 0000000000..46fef90160 --- /dev/null +++ b/code/__defines/traits/_traits.dm @@ -0,0 +1,122 @@ +#define SIGNAL_ADDTRAIT(trait_ref) "addtrait [trait_ref]" +#define SIGNAL_REMOVETRAIT(trait_ref) "removetrait [trait_ref]" + +// trait accessor defines +#define ADD_TRAIT(target, trait, source) \ + do { \ + var/list/_L; \ + if (!target._status_traits) { \ + target._status_traits = list(); \ + _L = target._status_traits; \ + _L[trait] = list(source); \ + SEND_SIGNAL(target, SIGNAL_ADDTRAIT(trait), trait); \ + } else { \ + _L = target._status_traits; \ + if (_L[trait]) { \ + _L[trait] |= list(source); \ + } else { \ + _L[trait] = list(source); \ + SEND_SIGNAL(target, SIGNAL_ADDTRAIT(trait), trait); \ + } \ + } \ + } while (0) +#define REMOVE_TRAIT(target, trait, sources) \ + do { \ + var/list/_L = target._status_traits; \ + var/list/_S; \ + if (sources && !islist(sources)) { \ + _S = list(sources); \ + } else { \ + _S = sources\ + }; \ + if (_L?[trait]) { \ + for (var/_T in _L[trait]) { \ + if ((!_S && (_T != ROUNDSTART_TRAIT)) || (_T in _S)) { \ + _L[trait] -= _T \ + } \ + };\ + if (!length(_L[trait])) { \ + _L -= trait; \ + SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(trait), trait); \ + }; \ + if (!length(_L)) { \ + target._status_traits = null \ + }; \ + } \ + } while (0) +#define REMOVE_TRAIT_NOT_FROM(target, trait, sources) \ + do { \ + var/list/_traits_list = target._status_traits; \ + var/list/_sources_list; \ + if (sources && !islist(sources)) { \ + _sources_list = list(sources); \ + } else { \ + _sources_list = sources\ + }; \ + if (_traits_list?[trait]) { \ + for (var/_trait_source in _traits_list[trait]) { \ + if (!(_trait_source in _sources_list)) { \ + _traits_list[trait] -= _trait_source \ + } \ + };\ + if (!length(_traits_list[trait])) { \ + _traits_list -= trait; \ + SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(trait), trait); \ + }; \ + if (!length(_traits_list)) { \ + target._status_traits = null \ + }; \ + } \ + } while (0) +#define REMOVE_TRAITS_NOT_IN(target, sources) \ + do { \ + var/list/_L = target._status_traits; \ + var/list/_S = sources; \ + if (_L) { \ + for (var/_T in _L) { \ + _L[_T] &= _S;\ + if (!length(_L[_T])) { \ + _L -= _T; \ + SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T), _T); \ + }; \ + };\ + if (!length(_L)) { \ + target._status_traits = null\ + };\ + }\ + } while (0) + +#define REMOVE_TRAITS_IN(target, sources) \ + do { \ + var/list/_L = target._status_traits; \ + var/list/_S = sources; \ + if (sources && !islist(sources)) { \ + _S = list(sources); \ + } else { \ + _S = sources\ + }; \ + if (_L) { \ + for (var/_T in _L) { \ + _L[_T] -= _S;\ + if (!length(_L[_T])) { \ + _L -= _T; \ + SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T)); \ + }; \ + };\ + if (!length(_L)) { \ + target._status_traits = null\ + };\ + }\ + } while (0) + +#define HAS_TRAIT(target, trait) (target._status_traits?[trait] ? TRUE : FALSE) +#define HAS_TRAIT_FROM(target, trait, source) (HAS_TRAIT(target, trait) && (source in target._status_traits[trait])) +#define HAS_TRAIT_FROM_ONLY(target, trait, source) (HAS_TRAIT(target, trait) && (source in target._status_traits[trait]) && (length(target._status_traits[trait]) == 1)) +#define HAS_TRAIT_NOT_FROM(target, trait, source) (HAS_TRAIT(target, trait) && (length(target._status_traits[trait] - source) > 0)) +/// Returns a list of trait sources for this trait. Only useful for wacko cases and internal futzing +/// You should not be using this +#define GET_TRAIT_SOURCES(target, trait) (target._status_traits?[trait] || list()) +/// Returns the amount of sources for a trait. useful if you don't want to have a "thing counter" stuck around all the time +#define COUNT_TRAIT_SOURCES(target, trait) length(GET_TRAIT_SOURCES(target, trait)) +/// A simple helper for checking traits in a mob's mind +#define HAS_MIND_TRAIT(target, trait) (HAS_TRAIT(target, trait) || (target.mind ? HAS_TRAIT(target.mind, trait) : FALSE)) diff --git a/code/__defines/traits/declarations.dm b/code/__defines/traits/declarations.dm new file mode 100644 index 0000000000..71e358701e --- /dev/null +++ b/code/__defines/traits/declarations.dm @@ -0,0 +1,11 @@ +// This file contains all of the "static" define strings that tie to a trait. +// WARNING: The sections here actually matter in this file as it's tested by CI. Please do not toy with the sections." + +// BEGIN TRAIT DEFINES + +/* +Remember to update _globalvars/traits.dm if you're adding/removing/renaming traits. +*/ + +/// Trait given to a mob that is currently thinking (giving off the "thinking" icon), used in an IC context +#define TRAIT_THINKING_IN_CHARACTER "currently_thinking_IC" diff --git a/code/__defines/traits/sources.dm b/code/__defines/traits/sources.dm new file mode 100644 index 0000000000..9e282a693d --- /dev/null +++ b/code/__defines/traits/sources.dm @@ -0,0 +1,7 @@ +// This file contains all of the trait sources, or all of the things that grant traits. +// Several things such as `type` or `REF(src)` may be used in the ADD_TRAIT() macro as the "source", but this file contains all of the defines for immutable static strings. + +/// cannot be removed without admin intervention +#define ROUNDSTART_TRAIT "roundstart" +/// This trait comes from when a mob is currently typing. +#define CURRENTLY_TYPING_TRAIT "currently_typing" diff --git a/code/_global_vars/traits/_traits.dm b/code/_global_vars/traits/_traits.dm new file mode 100644 index 0000000000..f1944d6bf8 --- /dev/null +++ b/code/_global_vars/traits/_traits.dm @@ -0,0 +1,12 @@ +// This file should contain every single global trait in the game in a type-based list, as well as any additional trait-related information that's useful to have on a global basis. +// This file is used in linting, so make sure to add everything alphabetically and what-not. +// Do consider adding your trait entry to the similar list in `admin_tooling.dm` if you want it to be accessible to admins (which is probably the case for 75% of traits). + +// Please do note that there is absolutely no bearing on what traits are added to what subtype of `/datum`, this is just an easily referenceable list sorted by type. +// The only thing that truly matters about traits is the code that is built to handle the traits, and where that code is located. Nothing else. + +GLOBAL_LIST_INIT(traits_by_type, list( + /mob = list( + "TRAIT_THINKING_IN_CHARACTER" = TRAIT_THINKING_IN_CHARACTER, + ) +)) diff --git a/code/_helpers/traits.dm b/code/_helpers/traits.dm new file mode 100644 index 0000000000..fcb0593bf2 --- /dev/null +++ b/code/_helpers/traits.dm @@ -0,0 +1,45 @@ +#define TRAIT_CALLBACK_ADD(target, trait, source) CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(___TraitAdd), ##target, ##trait, ##source) +#define TRAIT_CALLBACK_REMOVE(target, trait, source) CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(___TraitRemove), ##target, ##trait, ##source) + +///DO NOT USE ___TraitAdd OR ___TraitRemove as a replacement for ADD_TRAIT / REMOVE_TRAIT defines. To be used explicitly for callback. +/proc/___TraitAdd(target, trait, source) + if(!target || !trait || !source) + return + + if(islist(target)) + for(var/datum/listed_target in target) + ADD_TRAIT(listed_target, trait, source) + return + + ASSERT(isdatum(target), "Invalid target used in TRAIT_CALLBACK_ADD! Expected a datum reference, got [target] instead.") + + var/datum/datum_target = target + ADD_TRAIT(datum_target, trait, source) + +///DO NOT USE ___TraitAdd OR ___TraitRemove as a replacement for ADD_TRAIT / REMOVE_TRAIT defines. To be used explicitly for callback. +/proc/___TraitRemove(target, trait, source) + if(!target || !trait || !source) + return + + if(islist(target)) + for(var/datum/listed_target in target) + REMOVE_TRAIT(listed_target, trait, source) + return + + ASSERT(isdatum(target), "Invalid target used in TRAIT_CALLBACK_REMOVE! Expected a datum reference, got [target] instead.") + + var/datum/datum_target = target + REMOVE_TRAIT(datum_target, trait, source) + + +/// Proc that handles adding multiple traits to a target via a list. Must have a common source and target. +/datum/proc/add_traits(list/list_of_traits, source) + ASSERT(islist(list_of_traits), "Invalid arguments passed to add_traits! Invoked on [src] with [list_of_traits], source being [source].") + for(var/trait in list_of_traits) + ADD_TRAIT(src, trait, source) + +/// Proc that handles removing multiple traits from a target via a list. Must have a common source and target. +/datum/proc/remove_traits(list/list_of_traits, source) + ASSERT(islist(list_of_traits), "Invalid arguments passed to remove_traits! Invoked on [src] with [list_of_traits], source being [source].") + for(var/trait in list_of_traits) + REMOVE_TRAIT(src, trait, source) diff --git a/code/datums/datum.dm b/code/datums/datum.dm index a9bd003fa7..a50909a3b4 100644 --- a/code/datums/datum.dm +++ b/code/datums/datum.dm @@ -23,6 +23,8 @@ /// Active timers with this datum as the target var/list/active_timers + /// Status traits attached to this datum. associative list of the form: list(trait name (string) = list(source1, source2, source3,...)) + var/list/_status_traits /** * Components attached to this datum diff --git a/code/modules/asset_cache/assets/icon_ref_map.dm b/code/modules/asset_cache/assets/icon_ref_map.dm new file mode 100644 index 0000000000..2f7f846309 --- /dev/null +++ b/code/modules/asset_cache/assets/icon_ref_map.dm @@ -0,0 +1,28 @@ +/// Maps icon names to ref values +/datum/asset/json/icon_ref_map + name = "icon_ref_map" + early = TRUE + +/datum/asset/json/icon_ref_map/generate() + var/list/data = list() //"icons/obj/drinks.dmi" => "[0xc000020]" + + //var/start = "0xc000000" + var/value = 0 + + while(TRUE) + value += 1 + var/ref = "\[0xc[num2text(value,6,16)]\]" + var/mystery_meat = locate(ref) + + if(isicon(mystery_meat)) + if(!isfile(mystery_meat)) // Ignore the runtime icons for now + continue + var/path = get_icon_dmi_path(mystery_meat) //Try to get the icon path + if(path) + data[path] = ref + else if(mystery_meat) + continue; //Some other non-icon resource, ogg/json/whatever + else //Out of resources end this, could also try to end this earlier as soon as runtime generated icons appear but eh + break; + + return data diff --git a/code/modules/asset_cache/assets/tgui.dm b/code/modules/asset_cache/assets/tgui.dm index 6446471a30..9c79925602 100644 --- a/code/modules/asset_cache/assets/tgui.dm +++ b/code/modules/asset_cache/assets/tgui.dm @@ -1,12 +1,12 @@ /datum/asset/simple/tgui - // keep_local_name = TRUE + keep_local_name = TRUE assets = list( "tgui.bundle.js" = file("tgui/public/tgui.bundle.js"), "tgui.bundle.css" = file("tgui/public/tgui.bundle.css"), ) /datum/asset/simple/tgui_panel - // keep_local_name = TRUE + keep_local_name = TRUE assets = list( "tgui-panel.bundle.js" = file("tgui/public/tgui-panel.bundle.js"), "tgui-panel.bundle.css" = file("tgui/public/tgui-panel.bundle.css"), diff --git a/code/modules/client/client procs.dm b/code/modules/client/client procs.dm index 698a501924..6a08f8c16f 100644 --- a/code/modules/client/client procs.dm +++ b/code/modules/client/client procs.dm @@ -127,6 +127,9 @@ asset_cache_preload_data(href_list["asset_cache_preload_data"]) return + if(href_list["commandbar_typing"]) + handle_commandbar_typing(href_list) + switch(href_list["_src_"]) if("holder") hsrc = holder if("mentorholder") hsrc = (check_rights(R_ADMIN, 0) ? holder : mentorholder) @@ -179,6 +182,8 @@ GLOB.directory[ckey] = src // Instantiate tgui panel + tgui_say = new(src, "tgui_say") + initialize_commandbar_spy() tgui_panel = new(src, "browseroutput") GLOB.ahelp_tickets.ClientLogin(src) @@ -211,6 +216,7 @@ prefs.selecting_slots = FALSE // Initialize tgui panel + tgui_say.initialize() tgui_panel.initialize() connection_time = world.time diff --git a/code/modules/client/preference_setup/global/setting_datums.dm b/code/modules/client/preference_setup/global/setting_datums.dm index 6755472b72..7d74e5bd0d 100644 --- a/code/modules/client/preference_setup/global/setting_datums.dm +++ b/code/modules/client/preference_setup/global/setting_datums.dm @@ -226,7 +226,13 @@ var/list/_client_preferences_by_type /datum/client_preference/show_typing_indicator/toggled(var/mob/preference_mob, var/enabled) if(!enabled) - preference_mob.set_typing_indicator(FALSE) + preference_mob.client?.stop_thinking() + +/datum/client_preference/show_typing_indicator_subtle + description ="Typing indicator (subtle)" + key = "SHOW_TYPING_SUBTLE" + enabled_description = "Show" + disabled_description = "Hide" /datum/client_preference/show_ooc description ="OOC chat" @@ -401,6 +407,19 @@ var/list/_client_preferences_by_type enabled_description = "Automatic" disabled_description = "Manual Only" +/datum/client_preference/tgui_say + description = "TGUI Say: Use TGUI For Say Input" + key = "TGUI_SAY" + enabled_by_default = TRUE + enabled_description = "Yes" + disabled_description = "No" + +/datum/client_preference/tgui_say_light + description = "TGUI Say: Use Light Mode" + key = "TGUI_SAY_LIGHT_MODE" + enabled_by_default = FALSE + enabled_description = "Yes" + disabled_description = "No" /******************** * Staff Preferences * diff --git a/code/modules/client/verbs/typing.dm b/code/modules/client/verbs/typing.dm new file mode 100644 index 0000000000..4845a40e32 --- /dev/null +++ b/code/modules/client/verbs/typing.dm @@ -0,0 +1,77 @@ +#define IC_VERBS list("say", "me", "whisper", "subtle") + +/client/var/commandbar_thinking = FALSE +/client/var/commandbar_typing = FALSE + +/client/proc/initialize_commandbar_spy() + src << output('html/typing_indicator.html', "commandbar_spy") + +/client/proc/handle_commandbar_typing(href_list) + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator)) + return + + if(length(href_list["verb"]) < 1 || !(lowertext(href_list["verb"]) in IC_VERBS) || text2num(href_list["argument_length"]) < 1) + if(commandbar_typing) + commandbar_typing = FALSE + stop_typing() + + if(commandbar_thinking) + commandbar_thinking = FALSE + stop_thinking() + return + + if(!commandbar_thinking) + commandbar_thinking = TRUE + start_thinking(href_list["verb"]) + + if(!commandbar_typing) + commandbar_typing = TRUE + start_typing(href_list["verb"]) + + +/** Sets the mob as "thinking" - with indicator and the TRAIT_THINKING_IN_CHARACTER trait */ +/client/proc/start_thinking(channel) + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator)) + return FALSE + if(channel == "Whis" || channel == "Subtle" || channel == "whisper" || channel == "subtle") + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle)) + return FALSE + ADD_TRAIT(mob, TRAIT_THINKING_IN_CHARACTER, CURRENTLY_TYPING_TRAIT) + mob.create_thinking_indicator() + +/** Removes typing/thinking indicators and flags the mob as not thinking */ +/client/proc/stop_thinking(channel) + mob?.remove_all_indicators() + +/** + * Handles the user typing. After a brief period of inactivity, + * signals the client mob to revert to the "thinking" icon. + */ +/client/proc/start_typing(channel) + var/mob/client_mob = mob + client_mob.remove_thinking_indicator() + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator) || !HAS_TRAIT(client_mob, TRAIT_THINKING_IN_CHARACTER)) + return FALSE + if(channel == "Whis" || channel == "Subtle" || channel == "whisper" || channel == "subtle") + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle)) + return FALSE + client_mob.create_typing_indicator() + addtimer(CALLBACK(src, PROC_REF(stop_typing), channel), 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. + */ +/client/proc/stop_typing(channel) + if(isnull(mob)) + return FALSE + var/mob/client_mob = mob + client_mob.remove_typing_indicator() + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator) || !HAS_TRAIT(client_mob, TRAIT_THINKING_IN_CHARACTER)) + return FALSE + if(channel == "Whis" || channel == "Subtle" || channel == "whisper" || channel == "subtle") + if(!is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle)) + return FALSE + client_mob.create_thinking_indicator() + +#undef IC_VERBS diff --git a/code/modules/mob/living/carbon/brain/brain.dm b/code/modules/mob/living/carbon/brain/brain.dm index 43fe7fc495..2e77792a00 100644 --- a/code/modules/mob/living/carbon/brain/brain.dm +++ b/code/modules/mob/living/carbon/brain/brain.dm @@ -51,33 +51,6 @@ return loc -/mob/living/carbon/brain/set_typing_indicator(var/state) - if(isturf(loc)) - return ..() - - if(!is_preference_enabled(/datum/client_preference/show_typing_indicator)) - loc.cut_overlay(typing_indicator, TRUE) - return - - var/cur_bubble_appearance = custom_speech_bubble - if(!cur_bubble_appearance || cur_bubble_appearance == "default") - cur_bubble_appearance = speech_bubble_appearance() - if(!typing_indicator || cur_typing_indicator != cur_bubble_appearance) - init_typing_indicator("[cur_bubble_appearance]_typing") - - if(state && !typing) - add_overlay(typing_indicator, TRUE) - typing = TRUE - typing_indicator_active = typing_indicator - else if(typing) - cut_overlay(typing_indicator_active, TRUE) - typing = FALSE - if(typing_indicator_active != typing_indicator) - qdel(typing_indicator_active) - typing_indicator_active = null - - return state - // Vorestation edit start /mob/living/carbon/brain/verb/backup_ping() diff --git a/code/modules/mob/mob_defines.dm b/code/modules/mob/mob_defines.dm index b3a0518111..ae88b489f7 100644 --- a/code/modules/mob/mob_defines.dm +++ b/code/modules/mob/mob_defines.dm @@ -219,10 +219,6 @@ var/get_rig_stats = 0 //Moved from computer.dm - var/typing - var/obj/effect/decal/typing_indicator - var/obj/effect/decal/typing_indicator_active - var/cur_typing_indicator var/custom_speech_bubble = "default" var/low_priority = FALSE //Skip processing life() if there's just no players on this Z-level diff --git a/code/modules/mob/say.dm b/code/modules/mob/say.dm index 220ab26e6d..f2624a91be 100644 --- a/code/modules/mob/say.dm +++ b/code/modules/mob/say.dm @@ -4,6 +4,7 @@ /mob/verb/whisper(message as text) set name = "Whisper" set category = "IC" + set hidden = 1 //VOREStation Addition Start if(forced_psay) psay(message) @@ -15,18 +16,21 @@ /mob/verb/say_verb(message as text) set name = "Say" set category = "IC" + set hidden = 1 //VOREStation Addition Start if(forced_psay) psay(message) return //VOREStation Addition End - set_typing_indicator(FALSE) + client?.stop_thinking() usr.say(message) /mob/verb/me_verb(message as message) set name = "Me" set category = "IC" + set desc = "Emote to nearby people (and your pred/prey)" + set hidden = 1 if(say_disabled) //This is here to try to identify lag problems to_chat(usr, span_red("Speech is currently admin-disabled.")) @@ -45,7 +49,7 @@ message = sanitize_or_reflect(message,src) //VOREStation Edit - Reflect too-long messages (within reason) //VOREStation Edit End - set_typing_indicator(FALSE) + client?.stop_thinking() if(use_me) custom_emote(usr.emote_type, message) else diff --git a/code/modules/mob/say_vr.dm b/code/modules/mob/say_vr.dm index 32e609ad90..00fef766c9 100644 --- a/code/modules/mob/say_vr.dm +++ b/code/modules/mob/say_vr.dm @@ -6,6 +6,7 @@ set name = "Subtle" set category = "IC" set desc = "Emote to nearby people (and your pred/prey)" + set hidden = 1 if(say_disabled) //This is here to try to identify lag problems to_chat(usr, "Speech is currently admin-disabled.") @@ -18,7 +19,7 @@ if(!message) return - set_typing_indicator(FALSE) + client?.stop_thinking() if(use_me) usr.emote_vr("me",4,message) else @@ -40,7 +41,7 @@ if(!message) return - set_typing_indicator(FALSE) + client?.stop_thinking() if(use_me) usr.emote_vr("me",4,message,TRUE) else diff --git a/code/modules/mob/typing_indicator.dm b/code/modules/mob/typing_indicator.dm index 8684c3463c..ec1a0be6d6 100644 --- a/code/modules/mob/typing_indicator.dm +++ b/code/modules/mob/typing_indicator.dm @@ -1,4 +1,4 @@ -/proc/generate_speech_bubble(var/bubble_loc, var/speech_state, var/set_layer = FLOAT_LAYER) +/proc/generate_speech_bubble(bubble_loc, speech_state, set_layer = FLOAT_LAYER) var/image/I = image('icons/mob/talk_vr.dmi', bubble_loc, speech_state, set_layer) //VOREStation Edit - talk_vr.dmi instead of talk.dmi for right-side icons I.appearance_flags |= (RESET_COLOR|PIXEL_SCALE) //VOREStation Edit /* //VOREStation Removal Start @@ -11,77 +11,69 @@ */ //VOREStation Removal Start return I -/mob/proc/init_typing_indicator(var/set_state = "typing") - if(typing_indicator) - qdel(typing_indicator) - typing_indicator = null - typing_indicator = new - typing_indicator.appearance = generate_speech_bubble(null, set_state) - typing_indicator.appearance_flags |= (RESET_COLOR|PIXEL_SCALE) //VOREStation Edit +/mob/verb/say_wrapper() + set name = "Say verb" + set category = "IC" -/mob/proc/set_typing_indicator(var/state) //Leaving this here for mobs. - - if(!is_preference_enabled(/datum/client_preference/show_typing_indicator)) - if(typing_indicator) - cut_overlay(typing_indicator, TRUE) + if(is_preference_enabled(/datum/client_preference/tgui_say)) + winset(src, null, "command=[client.tgui_say_create_open_command(SAY_CHANNEL)]") return - var/cur_bubble_appearance = custom_speech_bubble - if(!cur_bubble_appearance || cur_bubble_appearance == "default") - cur_bubble_appearance = speech_bubble_appearance() - if(!typing_indicator || cur_typing_indicator != cur_bubble_appearance) - init_typing_indicator("[cur_bubble_appearance]_typing") - - if(state && !typing) - add_overlay(typing_indicator, TRUE) - typing = TRUE - typing_indicator_active = typing_indicator - else if(typing) - cut_overlay(typing_indicator_active, TRUE) - typing = FALSE - if(typing_indicator_active != typing_indicator) - qdel(typing_indicator_active) - typing_indicator_active = null - - return state - -/mob/verb/say_wrapper() - set name = ".Say" - set hidden = 1 - - set_typing_indicator(TRUE) + client?.start_thinking() + client?.start_typing() var/message = tgui_input_text(usr, "Type your message:", "Say") - set_typing_indicator(FALSE) + client?.stop_thinking() if(message) say_verb(message) /mob/verb/me_wrapper() - set name = ".Me" - set hidden = 1 + set name = "Me verb" + set category = "IC" - set_typing_indicator(TRUE) + if(is_preference_enabled(/datum/client_preference/tgui_say)) + winset(src, null, "command=[client.tgui_say_create_open_command(ME_CHANNEL)]") + return + + client?.start_thinking() + client?.start_typing() var/message = tgui_input_text(usr, "Type your message:", "Emote", multiline = TRUE) - set_typing_indicator(FALSE) + client?.stop_thinking() if(message) me_verb(message) -// No typing indicators here, but this is the file where the wrappers are, so... /mob/verb/whisper_wrapper() - set name = ".Whisper" - set hidden = 1 + set name = "Whisper verb" + set category = "IC" + if(is_preference_enabled(/datum/client_preference/tgui_say)) + winset(src, null, "command=[client.tgui_say_create_open_command(WHIS_CHANNEL)]") + return + + if(is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle)) + client?.start_thinking() + client?.start_typing() var/message = tgui_input_text(usr, "Type your message:", "Whisper") + client?.stop_thinking() if(message) whisper(message) /mob/verb/subtle_wrapper() - set name = ".Subtle" - set hidden = 1 + set name = "Subtle verb" + set category = "IC" + set desc = "Emote to nearby people (and your pred/prey)" + if(is_preference_enabled(/datum/client_preference/tgui_say)) + winset(src, null, "command=[client.tgui_say_create_open_command(SUBTLE_CHANNEL)]") + return + + if(is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle)) + client?.start_thinking() + client?.start_typing() var/message = tgui_input_text(usr, "Type your message:", "Subtle", multiline = TRUE) + client?.stop_thinking() if(message) me_verb_subtle(message) diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm index 42dcae7dcc..ed654a4c6f 100644 --- a/code/modules/tgui/tgui.dm +++ b/code/modules/tgui/tgui.dm @@ -126,6 +126,8 @@ /datum/asset/simple/namespaced/fontawesome)) flush_queue |= window.send_asset(get_asset_datum( /datum/asset/simple/namespaced/tgfont)) + flush_queue |= window.send_asset(get_asset_datum( + /datum/asset/json/icon_ref_map)) for(var/datum/asset/asset in src_object.ui_assets(user)) flush_queue |= window.send_asset(asset) if (flush_queue) diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm index 62435ac30a..9e0a5491ab 100644 --- a/code/modules/tgui/tgui_window.dm +++ b/code/modules/tgui/tgui_window.dm @@ -119,11 +119,11 @@ html = replacetextEx(html, "", inline_html) // Inject inline JS if (inline_js) - inline_js = "" + inline_js = "" html = replacetextEx(html, "", inline_js) // Inject inline CSS if (inline_css) - inline_css = "" + inline_css = "" html = replacetextEx(html, "", inline_css) // Open the window client << browse(html, "window=[id];[options]") @@ -146,6 +146,9 @@ inline_html = initial_inline_html, inline_js = initial_inline_js, inline_css = initial_inline_css) + // Resend assets + for(var/datum/asset/asset in sent_assets) + send_asset(asset) /** * public 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 0000000000..725f4321bf --- /dev/null +++ b/code/modules/tgui_input/say_modal/modal.dm @@ -0,0 +1,129 @@ +/** 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( + strict_mode = TRUE, + 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=410,400;size=360,30;is-visible=0;") + + window.send_message("props", list( + lightMode = client.is_preference_enabled(/datum/client_preference/tgui_say_light), + 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). Logs the event. + * + * 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 && payload["channel"] != ADMIN_CHANNEL) + start_thinking() + return TRUE + +/** + * Closes the window serverside. Closes any open chat bubbles + * regardless of preference. Logs the event. + */ +/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(payload["channel"]) + return TRUE + if(payload["visible"] == FALSE) + stop_thinking(payload["channel"]) + return TRUE + return FALSE + if(type == "typing") + start_typing(payload["channel"]) + 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 0000000000..bed862e63a --- /dev/null +++ b/code/modules/tgui_input/say_modal/speech.dm @@ -0,0 +1,106 @@ +/** + * 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 || payload["channel"] == LOOC_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(WHIS_CHANNEL) + client.mob.whisper(entry) + return TRUE + if(SUBTLE_CHANNEL) + client.mob.me_verb_subtle(entry) + return TRUE + if(OOC_CHANNEL) + client.ooc(entry) + return TRUE + if(LOOC_CHANNEL) + client.looc(entry) + return TRUE + if(ADMIN_CHANNEL) + if(check_rights(R_ADMIN, show_msg = FALSE)) + client.cmd_admin_say(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() + // if(client.typing_indicators) + // log_speech_indicators("[key_name(client)] FORCED to stop typing, indicators enabled.") + // else + // log_speech_indicators("[key_name(client)] FORCED to stop typing, indicators DISABLED.") + // SEND_SIGNAL(src, COMSIG_HUMAN_FORCESAY) + +/** + * 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 == LOOC_CHANNEL) + target_channel = SAY_CHANNEL // No ooc leaks - this is fine because alter_entry will override this completely + 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 0000000000..f4f3a63b87 --- /dev/null +++ b/code/modules/tgui_input/say_modal/typing.dm @@ -0,0 +1,75 @@ +/mob + ///the icon currently used for the typing indicator's bubble + var/mutable_appearance/active_typing_indicator + ///the icon currently used for the thinking indicator's bubble + var/mutable_appearance/active_thinking_indicator + +/** Creates a thinking indicator over the mob. Note: Prefs are checked in /client/proc/start_thinking() */ +/mob/proc/create_thinking_indicator() + if(active_thinking_indicator || active_typing_indicator || stat != CONSCIOUS || !HAS_TRAIT(src, TRAIT_THINKING_IN_CHARACTER)) + return FALSE + var/cur_bubble_appearance = custom_speech_bubble + if(!cur_bubble_appearance || cur_bubble_appearance == "default") + cur_bubble_appearance = speech_bubble_appearance() + active_thinking_indicator = mutable_appearance('icons/mob/talk_vr.dmi', "[cur_bubble_appearance]_thinking", FLOAT_LAYER) + active_thinking_indicator.appearance_flags |= (RESET_COLOR|PIXEL_SCALE) + add_overlay(active_thinking_indicator) + +/** Removes the thinking indicator over the mob. */ +/mob/proc/remove_thinking_indicator() + if(!active_thinking_indicator) + return FALSE + cut_overlay(active_thinking_indicator) + active_thinking_indicator = null + +/** Creates a typing indicator over the mob. Note: Prefs are checked in /client/proc/start_typing() */ +/mob/proc/create_typing_indicator() + if(active_typing_indicator || active_thinking_indicator || stat != CONSCIOUS || !HAS_TRAIT(src, TRAIT_THINKING_IN_CHARACTER)) + return FALSE + var/cur_bubble_appearance = custom_speech_bubble + if(!cur_bubble_appearance || cur_bubble_appearance == "default") + cur_bubble_appearance = speech_bubble_appearance() + active_typing_indicator = mutable_appearance('icons/mob/talk_vr.dmi', "[cur_bubble_appearance]_typing", ABOVE_MOB_LAYER) + active_typing_indicator.appearance_flags |= (RESET_COLOR|PIXEL_SCALE) + add_overlay(active_typing_indicator) + +/** Removes the typing indicator over the mob. */ +/mob/proc/remove_typing_indicator() + if(!active_typing_indicator) + return FALSE + cut_overlay(active_typing_indicator) + active_typing_indicator = null + +/** Removes any indicators and marks the mob as not speaking IC. */ +/mob/proc/remove_all_indicators() + REMOVE_TRAIT(src, TRAIT_THINKING_IN_CHARACTER, CURRENTLY_TYPING_TRAIT) + remove_thinking_indicator() + remove_typing_indicator() + +/mob/set_stat(new_stat) + . = ..() + if(.) + remove_all_indicators() + +/mob/Logout() + remove_all_indicators() + return ..() + +/** Sets the mob as "thinking" - with indicator and the TRAIT_THINKING_IN_CHARACTER trait */ +/datum/tgui_say/proc/start_thinking(channel) + if(!window_open) + return FALSE + return client.start_thinking(channel) + +/** Removes typing/thinking indicators and flags the mob as not thinking */ +/datum/tgui_say/proc/stop_thinking(channel) + return client.stop_thinking(channel) + +/** + * 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(channel) + if(!window_open) + return FALSE + return client.start_typing(channel) diff --git a/html/typing_indicator.html b/html/typing_indicator.html new file mode 100644 index 0000000000..2988edff55 --- /dev/null +++ b/html/typing_indicator.html @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/icons/mob/talk_vr.dmi b/icons/mob/talk_vr.dmi index 28337d0136308e88e063fc61c8308de142c82871..2c3ca06d73793e92c53f73d743d998d4407107da 100644 GIT binary patch literal 15518 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 VymPf90c<~hzk5f~cSnEue*jE@NofE8 literal 11850 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 diff --git a/interface/skin.dmf b/interface/skin.dmf index 0dfad8ccf5..a32906eb75 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -124,10 +124,10 @@ macro "borghotkeymode" command = "a-intent left" elem name = "5" - command = ".me" + command = "Me-verb" elem name = "6" - command = ".Subtle" + command = "Subtle-verb" elem name = "A" command = "KeyDown A" @@ -178,7 +178,7 @@ macro "borghotkeymode" command = "KeyUp S" elem name = "T" - command = ".say" + command = "Say-verb" elem "w_key" name = "W" command = "KeyDown W" @@ -193,10 +193,10 @@ macro "borghotkeymode" command = ".northeast" elem name = "Y" - command = ".Whisper" + command = "Whisper-verb" elem name = "CTRL+Y" - command = ".Whisper" + command = "Whisper-verb" elem name = "Z" command = "Activate-Held-Object" @@ -244,10 +244,10 @@ macro "borghotkeymode" command = ".screenshot" elem name = "F3" - command = ".say" + command = "Say-verb" elem name = "F4" - command = ".me" + command = "Me-verb" elem name = "F5" command = "asay" @@ -474,10 +474,10 @@ macro "macro" command = ".screenshot" elem name = "F3" - command = ".say" + command = "Say-verb" elem name = "F4" - command = ".me" + command = "Me-verb" elem name = "F5" command = "asay" @@ -626,10 +626,10 @@ macro "hotkeymode" command = "a-intent harm" elem name = "5" - command = ".me" + command = "Me-verb" elem name = "6" - command = ".Subtle" + command = "Subtle-verb" elem name = "A" command = "KeyDown A" @@ -692,7 +692,7 @@ macro "hotkeymode" command = "KeyUp S" elem name = "T" - command = ".say" + command = "Say-verb" elem "w_key" name = "W" command = "KeyDown W" @@ -707,10 +707,10 @@ macro "hotkeymode" command = ".northeast" elem name = "Y" - command = ".Whisper" + command = "Whisper-verb" elem name = "CTRL+Y" - command = ".Whisper" + command = "Whisper-verb" elem name = "Z" command = "Activate-Held-Object" @@ -767,10 +767,10 @@ macro "hotkeymode" command = ".screenshot" elem name = "F3" - command = ".say" + command = "Say-verb" elem name = "F4" - command = ".me" + command = "Me-verb" elem name = "F5" command = "asay" @@ -991,10 +991,10 @@ macro "borgmacro" command = ".screenshot" elem name = "F3" - command = ".say" + command = "Say-verb" elem name = "F4" - command = ".me" + command = "Me-verb" elem name = "F5" command = "asay" @@ -1230,6 +1230,15 @@ window "mainwindow" anchor2 = -1,-1 is-visible = false saved-params = "" + elem "commandbar_spy" + type = BROWSER + is-default = false + pos = 0,0 + size = 200x200 + anchor1 = -1,-1 + anchor2 = -1,-1 + is-visible = false + saved-params = "" window "mapwindow" elem "mapwindow" @@ -1562,3 +1571,22 @@ window "text_editor" border = line saved-params = "" multi-line = true + +window "tgui_say" + elem "tgui_say" + type = MAIN + pos = 410,400 + size = 360x30 + anchor1 = 50,50 + anchor2 = 50,50 + is-visible = false + saved-params = "" + statusbar = false + can-minimize = false + elem "browser" + type = BROWSER + pos = 0,0 + size = 360x30 + anchor1 = 0,0 + anchor2 = 0,0 + saved-params = "" diff --git a/tgui/packages/tgui-say/ChannelIterator.test.ts b/tgui/packages/tgui-say/ChannelIterator.test.ts new file mode 100644 index 0000000000..29e960d87a --- /dev/null +++ b/tgui/packages/tgui-say/ChannelIterator.test.ts @@ -0,0 +1,61 @@ +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('Whis'); + expect(channelIterator.next()).toBe('Subtle'); + expect(channelIterator.next()).toBe('LOOC'); + expect(channelIterator.next()).toBe('OOC'); + expect(channelIterator.next()).toBe('Say'); // Admin is blacklisted so it should be skipped + }); + + it('should cycle through channels backwards properly', () => { + expect(channelIterator.current()).toBe('Say'); + expect(channelIterator.prev()).toBe('OOC'); // Admin is blacklisted so it should be skipped + expect(channelIterator.prev()).toBe('LOOC'); + expect(channelIterator.prev()).toBe('Subtle'); + expect(channelIterator.prev()).toBe('Whis'); + expect(channelIterator.prev()).toBe('Me'); + expect(channelIterator.prev()).toBe('Radio'); + expect(channelIterator.prev()).toBe('Say'); + }); + + 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 0000000000..38ba90c0af --- /dev/null +++ b/tgui/packages/tgui-say/ChannelIterator.ts @@ -0,0 +1,89 @@ +export type Channel = + | 'Say' + | 'Radio' + | 'Me' + | 'Whis' + | 'Subtle' + | 'LOOC' + | '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', + 'Whis', + 'Subtle', + 'LOOC', + 'OOC', + 'Admin', + ]; + private readonly blacklist: Channel[] = ['Admin']; + private readonly quiet: Channel[] = ['OOC', 'LOOC', 'Admin']; + private readonly multiline: Channel[] = ['Me', 'Subtle']; + + 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 prev(): 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) % 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 isMultiline(): boolean { + return this.multiline.includes(this.channels[this.index]); + } + + 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 0000000000..c6d8c1c2e2 --- /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 0000000000..b5490b1887 --- /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 0000000000..e4b24f374a --- /dev/null +++ b/tgui/packages/tgui-say/TguiSay.tsx @@ -0,0 +1,389 @@ +import { KEY } from 'common/keys'; +import { BooleanLike } from 'common/react'; +import { Component, createRef, RefObject } from 'react'; +import { dragStartHandler } from 'tgui/drag'; +import { + removeAllSkiplines, + sanitizeMultiline, +} from 'tgui/interfaces/TextInputModal'; + +import { Channel, ChannelIterator } from './ChannelIterator'; +import { ChatHistory } from './ChatHistory'; +import { LINE_LENGTHS, RADIO_PREFIXES, WINDOW_SIZES } from './constants'; +import { windowClose, windowOpen, windowSet } from './helpers'; +import { byondMessages } from './timers'; + +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; + 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); + + // Everything can be multiline, but only emotes get passed that way to the game + const sanitizedValue = this.channelIterator.isMultiline() + ? sanitizeMultiline(value) + : removeAllSkiplines(value); + + Byond.sendMessage('entry', { + channel: this.channelIterator.current(), + entry: this.channelIterator.isSay() + ? prefix + sanitizedValue + : sanitizedValue, + }); + } + + 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() { + 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.channelIterator.current()); + } else { + this.messages.channelIncrementMsg(true, this.channelIterator.current()); + } + + this.setState({ buttonContent: this.channelIterator.current() }); + } + + handleDecrementChannel() { + this.currentPrefix = null; + + this.channelIterator.prev(); + + // If we've looped onto a quiet channel, tell byond to hide thinking indicators + if (!this.channelIterator.isVisible()) { + this.messages.channelIncrementMsg(false, this.channelIterator.current()); + } else { + this.messages.channelIncrementMsg(true, this.channelIterator.current()); + } + + 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.messages.typingMsg(this.channelIterator.current()); + } + + 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; + } + + this.channelIterator.set('Say'); + this.currentPrefix = prefix; + this.setState({ buttonContent: RADIO_PREFIXES[prefix] }); + this.setValue(typed.slice(3)); + } + + handleKeyDown(event: React.KeyboardEvent) { + const currentValue = this.innerRef.current?.value; + switch (event.key) { + case KEY.Up: + case KEY.Down: + // Allow moving between lines if there are newlines + if (currentValue?.includes('\n')) { + break; + } + event.preventDefault(); + this.handleArrowKeys(event.key); + break; + + case KEY.Delete: + case KEY.Backspace: + this.handleBackspaceDelete(); + break; + + case KEY.Enter: + // Allow inputting newlines + if (event.shiftKey) { + break; + } + event.preventDefault(); + this.handleEnter(); + break; + + case KEY.Tab: + event.preventDefault(); + if (event.shiftKey) { + this.handleDecrementChannel(); + } else { + 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; + + const currentValue = this.innerRef.current?.value; + + if (currentValue?.includes('\n')) { + newSize = WINDOW_SIZES.large; + } else 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 ( +
+ +
+ +
+ +