pages235/src/react/ChatContainer.tsx
2024-02-17 00:41:18 +03:00

302 lines
11 KiB
TypeScript

import { proxy, subscribe, useSnapshot } from 'valtio'
import { useEffect, useMemo, useRef, useState } from 'react'
import { isCypress } from '../standaloneUtils'
import { MessageFormatPart } from '../botUtils'
import { miscUiState } from '../globalState'
import { MessagePart } from './MessageFormatted'
import './ChatContainer.css'
import { isIos } from './utils'
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
}
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 }: Props) => {
const sendHistoryRef = useRef(JSON.parse(window.sessionStorage.chatHistory || '[]'))
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 openedChatWasAtBottom = useRef(false)
const chatHistoryPos = useRef(sendHistoryRef.current.length)
const inputCurrentlyEnteredValue = useRef('')
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)
}
useEffect(() => {
// todo focus input on any keypress except tab
}, [])
const resetCompletionItems = () => {
setCompletionItemsSource([])
setCompletionItems([])
}
useEffect(() => {
if (opened) {
updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = ''
if (!usingTouch) {
chatInput.current.focus()
}
const unsubscribe = subscribe(chatInputValueGlobal, () => {
if (!chatInputValueGlobal.value) return
updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = ''
chatInput.current.focus()
})
return unsubscribe
}
if (!opened && chatMessages.current) {
chatMessages.current.scrollTop = chatMessages.current.scrollHeight
}
}, [opened])
useMemo(() => {
if (opened) {
completeRequestValue.current = ''
resetCompletionItems()
}
}, [opened])
useEffect(() => {
if ((!opened || (opened && openedChatWasAtBottom.current)) && chatMessages.current) {
openedChatWasAtBottom.current = false
// stay at bottom on messages changes
chatMessages.current.scrollTop = chatMessages.current.scrollHeight
}
}, [messages])
useMemo(() => {
if ((opened && chatMessages.current)) {
const wasAtBottom = chatMessages.current.scrollTop === chatMessages.current.scrollHeight - chatMessages.current.clientHeight
openedChatWasAtBottom.current = wasAtBottom
// console.log(wasAtBottom, chatMessages.current.scrollTop, chatMessages.current.scrollHeight - chatMessages.current.clientHeight)
}
}, [messages])
const auxInputFocus = (fireKey: string) => {
chatInput.current.focus()
document.dispatchEvent(new KeyboardEvent('keydown', { code: fireKey }))
}
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 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[]) => {
const newCompleteItems = sourceItems.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))
})
setCompletionItems(newCompleteItems)
}
const onMainInputChange = () => {
const completeValue = getCompleteValue()
setCompletePadText(completeValue === '/' ? '' : completeValue)
if (completeRequestValue.current === completeValue) {
updateFilteredCompleteItems(completionItemsSource)
return
}
if (completeValue.startsWith('/')) {
void fetchCompletions(true)
} else {
resetCompletionItems()
}
completeRequestValue.current = completeValue
// if (completeValue === '/') {
// void fetchCompletions(true)
// }
}
return (
<>
<div className={`chat-wrapper chat-messages-wrapper ${usingTouch ? 'display-mobile' : ''}`} hidden={isCypress()}>
{opacity && <div ref={chatMessages} className={`chat ${opened ? 'opened' : ''}`} id="chat-messages" style={{ opacity }}>
{messages.map((m) => (
<MessageLine key={m.id} message={m} />
))}
</div> || undefined}
</div>
<div className={`chat-wrapper chat-input-wrapper ${usingTouch ? 'input-mobile' : ''}`} hidden={!opened}>
<div className="chat-input">
{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} onClick={() => 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?.()
}
}
}}>
{isIos && <input
value=''
type="text"
className="chat-mobile-hidden"
id="chatinput-next-command"
spellCheck={false}
autoComplete="off"
onFocus={() => auxInputFocus('ArrowUp')}
onChange={() => { }}
/>}
<input
defaultValue=''
ref={chatInput}
type="text"
className="chat-input"
id="chatinput"
spellCheck={false}
autoComplete="off"
aria-autocomplete="both"
onChange={onMainInputChange}
onKeyDown={(e) => {
if (e.code === 'ArrowUp') {
if (chatHistoryPos.current === 0) return
if (chatHistoryPos.current === sendHistoryRef.current.length) { // started navigating history
inputCurrentlyEnteredValue.current = e.currentTarget.value
}
chatHistoryPos.current--
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || '')
} else if (e.code === 'ArrowDown') {
if (chatHistoryPos.current === sendHistoryRef.current.length) return
chatHistoryPos.current++
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
}
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-hidden"
id="chatinput-prev-command"
spellCheck={false}
autoComplete="off"
onFocus={() => auxInputFocus('ArrowDown')}
onChange={() => { }}
/>}
<button type='submit' style={{ visibility: 'hidden' }} />
</form>
</div>
</div>
</>
)
}