pages235/src/react/Chat.tsx
2025-07-20 10:06:57 +03:00

567 lines
20 KiB
TypeScript

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 <li className={Object.entries(classes).filter(([, val]) => 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 <MessagePart key={j} part={part} />
}
return <MessagePart key={j} part={part} />
})
}
}
return <MessagePart key={i} part={msg} />
})}
</li>
}
type Props = {
messages: Message[]
usingTouch: boolean
opacity?: number
opened?: boolean
onClose?: () => void
sendMessage?: (message: string) => Promise<void> | void
fetchCompletionItems?: (triggerKind: 'implicit' | 'explicit', completeValue: string, fullValue: string, abortController?: AbortController) => Promise<string[] | void>
// width?: number
allowSelection?: boolean
inputDisabled?: string
placeholder?: string
chatVanillaRestrictions?: boolean
debugChatScroll?: boolean
getPingComplete?: (value: string) => Promise<string[]>
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<HTMLInputElement>(null!)
const chatMessages = useRef<HTMLDivElement>(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 (
<>
<div
className={`chat-wrapper chat-messages-wrapper ${usingTouch ? 'display-mobile' : ''} ${opened ? 'chat-opened' : ''}`} style={{
userSelect: opened && allowSelection ? 'text' : undefined,
}}
>
{opacity && <div ref={chatMessages} className={`chat ${opened ? 'opened' : ''}`} id="chat-messages" style={{ opacity }}>
{debugChatScroll && (
<div
style={{
position: 'absolute',
top: 5,
left: 5,
display: 'flex',
gap: 4,
zIndex: 100,
}}
>
<div
title="Right now is at bottom (updated every 50ms)"
style={{
width: 12,
height: 12,
backgroundColor: rightNowAtBottom ? '#00ff00' : '#ff0000',
border: '1px solid #fff',
}}
/>
<div
title="Currently at bottom"
style={{
width: 12,
height: 12,
backgroundColor: currentlyAtBottom ? '#00ff00' : '#ff0000',
border: '1px solid #fff',
}}
/>
<div
title="Was at bottom"
style={{
width: 12,
height: 12,
backgroundColor: wasAtBottom() ? '#00ff00' : '#ff0000',
border: '1px solid #fff',
}}
/>
<div
title="Chat opened"
style={{
width: 12,
height: 12,
backgroundColor: opened ? '#00ff00' : '#ff0000',
border: '1px solid #fff',
}}
/>
</div>
)}
{messages.map((m) => (
<MessageLine key={reactKeyForMessage(m)} message={m} currentPlayerName={playerNameValidated} chatOpened={opened} />
))}
</div> || undefined}
</div>
<div className={`chat-wrapper chat-input-wrapper ${usingTouch ? 'input-mobile' : ''}`} hidden={!opened}>
{usingTouch && (
<>
<Button
icon={pixelartIcons.close}
onClick={() => onClose?.()}
style={{
width: 20,
flexShrink: 0,
}}
/>
{(chatInput.current?.value && !chatInput.current.value.startsWith('/')) ? (
// TOGGLE SPELL CHECK
<Button
style={{
width: 20,
flexShrink: 0,
}}
overlayColor={spellCheckEnabled ? '#00ff00' : '#ff0000'}
icon={pixelartIcons['text-wrap']}
onClick={() => {
setPreservedInputValue(chatInput.current?.value || '')
setSpellCheckEnabled(!spellCheckEnabled)
remountInput()
}}
/>
) : (
// SLASH COMMAND
<Button
style={{
width: 20,
flexShrink: 0,
}}
label={chatInput.current?.value ? undefined : '/'}
icon={chatInput.current?.value ? pixelartIcons['arrow-right'] : undefined}
onClick={() => {
const inputValue = chatInput.current.value
if (!inputValue) {
handleSlashCommand()
} else if (completionItems.length > 0) {
handleAcceptFirstCompletion()
}
}}
/>
)}
</>
)}
<div className="chat-input">
{isInputFocused && completionItems?.length ? (
<div className="chat-completions">
<div className="chat-completions-pad-text">{completePadText}</div>
<div className="chat-completions-items">
{completionItems.map((item) => (
<div
key={item}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur before click
acceptComplete(item)
}}
>
{item}
</div>
))}
</div>
</div>
) : null}
<form onSubmit={async (e) => {
e.preventDefault()
const message = chatInput.current.value
if (message) {
setSendHistory([...sendHistoryRef.current, message])
onClose?.()
await sendMessage?.(message)
// Always scroll to bottom after sending a message
scrollToBottom()
}
}}
>
{isIos && <input
value=''
type="text"
className="chat-mobile-input-hidden chat-mobile-input-hidden-up"
id="chatinput-next-command"
spellCheck={false}
autoComplete="off"
onFocus={() => auxInputFocus('up')}
onChange={() => { }}
/>}
<input
maxLength={chatVanillaRestrictions ? 256 : undefined}
defaultValue={preservedInputValue}
// ios doesn't support toggling autoCorrect on the fly so we need to re-create the input
key={`${inputKey}`}
autoCapitalize={preservedInputValue ? 'off' : 'on'}
ref={chatInput}
type="text"
className="chat-input"
id="chatinput"
spellCheck={spellCheckEnabled}
autoCorrect={spellCheckEnabled ? 'on' : 'off'}
autoComplete="off"
aria-autocomplete="both"
onChange={onMainInputChange}
disabled={!!inputDisabled}
placeholder={inputDisabled || placeholder}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => {
if (e.code === 'ArrowUp') {
handleArrowUp()
} else if (e.code === 'ArrowDown') {
handleArrowDown()
}
if (e.code === 'Tab') {
if (completionItemsSource.length) {
if (completionItems.length) {
acceptComplete(completionItems[0])
}
} else {
void fetchCompletions(false)
}
e.preventDefault()
}
if (e.code === 'Space') {
resetCompletionItems()
if (chatInput.current.value.startsWith('/')) {
// alternative we could just simply use keyup, but only with keydown we can display suggestions popup as soon as possible
void fetchCompletions(true, getCompleteValue(getDefaultCompleteValue() + ' '))
}
}
}}
/>
{isIos && <input
value=''
type="text"
className="chat-mobile-input-hidden chat-mobile-input-hidden-down"
id="chatinput-prev-command"
spellCheck={false}
autoComplete="off"
onFocus={() => auxInputFocus('down')}
onChange={() => { }}
/>}
{/* for some reason this is needed to make Enter work on android chrome */}
<button type='submit' className="chat-submit-button" />
</form>
</div>
</div>
</>
)
}