import { useState, useCallback, useImperativeHandle, forwardRef, useRef, useMemo, type FormEvent, type KeyboardEvent, } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Send, Lock } from 'lucide-react'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; // MeshCore message size limits (empirically determined from LoRa packet constraints) const DM_HARD_LIMIT = 156; const DM_WARNING_THRESHOLD = 140; const CHANNEL_HARD_LIMIT = 156; const CHANNEL_WARNING_THRESHOLD = 120; const CHANNEL_DANGER_BUFFER = 8; const textEncoder = new TextEncoder(); function byteLen(s: string): number { return textEncoder.encode(s).length; } interface MessageInputProps { onSend: (text: string) => Promise; disabled: boolean; placeholder?: string; isRepeaterMode?: boolean; conversationType?: 'contact' | 'channel' | 'raw'; senderName?: string; } type LimitState = 'normal' | 'warning' | 'danger' | 'error'; export interface MessageInputHandle { appendText: (text: string) => void; } export const MessageInput = forwardRef(function MessageInput( { onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName }, ref ) { const [text, setText] = useState(''); const [sending, setSending] = useState(false); const inputRef = useRef(null); useImperativeHandle(ref, () => ({ appendText: (appendedText: string) => { setText((prev) => prev + appendedText); inputRef.current?.focus(); }, })); const limits = useMemo(() => { if (conversationType === 'contact') { return { warningAt: DM_WARNING_THRESHOLD, dangerAt: DM_HARD_LIMIT, hardLimit: DM_HARD_LIMIT, }; } else if (conversationType === 'channel') { const nameByteLen = senderName ? byteLen(senderName) : 10; const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameByteLen - 2); return { warningAt: CHANNEL_WARNING_THRESHOLD, dangerAt: Math.max(1, hardLimit - CHANNEL_DANGER_BUFFER), hardLimit, }; } return null; }, [conversationType, senderName]); const textByteLen = useMemo(() => byteLen(text), [text]); const { limitState, warningMessage } = useMemo((): { limitState: LimitState; warningMessage: string | null; } => { if (!limits) return { limitState: 'normal', warningMessage: null }; if (textByteLen >= limits.hardLimit) { return { limitState: 'error', warningMessage: 'likely truncated' }; } if (textByteLen >= limits.dangerAt) { return { limitState: 'danger', warningMessage: 'multi-hop risk' }; } if (textByteLen >= limits.warningAt) { return { limitState: 'warning', warningMessage: 'multi-hop risk' }; } return { limitState: 'normal', warningMessage: null }; }, [textByteLen, limits]); const remaining = limits ? limits.hardLimit - textByteLen : 0; const handleSubmit = useCallback( async (e: FormEvent) => { e.preventDefault(); const trimmed = text.trim(); if (isRepeaterMode) { if (sending || disabled) return; setSending(true); try { await onSend(trimmed); setText(''); } catch (err) { console.error('Failed to request telemetry:', err); toast.error('Failed to request telemetry', { description: err instanceof Error ? err.message : 'Check radio connection', }); return; } finally { setSending(false); } setTimeout(() => inputRef.current?.focus(), 0); } else { if (!trimmed || sending || disabled) return; setSending(true); try { await onSend(trimmed); setText(''); } catch (err) { console.error('Failed to send message:', err); toast.error('Failed to send message', { description: err instanceof Error ? err.message : 'Check radio connection', }); return; } finally { setSending(false); } setTimeout(() => inputRef.current?.focus(), 0); } }, [text, sending, disabled, onSend, isRepeaterMode] ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e as unknown as FormEvent); } }, [handleSubmit] ); const canSubmit = isRepeaterMode ? true : text.trim().length > 0; const showCharCounter = !isRepeaterMode && limits !== null && textByteLen > 0; return (
{/* Input container with glow */}
0 ? 'bg-secondary/40 border-primary/25 shadow-glow-amber-sm' : 'bg-secondary/30 border-border/50 focus-within:border-primary/30 focus-within:shadow-glow-amber-sm' )} > {isRepeaterMode && ( )} setText(e.target.value)} onKeyDown={handleKeyDown} placeholder={ placeholder || (isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...') } disabled={disabled || sending} className={cn( 'w-full h-10 bg-transparent rounded-xl text-sm placeholder:text-muted-foreground/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40', isRepeaterMode ? 'pl-9 pr-3' : 'px-4' )} />
{/* Send button */} {sending ? (
) : isRepeaterMode ? ( <> {text.trim() ? 'Login' : 'Guest'} ) : ( <> Send )}
{/* Character counter */} {showCharCounter && ( {textByteLen}/{limits!.hardLimit}b{remaining < 0 && ` (${remaining})`} {warningMessage && ( {warningMessage} )} )}
); });