import { useState, useCallback, useImperativeHandle, forwardRef, useRef, useMemo, type FormEvent, type KeyboardEvent, } from 'react'; import { Input } from './ui/input'; import { Button } from './ui/button'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; // MeshCore message size limits (empirically determined from LoRa packet constraints) // Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth. // Channels include "sender: " prefix in the encrypted payload. // All limits are in bytes (UTF-8), not characters, since LoRa packets are byte-constrained. const DM_HARD_LIMIT = 156; // Max bytes for direct delivery const DM_WARNING_THRESHOLD = 140; // Conservative for multi-hop const CHANNEL_HARD_LIMIT = 156; // Base byte limit before sender overhead const CHANNEL_WARNING_THRESHOLD = 120; // Conservative for multi-hop const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many bytes before hard limit const textEncoder = new TextEncoder(); /** Get UTF-8 byte length of a string (LoRa packets are byte-constrained, not character-constrained). */ function byteLen(s: string): number { return textEncoder.encode(s).length; } interface MessageInputProps { onSend: (text: string) => Promise; disabled: boolean; placeholder?: string; /** Conversation type for character limit calculation */ conversationType?: 'contact' | 'channel' | 'raw'; /** Sender name (radio name) for channel message limit calculation */ senderName?: string; } type LimitState = 'normal' | 'warning' | 'danger' | 'error'; export interface MessageInputHandle { appendText: (text: string) => void; } export const MessageInput = forwardRef(function MessageInput( { onSend, disabled, placeholder, 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); // Focus the input after appending inputRef.current?.focus(); }, })); // Calculate character limits based on conversation type const limits = useMemo(() => { if (conversationType === 'contact') { return { warningAt: DM_WARNING_THRESHOLD, dangerAt: DM_HARD_LIMIT, // Same as hard limit for DMs (no intermediate red zone) hardLimit: DM_HARD_LIMIT, }; } else if (conversationType === 'channel') { // Channel hard limit = 156 bytes - senderName bytes - 2 (for ": " separator) 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; // Raw/other - no limits }, [conversationType, senderName]); // UTF-8 byte length of the current text (LoRa packets are byte-constrained) const textByteLen = useMemo(() => byteLen(text), [text]); // Determine current limit state 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 by radio' }; } if (textByteLen >= limits.dangerAt) { return { limitState: 'danger', warningMessage: 'may impact multi-repeater hop delivery' }; } if (textByteLen >= limits.warningAt) { return { limitState: 'warning', warningMessage: 'may impact multi-repeater hop delivery' }; } 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 (!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); } // Refocus after React re-enables the input setTimeout(() => inputRef.current?.focus(), 0); }, [text, sending, disabled, onSend] ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e as unknown as FormEvent); } }, [handleSubmit] ); const canSubmit = text.trim().length > 0; // Show counter for messages (not raw). // Desktop: always visible. Mobile: only show count after 100 characters. const showCharCounter = limits !== null; const showMobileCounterValue = text.length > 100; return (
setText(e.target.value)} onKeyDown={handleKeyDown} placeholder={placeholder || 'Type a message...'} disabled={disabled || sending} className="flex-1 min-w-0" />
{showCharCounter && ( <>
{textByteLen}/{limits!.hardLimit} {remaining < 0 && ` (${remaining})`} {warningMessage && ( — {warningMessage} )}
{(showMobileCounterValue || warningMessage) && (
{showMobileCounterValue && ( {textByteLen}/{limits!.hardLimit} {remaining < 0 && ` (${remaining})`} )} {warningMessage && ( — {warningMessage} )}
)} )}
); });