From 25041e1367b4a779684a2bf18969902cc5568ced Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 25 Apr 2026 15:00:36 -0700 Subject: [PATCH] Add dynamic text replacement. Closes #223. --- frontend/src/components/MessageInput.tsx | 33 ++- .../repeater/RepeaterTelemetryPane.tsx | 7 +- .../settings/SettingsLocalSection.tsx | 67 ++++++ frontend/src/test/textReplace.test.ts | 192 ++++++++++++++++++ frontend/src/utils/textReplace.ts | 142 +++++++++++++ 5 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test/textReplace.test.ts create mode 100644 frontend/src/utils/textReplace.ts diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 86721e2..9c6afb0 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -5,6 +5,7 @@ import { forwardRef, useRef, useMemo, + type ChangeEvent, type FormEvent, type KeyboardEvent, } from 'react'; @@ -12,6 +13,11 @@ import { Input } from './ui/input'; import { Button } from './ui/button'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; +import { + getTextReplaceEnabled, + getTextReplaceMapJson, + applyTextReplacements, +} from '../utils/textReplace'; // MeshCore message size limits (empirically determined from LoRa packet constraints) // Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth. @@ -139,6 +145,31 @@ export const MessageInput = forwardRef(fu [text, sending, disabled, onSend] ); + const handleChange = useCallback((e: ChangeEvent) => { + const input = e.target; + const raw = input.value; + // Skip replacement during IME / dead-key composition to avoid garbling interim input + if (!e.nativeEvent || (e.nativeEvent as InputEvent).isComposing) { + setText(raw); + return; + } + if (getTextReplaceEnabled()) { + const result = applyTextReplacements( + raw, + input.selectionStart ?? raw.length, + getTextReplaceMapJson() + ); + if (result) { + setText(result.text); + // Schedule cursor restore after React flushes the new value + const pos = result.cursor; + requestAnimationFrame(() => input.setSelectionRange(pos, pos)); + return; + } + } + setText(raw); + }, []); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -173,7 +204,7 @@ export const MessageInput = forwardRef(fu data-1p-ignore="true" data-bwignore="true" value={text} - onChange={(e) => setText(e.target.value)} + onChange={handleChange} onKeyDown={handleKeyDown} placeholder={placeholder || 'Type a message...'} disabled={disabled || sending} diff --git a/frontend/src/components/repeater/RepeaterTelemetryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryPane.tsx index 51ef4a3..80a6481 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryPane.tsx @@ -99,7 +99,12 @@ export function TelemetryPane({ {data.recv_errors.toLocaleString()} {data.packets_received > 0 && ( - ({((data.recv_errors / (data.packets_received + data.recv_errors)) * 100).toFixed(2)}%) + ( + {( + (data.recv_errors / (data.packets_received + data.recv_errors)) * + 100 + ).toFixed(2)} + %) )} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 6fd9b5c..ad7b8c1 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -33,6 +33,13 @@ import { setSavedFontScale, } from '../../utils/fontScale'; import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput'; +import { + getTextReplaceEnabled, + setTextReplaceEnabled as saveTextReplaceEnabled, + getTextReplaceMapJson, + setTextReplaceMapJson, + DEFAULT_MAP_JSON, +} from '../../utils/textReplace'; import { BATTERY_DISPLAY_CHANGE_EVENT, getShowBatteryPercent, @@ -232,6 +239,9 @@ export function SettingsLocalSection({ const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent); const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage); const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled); + const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled); + const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson); + const [textReplaceError, setTextReplaceError] = useState(null); const [fontScale, setFontScale] = useState(getSavedFontScale); const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale); const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale())); @@ -439,6 +449,63 @@ export function SettingsLocalSection({

+ +
+
+ { + const v = checked === true; + setTextReplaceEnabled(v); + saveTextReplaceEnabled(v); + }} + className="mt-0.5" + /> +
+ +

+ Automatically replace characters as you type in the message input. Define + replacements as a JSON object mapping source strings to their replacements. +

+
+
+ {textReplaceEnabled && ( +
+