fix: disable annoying in many cases auto correct on ios (more annoying than useful especially in commands) fix: make stats dont overlap with chat fix: fix edgecases when focusing on chat was not possible on mobile
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
import { proxy, subscribe } from 'valtio'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { 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
|
|
fading?: boolean
|
|
faded?: boolean
|
|
}
|
|
|
|
const MessageLine = ({ message }: { message: Message }) => {
|
|
const classes = {
|
|
'chat-message-fadeout': message.fading,
|
|
'chat-message-fade': message.fading,
|
|
'chat-message-faded': message.faded,
|
|
'chat-message': true
|
|
}
|
|
|
|
return <li className={Object.entries(classes).filter(([, val]) => val).map(([name]) => name).join(' ')}>
|
|
{message.parts.map((msg, i) => <MessagePart key={i} part={msg} />)}
|
|
</li>
|
|
}
|
|
|
|
type Props = {
|
|
messages: Message[]
|
|
usingTouch: boolean
|
|
opacity?: number
|
|
opened?: boolean
|
|
onClose?: () => void
|
|
sendMessage?: (message: string) => boolean | void
|
|
fetchCompletionItems?: (triggerKind: 'implicit' | 'explicit', completeValue: string, fullValue: string, abortController?: AbortController) => Promise<string[] | void>
|
|
// width?: number
|
|
allowSelection?: boolean
|
|
inputDisabled?: string
|
|
placeholder?: string
|
|
}
|
|
|
|
export const chatInputValueGlobal = proxy({
|
|
value: ''
|
|
})
|
|
|
|
export const fadeMessage = (message: Message, initialTimeout: boolean, requestUpdate: () => void) => {
|
|
setTimeout(() => {
|
|
message.fading = true
|
|
requestUpdate()
|
|
setTimeout(() => {
|
|
message.faded = true
|
|
requestUpdate()
|
|
}, 3000)
|
|
}, initialTimeout ? 5000 : 0)
|
|
}
|
|
|
|
export default ({
|
|
messages,
|
|
opacity = 1,
|
|
fetchCompletionItems,
|
|
opened,
|
|
sendMessage,
|
|
onClose,
|
|
usingTouch,
|
|
allowSelection,
|
|
inputDisabled,
|
|
placeholder
|
|
}: Props) => {
|
|
const sendHistoryRef = useRef(JSON.parse(window.sessionStorage.chatHistory || '[]'))
|
|
const [isInputFocused, setIsInputFocused] = useState(false)
|
|
// const [spellCheckEnabled, setSpellCheckEnabled] = useState(false)
|
|
const spellCheckEnabled = false
|
|
|
|
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 } = useScrollBehavior(chatMessages, { messages, opened })
|
|
|
|
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)
|
|
// todo would be cool but disabled because some comands don't need args (like ping)
|
|
// // trigger next tab complete
|
|
// this.chatInput.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' }))
|
|
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()
|
|
}
|
|
}, [opened])
|
|
|
|
const onMainInputChange = () => {
|
|
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
|
|
// if (completeValue === '/') {
|
|
// void fetchCompletions(true)
|
|
// }
|
|
}
|
|
|
|
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 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)!
|
|
// return [item, ...compareableParts].some(compareablePart => compareablePart.startsWith(lastWord))
|
|
return item.includes(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
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`chat-wrapper chat-messages-wrapper ${usingTouch ? 'display-mobile' : ''}`} style={{
|
|
userSelect: opened && allowSelection ? 'text' : undefined,
|
|
}}
|
|
>
|
|
{opacity && <div ref={chatMessages} className={`chat ${opened ? 'opened' : ''}`} id="chat-messages" style={{ opacity }}>
|
|
{messages.map((m) => (
|
|
<MessageLine key={reactKeyForMessage(m)} message={m} />
|
|
))}
|
|
</div> || undefined}
|
|
</div>
|
|
|
|
<div className={`chat-wrapper chat-input-wrapper ${usingTouch ? 'input-mobile' : ''}`} hidden={!opened}>
|
|
{/* close button */}
|
|
{usingTouch && <Button icon={pixelartIcons.close} onClick={() => onClose?.()} />}
|
|
<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={(e) => {
|
|
e.preventDefault()
|
|
const message = chatInput.current.value
|
|
if (message) {
|
|
setSendHistory([...sendHistoryRef.current, message])
|
|
const result = sendMessage?.(message)
|
|
if (result !== false) {
|
|
onClose?.()
|
|
}
|
|
// 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
|
|
defaultValue=''
|
|
// ios doesn't support toggling autoCorrect on the fly so we need to re-create the input
|
|
key={spellCheckEnabled ? 'true' : 'false'}
|
|
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>
|
|
</>
|
|
)
|
|
}
|