fix(settings): Preferences stopped lagging behind the keyboard
Typing a cluster macro cost two things per character. The state lived on SettingsModal, so every keystroke in one of those twenty-four boxes re-rendered the WHOLE preferences dialog — every list, every form, every panel. And the save wrote through to Go and into the database on each one: a round trip per character. The editor is now its own module-scoped component with its own state, so a keystroke re-renders twelve rows. And writeUiPrefDebounced holds the database write until the typing stops, while the local cache — which is what everything reads back — is still written at once. Pending values are flushed when the page goes away, so typing and immediately closing does not lose the last word. The behaviour is unchanged: still saved as you type, still no Save button, because a text box whose contents only take effect on some other button is how work gets lost. Also folds the satellite changelog into one [NEW] entry. Satellites are new in this version — nobody reading the notes has seen any of it — so a running account of how it was built, tab then tracking then rotator then where the settings moved to, is the wrong shape. One entry saying what it does.
This commit is contained in:
@@ -916,6 +916,66 @@ const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
|
||||
const relayCountUI = (type: string) =>
|
||||
type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5;
|
||||
|
||||
// The twelve cluster command macros.
|
||||
//
|
||||
// Module-scoped, with its own state, and that is the point rather than tidiness:
|
||||
// nested inside SettingsModal every keystroke in one of these twenty-four boxes
|
||||
// re-rendered the WHOLE preferences dialog — every list, every form, every
|
||||
// panel — and the letters arrived visibly after the finger had left the key.
|
||||
// Here a keystroke re-renders twelve rows.
|
||||
//
|
||||
// Written on every keystroke as before. This panel has no Save button, and a
|
||||
// text box whose contents only take effect on some other button is how work
|
||||
// gets lost; the database write is what waits (see saveClusterMacros).
|
||||
function ClusterMacroEditor() {
|
||||
const { t } = useI18n();
|
||||
const [macros, setMacros] = useState<ClusterMacro[]>(loadClusterMacros);
|
||||
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
|
||||
setMacros((cur) => {
|
||||
const next = cur.map((m, j) => (j === i ? { ...m, ...patch } : m));
|
||||
saveClusterMacros(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{t('clu.macros')}</span>
|
||||
</div>
|
||||
{/* Two columns of six: twelve rows stacked would push everything else
|
||||
in this panel off the screen. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{macros.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
|
||||
<Input
|
||||
className="h-8 w-28 shrink-0 text-xs"
|
||||
placeholder={t('clu.macroLabel')}
|
||||
value={m.label}
|
||||
maxLength={24}
|
||||
onChange={(e) => setMacro(i, { label: e.target.value })}
|
||||
/>
|
||||
{/* 500, not 120. A DXSpider filter is a list of prefixes and an
|
||||
operator's own list of wanted countries runs past a hundred
|
||||
characters easily — the field simply stopped accepting
|
||||
keystrokes, with nothing to say why, and the command was saved
|
||||
truncated. The title shows the whole thing, since the box
|
||||
cannot. */}
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 font-mono text-xs"
|
||||
placeholder={t('clu.macroCmd')}
|
||||
value={m.cmd}
|
||||
title={m.cmd}
|
||||
maxLength={500}
|
||||
onChange={(e) => setMacro(i, { cmd: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
|
||||
// isn't remounted on every parent render. Polls once a second while shown.
|
||||
@@ -1950,9 +2010,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
||||
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
|
||||
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
||||
// function by the PANELS map, so it must stay hooks-free.
|
||||
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||
// Password-encryption (secret vault) state.
|
||||
@@ -5975,14 +6032,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
|
||||
function ClusterPanel() {
|
||||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
// Written on every keystroke. This panel has no Save button, and a pair of
|
||||
// text boxes whose contents only take effect on some other button is how
|
||||
// work gets lost.
|
||||
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
|
||||
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
|
||||
setClusterMacros(next);
|
||||
saveClusterMacros(next);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
@@ -6075,41 +6124,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
{t('clu.autoConnect')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{t('clu.macros')}</span>
|
||||
</div>
|
||||
{/* Two columns of six: twelve rows stacked would push everything else
|
||||
in this panel off the screen. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{clusterMacros.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
|
||||
<Input
|
||||
className="h-8 w-28 shrink-0 text-xs"
|
||||
placeholder={t('clu.macroLabel')}
|
||||
value={m.label}
|
||||
maxLength={24}
|
||||
onChange={(e) => setMacro(i, { label: e.target.value })}
|
||||
/>
|
||||
{/* 500, not 120. A DXSpider filter is a list of prefixes and
|
||||
an operator's own list of wanted countries runs past a
|
||||
hundred characters easily — the field simply stopped
|
||||
accepting keystrokes, with nothing to say why, and the
|
||||
command was saved truncated. The title shows the whole
|
||||
thing, since the box cannot. */}
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 font-mono text-xs"
|
||||
placeholder={t('clu.macroCmd')}
|
||||
value={m.cmd}
|
||||
title={m.cmd}
|
||||
maxLength={500}
|
||||
onChange={(e) => setMacro(i, { cmd: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ClusterMacroEditor />
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
||||
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Stored through writeUiPref like every other portable preference, so the
|
||||
// buttons travel with data/ rather than living in one browser profile.
|
||||
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { writeUiPrefDebounced } from '@/lib/uiPref';
|
||||
|
||||
export type ClusterMacro = {
|
||||
label: string; // what the button says
|
||||
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Debounced, because this is called on every keystroke in twenty-four text
|
||||
// boxes. The local cache is written at once — it is what everything reads back
|
||||
// — and only the database write waits for the typing to stop. A round trip into
|
||||
// Go per character is what "the letters appear after I have moved on" was.
|
||||
export function saveClusterMacros(macros: ClusterMacro[]): void {
|
||||
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
|
||||
writeUiPrefDebounced(clusterMacrosKey, JSON.stringify(macros));
|
||||
}
|
||||
|
||||
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
|
||||
|
||||
@@ -93,6 +93,50 @@ export async function syncPortablePrefs(): Promise<void> {
|
||||
}));
|
||||
}
|
||||
|
||||
// writeUiPrefDebounced is writeUiPref for a value that changes AS SOMEBODY
|
||||
// TYPES.
|
||||
//
|
||||
// The local cache is written at once, because that is what the interface reads
|
||||
// back and it costs nothing. The DATABASE write is held until the typing stops:
|
||||
// writeUiPref crosses into Go and writes a row, and doing that per character in
|
||||
// a text box is a round trip per keystroke — twenty-four boxes of cluster
|
||||
// macros was exactly that, and it showed as characters appearing after the
|
||||
// finger had left the key.
|
||||
//
|
||||
// Pending writes are flushed when the page goes away, so a value typed and
|
||||
// immediately followed by a close is not lost.
|
||||
const pendingPrefs = new Map<string, { value: string; timer: number }>();
|
||||
|
||||
export function writeUiPrefDebounced(key: string, value: string, ms = 400): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||
const prev = pendingPrefs.get(key);
|
||||
if (prev) window.clearTimeout(prev.timer);
|
||||
const timer = window.setTimeout(() => {
|
||||
pendingPrefs.delete(key);
|
||||
SetUIPref(key, value).catch((e: any) => {
|
||||
try { LogUIError('ui pref', 'could not store ' + key + ': ' + String(e?.message ?? e), ''); } catch { /* nothing left to try */ }
|
||||
});
|
||||
}, ms);
|
||||
pendingPrefs.set(key, { value, timer });
|
||||
}
|
||||
|
||||
// flushUiPrefs writes every pending value immediately.
|
||||
export function flushUiPrefs(): void {
|
||||
for (const [key, p] of pendingPrefs) {
|
||||
window.clearTimeout(p.timer);
|
||||
SetUIPref(key, p.value).catch(() => { /* the local cache still holds it */ });
|
||||
}
|
||||
pendingPrefs.clear();
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', flushUiPrefs);
|
||||
// Closing the app does not always fire beforeunload in a WebView; a hidden
|
||||
// page is the earlier and more reliable signal.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flushUiPrefs();
|
||||
});
|
||||
}
|
||||
|
||||
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||
export function writeUiPref(key: string, value: string): void {
|
||||
|
||||
Reference in New Issue
Block a user