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 28337d0136..2c3ca06d73 100644 Binary files a/icons/mob/talk_vr.dmi and b/icons/mob/talk_vr.dmi differ 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 ( +
+ +
+ +
+ +