diff --git a/code/modules/tgui_input/say_modal/modal.dm b/code/modules/tgui_input/say_modal/modal.dm index 444d67812ae1..a69d5120e2f3 100644 --- a/code/modules/tgui_input/say_modal/modal.dm +++ b/code/modules/tgui_input/say_modal/modal.dm @@ -36,6 +36,7 @@ /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 @@ -62,7 +63,9 @@ */ /datum/tgui_say/proc/load() window_open = FALSE - winshow(client, "tgui_say", FALSE) + + winset(client, "tgui_say", "pos=848,500;size=231,30;is-visible=0;") + window.send_message("props", list( lightMode = client.prefs?.read_preference(/datum/preference/toggle/tgui_say_light_mode), maxLength = max_length, @@ -84,9 +87,7 @@ window_open = TRUE if(payload["channel"] != OOC_CHANNEL && payload["channel"] != LOOC_CHANNEL && (payload["channel"] != ADMIN_CHANNEL) && (payload["channel"] != MENTOR_CHANNEL)) // monke: add LOOC start_thinking() - if(client.typing_indicators) - log_speech_indicators("[key_name(client)] started typing at [loc_name(client.mob)], indicators enabled.") - else + if(!client.typing_indicators) log_speech_indicators("[key_name(client)] started typing at [loc_name(client.mob)], indicators DISABLED.") return TRUE @@ -97,9 +98,7 @@ /datum/tgui_say/proc/close() window_open = FALSE stop_thinking() - if(client.typing_indicators) - log_speech_indicators("[key_name(client)] stopped typing at [loc_name(client.mob)], indicators enabled.") - else + if(!client.typing_indicators) log_speech_indicators("[key_name(client)] stopped typing at [loc_name(client.mob)], indicators DISABLED.") /** @@ -117,10 +116,10 @@ close() return TRUE if (type == "thinking") - if(payload["mode"] == TRUE) + if(payload["visible"] == TRUE) start_thinking() return TRUE - if(payload["mode"] == FALSE) + if(payload["visible"] == FALSE) stop_thinking() return TRUE return FALSE diff --git a/tgui/packages/common/keys.ts b/tgui/packages/common/keys.ts new file mode 100644 index 000000000000..61b79992b486 --- /dev/null +++ b/tgui/packages/common/keys.ts @@ -0,0 +1,39 @@ +/** + * ### Key codes. + * event.keyCode is deprecated, use this reference instead. + * + * Handles modifier keys (Shift, Alt, Control) and arrow keys. + * + * For alphabetical keys, use the actual character (e.g. 'a') instead of the key code. + * + * Something isn't here that you want? Just add it: + * @url https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + * @usage + * ```ts + * import { KEY } from 'tgui/common/keys'; + * + * if (event.key === KEY.Enter) { + * // do something + * } + * ``` + */ +export enum KEY { + Alt = 'Alt', + Backspace = 'Backspace', + Control = 'Control', + Delete = 'Delete', + Down = 'Down', + End = 'End', + Enter = 'Enter', + Escape = 'Esc', + Home = 'Home', + Insert = 'Insert', + Left = 'Left', + PageDown = 'PageDown', + PageUp = 'PageUp', + Right = 'Right', + Shift = 'Shift', + Space = ' ', + Tab = 'Tab', + Up = 'Up', +} diff --git a/tgui/packages/common/timer.js b/tgui/packages/common/timer.ts similarity index 59% rename from tgui/packages/common/timer.js rename to tgui/packages/common/timer.ts index 7d89e935b9b5..49d36484200b 100644 --- a/tgui/packages/common/timer.js +++ b/tgui/packages/common/timer.ts @@ -10,9 +10,13 @@ * called for N milliseconds. If `immediate` is passed, trigger the * function on the leading edge, instead of the trailing. */ -export const debounce = (fn, time, immediate = false) => { - let timeout; - return (...args) => { +export const debounce = any>( + fn: F, + time: number, + immediate = false +): ((...args: Parameters) => void) => { + let timeout: ReturnType | null; + return (...args: Parameters) => { const later = () => { timeout = null; if (!immediate) { @@ -20,7 +24,7 @@ export const debounce = (fn, time, immediate = false) => { } }; const callNow = immediate && !timeout; - clearTimeout(timeout); + clearTimeout(timeout!); timeout = setTimeout(later, time); if (callNow) { fn(...args); @@ -32,18 +36,24 @@ export const debounce = (fn, time, immediate = false) => { * Returns a function, that, when invoked, will only be triggered at most once * during a given window of time. */ -export const throttle = (fn, time) => { - let previouslyRun, queuedToRun; - return function invokeFn(...args) { +export const throttle = any>( + fn: F, + time: number +): ((...args: Parameters) => void) => { + let previouslyRun: number | null, + queuedToRun: ReturnType | null; + return function invokeFn(...args: Parameters) { const now = Date.now(); - queuedToRun = clearTimeout(queuedToRun); + if (queuedToRun) { + clearTimeout(queuedToRun); + } if (!previouslyRun || now - previouslyRun >= time) { fn.apply(null, args); previouslyRun = now; } else { queuedToRun = setTimeout( - invokeFn.bind(null, ...args), - time - (now - previouslyRun) + () => invokeFn(...args), + time - (now - (previouslyRun ?? 0)) ); } }; @@ -54,5 +64,5 @@ export const throttle = (fn, time) => { * * @param {number} time */ -export const sleep = (time) => +export const sleep = (time: number): Promise => new Promise((resolve) => setTimeout(resolve, time)); diff --git a/tgui/packages/tgui-say/ChannelIterator.test.ts b/tgui/packages/tgui-say/ChannelIterator.test.ts new file mode 100644 index 000000000000..a79de1526f8a --- /dev/null +++ b/tgui/packages/tgui-say/ChannelIterator.test.ts @@ -0,0 +1,58 @@ +import { ChannelIterator } from './ChannelIterator'; + +describe('ChannelIterator', () => { + let channelIterator: ChannelIterator; + + beforeEach(() => { + channelIterator = new ChannelIterator(); + }); + + it('should cycle through channels properly', () => { + expect(channelIterator.current()).toBe('Say'); + expect(channelIterator.next()).toBe('Radio'); + expect(channelIterator.next()).toBe('Me'); + expect(channelIterator.next()).toBe('OOC'); + expect(channelIterator.next()).toBe('LOOC'); + expect(channelIterator.next()).toBe('Say'); // Admin is blacklisted so it should be skipped + }); + + it('should set a channel properly', () => { + channelIterator.set('OOC'); + expect(channelIterator.current()).toBe('OOC'); + }); + + it('should return true when current channel is "Say"', () => { + channelIterator.set('Say'); + expect(channelIterator.isSay()).toBe(true); + }); + + it('should return false when current channel is not "Say"', () => { + channelIterator.set('Radio'); + expect(channelIterator.isSay()).toBe(false); + }); + + it('should return true when current channel is visible', () => { + channelIterator.set('Say'); + expect(channelIterator.isVisible()).toBe(true); + }); + + it('should return false when current channel is not visible', () => { + channelIterator.set('OOC'); + expect(channelIterator.isVisible()).toBe(false); + }); + + it('should return false when current channel is not visible', () => { + channelIterator.set('LOOC'); + expect(channelIterator.isVisible()).toBe(false); + }); + + it('should not leak a message from a blacklisted channel', () => { + channelIterator.set('Mentor'); + expect(channelIterator.next()).toBe('Mentor'); + }); + + it('should not leak a message from a blacklisted channel', () => { + channelIterator.set('Admin'); + expect(channelIterator.next()).toBe('Admin'); + }); +}); diff --git a/tgui/packages/tgui-say/ChannelIterator.ts b/tgui/packages/tgui-say/ChannelIterator.ts new file mode 100644 index 000000000000..cc6978e144fb --- /dev/null +++ b/tgui/packages/tgui-say/ChannelIterator.ts @@ -0,0 +1,65 @@ +export type Channel = + | 'Say' + | 'Radio' + | 'Me' + | 'OOC' + | 'LOOC' + | 'Mentor' + | 'Admin'; + +/** + * ### ChannelIterator + * Cycles a predefined list of channels, + * skipping over blacklisted ones, + * and providing methods to manage and query the current channel. + */ +export class ChannelIterator { + private index: number = 0; + private readonly channels: Channel[] = [ + 'Say', + 'Radio', + 'Me', + 'OOC', + 'LOOC', + 'Mentor', + 'Admin', + ]; + private readonly blacklist: Channel[] = ['Mentor', 'Admin']; + private readonly quiet: Channel[] = ['OOC', 'LOOC', 'Mentor', 'Admin']; + + public next(): Channel { + if (this.blacklist.includes(this.channels[this.index])) { + return this.channels[this.index]; + } + + for (let index = 1; index <= this.channels.length; index++) { + let nextIndex = (this.index + index) % this.channels.length; + if (!this.blacklist.includes(this.channels[nextIndex])) { + this.index = nextIndex; + break; + } + } + + return this.channels[this.index]; + } + + public set(channel: Channel): void { + this.index = this.channels.indexOf(channel) || 0; + } + + public current(): Channel { + return this.channels[this.index]; + } + + public isSay(): boolean { + return this.channels[this.index] === 'Say'; + } + + public isVisible(): boolean { + return !this.quiet.includes(this.channels[this.index]); + } + + public reset(): void { + this.index = 0; + } +} diff --git a/tgui/packages/tgui-say/ChatHistory.test.ts b/tgui/packages/tgui-say/ChatHistory.test.ts new file mode 100644 index 000000000000..c6d8c1c2e27c --- /dev/null +++ b/tgui/packages/tgui-say/ChatHistory.test.ts @@ -0,0 +1,50 @@ +import { ChatHistory } from './ChatHistory'; + +describe('ChatHistory', () => { + let chatHistory: ChatHistory; + + beforeEach(() => { + chatHistory = new ChatHistory(); + }); + + it('should add a message to the history', () => { + chatHistory.add('Hello'); + expect(chatHistory.getOlderMessage()).toEqual('Hello'); + }); + + it('should retrieve older and newer messages', () => { + chatHistory.add('Hello'); + chatHistory.add('World'); + expect(chatHistory.getOlderMessage()).toEqual('World'); + expect(chatHistory.getOlderMessage()).toEqual('Hello'); + expect(chatHistory.getNewerMessage()).toEqual('World'); + expect(chatHistory.getNewerMessage()).toBeNull(); + expect(chatHistory.getOlderMessage()).toEqual('World'); + }); + + it('should limit the history to 5 messages', () => { + for (let i = 1; i <= 6; i++) { + chatHistory.add(`Message ${i}`); + } + + expect(chatHistory.getOlderMessage()).toEqual('Message 6'); + for (let i = 5; i >= 2; i--) { + expect(chatHistory.getOlderMessage()).toEqual(`Message ${i}`); + } + expect(chatHistory.getOlderMessage()).toBeNull(); + }); + + it('should handle temp message correctly', () => { + chatHistory.saveTemp('Temp message'); + expect(chatHistory.getTemp()).toEqual('Temp message'); + expect(chatHistory.getTemp()).toBeNull(); + }); + + it('should reset correctly', () => { + chatHistory.add('Hello'); + chatHistory.getOlderMessage(); + chatHistory.reset(); + expect(chatHistory.isAtLatest()).toBe(true); + expect(chatHistory.getOlderMessage()).toEqual('Hello'); + }); +}); diff --git a/tgui/packages/tgui-say/ChatHistory.ts b/tgui/packages/tgui-say/ChatHistory.ts new file mode 100644 index 000000000000..b5490b1887f4 --- /dev/null +++ b/tgui/packages/tgui-say/ChatHistory.ts @@ -0,0 +1,59 @@ +/** + * ### ChatHistory + * A class to manage a chat history, + * maintaining a maximum of five messages and supporting navigation, + * temporary message storage, and query operations. + */ +export class ChatHistory { + private messages: string[] = []; + private index: number = -1; // Initialize index at -1 + private temp: string | null = null; + + public add(message: string): void { + this.messages.unshift(message); + this.index = -1; // Reset index + if (this.messages.length > 5) { + this.messages.pop(); + } + } + + public getIndex(): number { + return this.index + 1; + } + + public getOlderMessage(): string | null { + if (this.messages.length === 0 || this.index >= this.messages.length - 1) { + return null; + } + this.index++; + return this.messages[this.index]; + } + + public getNewerMessage(): string | null { + if (this.index <= 0) { + this.index = -1; + return null; + } + this.index--; + return this.messages[this.index]; + } + + public isAtLatest(): boolean { + return this.index === -1; + } + + public saveTemp(message: string): void { + this.temp = message; + } + + public getTemp(): string | null { + const temp = this.temp; + this.temp = null; + return temp; + } + + public reset(): void { + this.index = -1; + this.temp = null; + } +} diff --git a/tgui/packages/tgui-say/TguiSay.tsx b/tgui/packages/tgui-say/TguiSay.tsx new file mode 100644 index 000000000000..949099dd5bfa --- /dev/null +++ b/tgui/packages/tgui-say/TguiSay.tsx @@ -0,0 +1,354 @@ +import { Channel, ChannelIterator } from './ChannelIterator'; +import { ChatHistory } from './ChatHistory'; +import { Component, createRef, InfernoKeyboardEvent, RefObject } from 'inferno'; +import { LINE_LENGTHS, RADIO_PREFIXES, WINDOW_SIZES } from './constants'; +import { byondMessages } from './timers'; +import { dragStartHandler } from 'tgui/drag'; +import { windowOpen, windowClose, windowSet } from './helpers'; +import { BooleanLike } from 'common/react'; +import { KEY } from 'common/keys'; + +type ByondOpen = { + channel: Channel; +}; + +type ByondProps = { + maxLength: number; + lightMode: BooleanLike; +}; + +type State = { + buttonContent: string | number; + size: WINDOW_SIZES; +}; + +const CHANNEL_REGEX = /^[:.]\w\s/; + +export class TguiSay extends Component<{}, State> { + private channelIterator: ChannelIterator; + private chatHistory: ChatHistory; + private currentPrefix: keyof typeof RADIO_PREFIXES | null; + private innerRef: RefObject; + private lightMode: boolean; + private maxLength: number; + private messages: typeof byondMessages; + state: State; + + constructor(props: never) { + super(props); + + this.channelIterator = new ChannelIterator(); + this.chatHistory = new ChatHistory(); + this.currentPrefix = null; + this.innerRef = createRef(); + this.lightMode = false; + this.maxLength = 1024; + this.messages = byondMessages; + this.state = { + buttonContent: '', + size: WINDOW_SIZES.small, + }; + + this.handleArrowKeys = this.handleArrowKeys.bind(this); + this.handleBackspaceDelete = this.handleBackspaceDelete.bind(this); + this.handleClose = this.handleClose.bind(this); + this.handleEnter = this.handleEnter.bind(this); + this.handleForceSay = this.handleForceSay.bind(this); + this.handleIncrementChannel = this.handleIncrementChannel.bind(this); + this.handleInput = this.handleInput.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleOpen = this.handleOpen.bind(this); + this.handleProps = this.handleProps.bind(this); + this.reset = this.reset.bind(this); + this.setSize = this.setSize.bind(this); + this.setValue = this.setValue.bind(this); + } + + componentDidMount() { + Byond.subscribeTo('props', this.handleProps); + Byond.subscribeTo('force', this.handleForceSay); + Byond.subscribeTo('open', this.handleOpen); + } + + handleArrowKeys(direction: KEY.Up | KEY.Down) { + const currentValue = this.innerRef.current?.value; + + if (direction === KEY.Up) { + if (this.chatHistory.isAtLatest() && currentValue) { + // Save current message to temp history if at the most recent message + this.chatHistory.saveTemp(currentValue); + } + // Try to get the previous message, fall back to the current value if none + const prevMessage = this.chatHistory.getOlderMessage(); + + if (prevMessage) { + this.setState({ buttonContent: this.chatHistory.getIndex() }); + this.setSize(prevMessage.length); + this.setValue(prevMessage); + } + } else { + const nextMessage = + this.chatHistory.getNewerMessage() || this.chatHistory.getTemp() || ''; + + const buttonContent = this.chatHistory.isAtLatest() + ? this.channelIterator.current() + : this.chatHistory.getIndex(); + + this.setState({ buttonContent }); + this.setSize(nextMessage.length); + this.setValue(nextMessage); + } + } + + handleBackspaceDelete() { + const typed = this.innerRef.current?.value; + + // User is on a chat history message + if (!this.chatHistory.isAtLatest()) { + this.chatHistory.reset(); + this.setState({ + buttonContent: this.currentPrefix ?? this.channelIterator.current(), + }); + // Empty input, resets the channel + } else if ( + !!this.currentPrefix && + this.channelIterator.isSay() && + typed?.length === 0 + ) { + this.currentPrefix = null; + this.setState({ buttonContent: this.channelIterator.current() }); + } + + this.setSize(typed?.length); + } + + handleClose() { + const current = this.innerRef.current; + + if (current) { + current.blur(); + } + + this.reset(); + this.chatHistory.reset(); + this.channelIterator.reset(); + this.currentPrefix = null; + windowClose(); + } + + handleEnter() { + const prefix = this.currentPrefix ?? ''; + const value = this.innerRef.current?.value; + + if (value?.length && value.length < this.maxLength) { + this.chatHistory.add(value); + Byond.sendMessage('entry', { + channel: this.channelIterator.current(), + entry: this.channelIterator.isSay() ? prefix + value : value, + }); + } + + this.handleClose(); + } + + handleForceSay() { + const currentValue = this.innerRef.current?.value; + // Only force say if we're on a visible channel and have typed something + if (!currentValue || !this.channelIterator.isVisible()) return; + + const prefix = this.currentPrefix ?? ''; + const grunt = this.channelIterator.isSay() + ? prefix + currentValue + : currentValue; + + this.messages.forceSayMsg(grunt); + this.reset(); + } + + handleIncrementChannel() { + // Binary talk is a special case, tell byond to show thinking indicators + if (this.channelIterator.isSay() && this.currentPrefix === ':b ') { + this.messages.channelIncrementMsg(true); + } + + this.currentPrefix = null; + + this.channelIterator.next(); + + // If we've looped onto a quiet channel, tell byond to hide thinking indicators + if (!this.channelIterator.isVisible()) { + this.messages.channelIncrementMsg(false); + } + + this.setState({ buttonContent: this.channelIterator.current() }); + } + + handleInput() { + const typed = this.innerRef.current?.value; + + // If we're typing, send the message + if (this.channelIterator.isVisible() && this.currentPrefix !== ':b ') { + this.messages.typingMsg(); + } + + this.setSize(typed?.length); + + // Is there a value? Is it long enough to be a prefix? + if (!typed || typed.length < 3) { + return; + } + + if (!CHANNEL_REGEX.test(typed)) { + return; + } + + // Is it a valid prefix? + const prefix = typed + .slice(0, 3) + ?.toLowerCase() + ?.replace('.', ':') as keyof typeof RADIO_PREFIXES; + if (!RADIO_PREFIXES[prefix] || prefix === this.currentPrefix) { + return; + } + + // If we're in binary, hide the thinking indicator + if (prefix === ':b ') { + Byond.sendMessage('thinking', { visible: false }); + } + + this.channelIterator.set('Say'); + this.currentPrefix = prefix; + this.setState({ buttonContent: RADIO_PREFIXES[prefix] }); + this.setValue(typed.slice(3)); + } + + handleKeyDown(event: InfernoKeyboardEvent) { + switch (event.key) { + case KEY.Up: + case KEY.Down: + event.preventDefault(); + this.handleArrowKeys(event.key); + break; + + case KEY.Delete: + case KEY.Backspace: + this.handleBackspaceDelete(); + break; + + case KEY.Enter: + event.preventDefault(); + this.handleEnter(); + break; + + case KEY.Tab: + event.preventDefault(); + this.handleIncrementChannel(); + break; + + case KEY.Escape: + this.handleClose(); + break; + } + } + + handleOpen = (data: ByondOpen) => { + setTimeout(() => { + this.innerRef.current?.focus(); + }, 0); + + const { channel } = data; + // Catches the case where the modal is already open + if (this.channelIterator.isSay()) { + this.channelIterator.set(channel); + } + this.setState({ buttonContent: this.channelIterator.current() }); + + windowOpen(this.channelIterator.current()); + }; + + handleProps = (data: ByondProps) => { + const { maxLength, lightMode } = data; + this.maxLength = maxLength; + this.lightMode = !!lightMode; + }; + + reset() { + this.setValue(''); + this.setSize(); + this.setState({ + buttonContent: this.channelIterator.current(), + }); + } + + setSize(length = 0) { + let newSize: WINDOW_SIZES; + + if (length > LINE_LENGTHS.medium) { + newSize = WINDOW_SIZES.large; + } else if (length <= LINE_LENGTHS.medium && length > LINE_LENGTHS.small) { + newSize = WINDOW_SIZES.medium; + } else { + newSize = WINDOW_SIZES.small; + } + + if (this.state.size !== newSize) { + this.setState({ size: newSize }); + windowSet(newSize); + } + } + + setValue(value: string) { + const textArea = this.innerRef.current; + if (textArea) { + textArea.value = value; + } + } + + render() { + const theme = + (this.lightMode && 'lightMode') || + (this.currentPrefix && RADIO_PREFIXES[this.currentPrefix]) || + this.channelIterator.current(); + + return ( +
+ +
+ +
+ +