import { proxy, subscribe } from 'valtio' import { useEffect, useMemo, useRef, useState } from 'react' import { isStringAllowed, MessageFormatPart } from '../chatUtils' import { MessagePart } from './MessageFormatted' import './Chat.css' import { isIos, reactKeyForMessage } from './utils' import Button from './Button' import { pixelartIcons } from './PixelartIcon' import { useScrollBehavior } from './hooks/useScrollBehavior' export type Message = { parts: MessageFormatPart[], id: number timestamp?: number } const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Message, currentPlayerName?: string, chatOpened?: boolean }) => { const [fadeState, setFadeState] = useState<'visible' | 'fading' | 'faded'>('visible') useEffect(() => { if (window.debugStopChatFade) return // Start fading after 5 seconds const fadeTimeout = setTimeout(() => { setFadeState('fading') }, 5000) // Remove after fade animation (3s) completes const removeTimeout = setTimeout(() => { setFadeState('faded') }, 8000) // Cleanup timeouts if component unmounts return () => { clearTimeout(fadeTimeout) clearTimeout(removeTimeout) } }, []) // Empty deps array since we only want this to run once when message is added const classes = { 'chat-message': true, 'chat-message-fading': !chatOpened && fadeState === 'fading', 'chat-message-faded': !chatOpened && fadeState === 'faded' } return
  • val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}> {message.parts.map((msg, i) => { // Check if this is a text part that might contain a mention if (typeof msg.text === 'string' && currentPlayerName) { const parts = msg.text.split(new RegExp(`(@${currentPlayerName})`, 'i')) if (parts.length > 1) { return parts.map((txtPart, j) => { const part = { ...msg, text: txtPart } if (txtPart.toLowerCase() === `@${currentPlayerName}`.toLowerCase()) { part.color = '#ffa500' part.bold = true return } return }) } } return })}
  • } type Props = { messages: Message[] usingTouch: boolean opacity?: number opened?: boolean onClose?: () => void sendMessage?: (message: string) => Promise | void fetchCompletionItems?: (triggerKind: 'implicit' | 'explicit', completeValue: string, fullValue: string, abortController?: AbortController) => Promise // width?: number allowSelection?: boolean inputDisabled?: string placeholder?: string chatVanillaRestrictions?: boolean debugChatScroll?: boolean getPingComplete?: (value: string) => Promise currentPlayerName?: string } export const chatInputValueGlobal = proxy({ value: '' }) export default ({ messages, opacity = 1, fetchCompletionItems, opened, sendMessage, onClose, usingTouch, allowSelection, inputDisabled, placeholder, chatVanillaRestrictions, debugChatScroll, getPingComplete, currentPlayerName }: Props) => { const playerNameValidated = useMemo(() => { if (!/^[\w\d_]+$/i.test(currentPlayerName ?? '')) return '' return currentPlayerName }, [currentPlayerName]) const sendHistoryRef = useRef(JSON.parse(window.sessionStorage.chatHistory || '[]')) const [isInputFocused, setIsInputFocused] = useState(false) const [spellCheckEnabled, setSpellCheckEnabled] = useState(false) const [preservedInputValue, setPreservedInputValue] = useState('') const [inputKey, setInputKey] = useState(0) const pingHistoryRef = useRef(JSON.parse(window.localStorage.pingHistory || '[]')) const [completePadText, setCompletePadText] = useState('') const completeRequestValue = useRef('') const [completionItemsSource, setCompletionItemsSource] = useState([] as string[]) const [completionItems, setCompletionItems] = useState([] as string[]) const chatInput = useRef(null!) const chatMessages = useRef(null) const chatHistoryPos = useRef(sendHistoryRef.current.length) const inputCurrentlyEnteredValue = useRef('') const { scrollToBottom, isAtBottom, wasAtBottom, currentlyAtBottom } = useScrollBehavior(chatMessages, { messages, opened }) const [rightNowAtBottom, setRightNowAtBottom] = useState(false) useEffect(() => { if (!debugChatScroll) return const interval = setInterval(() => { setRightNowAtBottom(isAtBottom()) }, 50) return () => clearInterval(interval) }, [debugChatScroll]) const setSendHistory = (newHistory: string[]) => { sendHistoryRef.current = newHistory window.sessionStorage.chatHistory = JSON.stringify(newHistory) chatHistoryPos.current = newHistory.length } const acceptComplete = (item: string) => { const base = completeRequestValue.current === '/' ? '' : getCompleteValue() updateInputValue(base + item) // Record ping completion in history if (item.startsWith('@')) { const newHistory = [item, ...pingHistoryRef.current.filter((x: string) => x !== item)].slice(0, 10) pingHistoryRef.current = newHistory // todo use appStorage window.localStorage.pingHistory = JSON.stringify(newHistory) } chatInput.current.focus() } const updateInputValue = (newValue: string) => { chatInput.current.value = newValue onMainInputChange() setTimeout(() => { chatInput.current.setSelectionRange(newValue.length, newValue.length) }, 0) } const handleArrowUp = () => { if (chatHistoryPos.current === 0) return if (chatHistoryPos.current === sendHistoryRef.current.length) { // started navigating history inputCurrentlyEnteredValue.current = chatInput.current.value } chatHistoryPos.current-- updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || '') } const handleArrowDown = () => { if (chatHistoryPos.current === sendHistoryRef.current.length) return chatHistoryPos.current++ updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '') } const auxInputFocus = (direction: 'up' | 'down') => { chatInput.current.focus() if (direction === 'up') { handleArrowUp() } else { handleArrowDown() } } useEffect(() => { // todo focus input on any keypress except tab }, []) const resetCompletionItems = () => { setCompletionItemsSource([]) setCompletionItems([]) } useEffect(() => { if (opened) { updateInputValue(chatInputValueGlobal.value) chatInputValueGlobal.value = '' chatHistoryPos.current = sendHistoryRef.current.length if (!usingTouch) { chatInput.current.focus() } // Add keyboard event listener for letter keys and paste const handleKeyDown = (e: KeyboardEvent) => { if (['input', 'textarea', 'select'].includes(document.activeElement?.tagName.toLowerCase() ?? '')) return // Check if it's a single character key (works with any layout) without modifiers except shift const isSingleChar = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey // Check if it's paste command const isPaste = e.code === 'KeyV' && (e.ctrlKey || e.metaKey) if ((isSingleChar || isPaste) && document.activeElement !== chatInput.current) { chatInput.current.focus() } } window.addEventListener('keydown', handleKeyDown) const unsubscribeValtio = subscribe(chatInputValueGlobal, () => { if (!chatInputValueGlobal.value) return updateInputValue(chatInputValueGlobal.value) chatInputValueGlobal.value = '' chatInput.current.focus() }) return () => { window.removeEventListener('keydown', handleKeyDown) unsubscribeValtio() } } }, [opened]) useMemo(() => { if (opened) { completeRequestValue.current = '' resetCompletionItems() } else { setPreservedInputValue('') } }, [opened]) const onMainInputChange = () => { const lastWord = chatInput.current.value.slice(0, chatInput.current.selectionEnd ?? chatInput.current.value.length).split(' ').at(-1)! const isCommand = chatInput.current.value.startsWith('/') if (lastWord.startsWith('@') && getPingComplete && !isCommand) { setCompletePadText(lastWord) void fetchPingCompletions(true, lastWord.slice(1)) return } const completeValue = getCompleteValue() setCompletePadText(completeValue === '/' ? '' : completeValue) // not sure if enabling would be useful at all (maybe make as a setting in the future?) // setSpellCheckEnabled(!chatInput.current.value.startsWith('/')) if (completeRequestValue.current === completeValue) { updateFilteredCompleteItems(completionItemsSource) return } if (completeValue.startsWith('/')) { void fetchCompletions(true) } else { resetCompletionItems() } completeRequestValue.current = completeValue } const fetchCompletions = async (implicit: boolean, inputValue = chatInput.current.value) => { const completeValue = getCompleteValue(inputValue) completeRequestValue.current = completeValue resetCompletionItems() const newItems = await fetchCompletionItems?.(implicit ? 'implicit' : 'explicit', completeValue, inputValue) ?? [] if (completeValue !== completeRequestValue.current) return setCompletionItemsSource(newItems) updateFilteredCompleteItems(newItems) } const fetchPingCompletions = async (implicit: boolean, inputValue: string) => { completeRequestValue.current = inputValue resetCompletionItems() const newItems = await getPingComplete?.(inputValue) ?? [] if (inputValue !== completeRequestValue.current) return // Sort items by ping history const sortedItems = [...newItems].sort((a, b) => { const aIndex = pingHistoryRef.current.indexOf(a) const bIndex = pingHistoryRef.current.indexOf(b) if (aIndex === -1 && bIndex === -1) return 0 if (aIndex === -1) return 1 if (bIndex === -1) return -1 return aIndex - bIndex }) setCompletionItemsSource(sortedItems) updateFilteredCompleteItems(sortedItems) } const updateFilteredCompleteItems = (sourceItems: string[] | Array<{ match: string, toolip: string }>) => { const newCompleteItems = sourceItems .map((item): string => (typeof item === 'string' ? item : item.match)) .filter(item => { // this regex is imporatnt is it controls the word matching // const compareableParts = item.split(/[[\]{},_:]/) const lastWord = chatInput.current.value.slice(0, chatInput.current.selectionEnd ?? chatInput.current.value.length).split(' ').at(-1)! if (lastWord.startsWith('@')) { return item.toLowerCase().includes(lastWord.slice(1).toLowerCase()) } return item.includes(lastWord) // return [item, ...compareableParts].some(compareablePart => compareablePart.startsWith(lastWord)) }) setCompletionItems(newCompleteItems) } const getDefaultCompleteValue = () => { const raw = chatInput.current.value return raw.slice(0, chatInput.current.selectionEnd ?? raw.length) } const getCompleteValue = (value = getDefaultCompleteValue()) => { const valueParts = value.split(' ') const lastLength = valueParts.at(-1)!.length const completeValue = lastLength ? value.slice(0, -lastLength) : value if (valueParts.length === 1 && value.startsWith('/')) return '/' return completeValue } const handleSlashCommand = () => { remountInput('/') } const handleAcceptFirstCompletion = () => { if (completionItems.length > 0) { acceptComplete(completionItems[0]) } } const remountInput = (newValue?: string) => { if (newValue !== undefined) { setPreservedInputValue(newValue) } setInputKey(k => k + 1) } useEffect(() => { if (preservedInputValue && chatInput.current) { chatInput.current.focus() } }, [inputKey]) // Changed from spellCheckEnabled to inputKey return ( <>
    {opacity &&
    {debugChatScroll && (
    )} {messages.map((m) => ( ))}
    || undefined}
    ) }