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
  • val).map(([name]) => name).join(' ')}> {message.parts.map((msg, i) => )}
  • } 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 // 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(null!) const chatMessages = useRef(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 ( <>
    {opacity &&
    {messages.map((m) => ( ))}
    || undefined}
    ) }