567 lines
20 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|