Add character count tracking

This commit is contained in:
Jack Kingsman
2026-01-12 22:15:46 -08:00
parent 9715f7af9b
commit 9f1c925157
5 changed files with 146 additions and 60 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-C9b-ufL4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CacA_plj.css">
<script type="module" crossorigin src="/assets/index-04feYCZ_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DaLCXB8p.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -562,6 +562,8 @@ export function App() {
}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
+102 -18
View File
@@ -1,6 +1,14 @@
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, type FormEvent, type KeyboardEvent } from 'react';
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, useMemo, type FormEvent, type KeyboardEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
// MeshCore message size limits (empirically determined from LoRa packet constraints)
// Total text budget is ~150 bytes. DMs use full budget; channels include "sender: " prefix.
const DM_HARD_LIMIT = 150;
const DM_WARNING_THRESHOLD = 140; // Multi-hop delivery may be impacted
const CHANNEL_WARNING_THRESHOLD = 120; // Conservative limit for channels
const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many chars before hard limit
interface MessageInputProps {
onSend: (text: string) => Promise<void>;
@@ -8,14 +16,20 @@ interface MessageInputProps {
placeholder?: string;
/** When true, input becomes password field for repeater telemetry */
isRepeaterMode?: boolean;
/** 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<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder, isRepeaterMode }, ref) {
function MessageInput({ onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName }, ref) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -28,6 +42,49 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
},
}));
// 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 = 150 - senderName.length - 2 (for ": " separator)
const nameLen = senderName?.length ?? 10;
const hardLimit = Math.max(1, DM_HARD_LIMIT - nameLen - 2);
return {
warningAt: CHANNEL_WARNING_THRESHOLD,
dangerAt: Math.max(1, hardLimit - CHANNEL_DANGER_BUFFER),
hardLimit,
};
}
return null; // Raw/other - no limits
}, [conversationType, senderName]);
// Determine current limit state
const { limitState, warningMessage } = useMemo((): {
limitState: LimitState;
warningMessage: string | null;
} => {
if (!limits) return { limitState: 'normal', warningMessage: null };
const len = text.length;
if (len >= limits.hardLimit) {
return { limitState: 'error', warningMessage: 'likely rejected by radio' };
}
if (len >= limits.dangerAt) {
return { limitState: 'danger', warningMessage: 'may impact multi-repeater hop delivery' };
}
if (len >= limits.warningAt) {
return { limitState: 'warning', warningMessage: 'may impact multi-repeater hop delivery' };
}
return { limitState: 'normal', warningMessage: null };
}, [text.length, limits]);
const remaining = limits ? limits.hardLimit - text.length : 0;
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
@@ -84,23 +141,50 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
? text.trim().length > 0 || text === '.'
: text.trim().length > 0;
// Show character counter for messages (not repeater mode or raw)
const showCharCounter = !isRepeaterMode && limits !== null;
return (
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
<Input
ref={inputRef}
type={isRepeaterMode ? 'password' : 'text'}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || (isRepeaterMode ? 'Enter password (or . for none)...' : 'Type a message...')}
disabled={disabled || sending}
className="flex-1 min-w-0"
/>
<Button type="submit" disabled={disabled || sending || !canSubmit} className="flex-shrink-0">
{sending
? (isRepeaterMode ? 'Fetching...' : 'Sending...')
: (isRepeaterMode ? 'Fetch' : 'Send')}
</Button>
<form className="px-4 py-3 border-t border-border flex flex-col gap-1" onSubmit={handleSubmit}>
<div className="flex gap-2">
<Input
ref={inputRef}
type={isRepeaterMode ? 'password' : 'text'}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || (isRepeaterMode ? 'Enter password (or . for none)...' : 'Type a message...')}
disabled={disabled || sending}
className="flex-1 min-w-0"
/>
<Button type="submit" disabled={disabled || sending || !canSubmit} className="flex-shrink-0">
{sending
? (isRepeaterMode ? 'Fetching...' : 'Sending...')
: (isRepeaterMode ? 'Fetch' : 'Send')}
</Button>
</div>
{showCharCounter && (
<div className="flex items-center justify-end gap-2 text-xs">
<span
className={cn(
'tabular-nums',
limitState === 'error' || limitState === 'danger'
? 'text-red-500 font-medium'
: limitState === 'warning'
? 'text-yellow-500'
: 'text-muted-foreground'
)}
>
{text.length}/{limits!.hardLimit}
{remaining < 0 && ` (${remaining})`}
</span>
{warningMessage && (
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
{warningMessage}
</span>
)}
</div>
)}
</form>
);
});