mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 14:02:26 +02:00
Add character count tracking
This commit is contained in:
+39
-39
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user