chore: release v0.26.0
This commit is contained in:
+233
-16
@@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { AnswerDecode, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { applyMatrixColors } from '@/lib/matrixColors';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||
@@ -108,6 +108,8 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { GridSquareMap } from '@/components/GridSquareMap';
|
||||
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
|
||||
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
@@ -1036,6 +1038,22 @@ export default function App() {
|
||||
// unlike Statistics, which is consulted and closed, this one is a panel an
|
||||
// operator running digital modes leaves open for the session.
|
||||
const [decodesTabOpen, setDecodesTabOpen] = useState(() => localStorage.getItem('opslog.decodesTab') === '1');
|
||||
// The grid-square map is its OWN closable tab, not a column beside the decode
|
||||
// table. It is an analysis view — consulted a few times an evening — and the
|
||||
// space beside the table is worth far more to a second receiver's decodes when
|
||||
// two bands are running. Full width also makes it legible, which it was not in
|
||||
// a 38 % column.
|
||||
const [gridsTabOpen, setGridsTabOpen] = useState(() => localStorage.getItem('opslog.gridsTab') === '1');
|
||||
function openGridsTab() {
|
||||
setGridsTabOpen(true);
|
||||
writeUiPref('opslog.gridsTab', '1');
|
||||
setActiveTab('grids');
|
||||
}
|
||||
function closeGridsTab() {
|
||||
setGridsTabOpen(false);
|
||||
writeUiPref('opslog.gridsTab', '0');
|
||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
||||
}
|
||||
function openDecodesTab() {
|
||||
setDecodesTabOpen(true);
|
||||
writeUiPref('opslog.decodesTab', '1');
|
||||
@@ -1828,6 +1846,7 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
|
||||
// Openings under way, for the blinking status-bar badge. Polled rather than
|
||||
// event-driven: the badge also has to go OUT when a band goes quiet, and
|
||||
// nothing emits an event for something that stopped happening.
|
||||
@@ -2068,7 +2087,99 @@ export default function App() {
|
||||
// The LIVE transmit state, replaced on every Status — what is going out now
|
||||
// and to whom, which the period history cannot answer between overs.
|
||||
const [txState, setTxState] = useState<TxMsgRow | null>(null);
|
||||
// The same, per receiver. With two instances the single txState is whichever
|
||||
// one reported last, so a split view cannot say WHICH is on the air — which is
|
||||
// the only question worth asking when both are calling.
|
||||
const [txStates, setTxStates] = useState<Record<string, TxMsgRow>>({});
|
||||
useEffect(() => { decodesRef.current = decodes; }, [decodes]);
|
||||
|
||||
// ── Auto-call ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Answers a decode without the operator clicking it. The DECISION lives in
|
||||
// lib/autocall (one pure function, so the dangerous part can be read and
|
||||
// argued with); this is only the plumbing that runs it and keys the radio.
|
||||
//
|
||||
// Re-read when Preferences closes, like every other setting edited there.
|
||||
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
|
||||
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
|
||||
// When each callsign was last answered, so a station still calling CQ is not
|
||||
// re-answered every slot while the QSO it started is still running.
|
||||
const autoCalledRef = useRef<Map<string, number>>(new Map());
|
||||
// Decodes are scanned once. Without this the same decode is reconsidered on
|
||||
// every status refresh, and a cooldown that has just expired would fire again
|
||||
// on a decode minutes old.
|
||||
const autoSeenRef = useRef<Set<string>>(new Set());
|
||||
// Set when a call goes out, so nothing else fires until the receiver's own
|
||||
// status catches up and `busy` can be trusted again.
|
||||
const autoHoldUntilRef = useRef(0);
|
||||
// Per receiver, when the carrier was last up. Feeds the stale-exchange
|
||||
// backstop below.
|
||||
const lastTxAtRef = useRef<Map<string, number>>(new Map());
|
||||
useEffect(() => {
|
||||
if (!autoCall.enabled) return;
|
||||
const now = Date.now();
|
||||
// A QSO is in progress somewhere if ANY receiver is transmitting or still
|
||||
// holding a DX call it has not finished with. Both matter: between overs the
|
||||
// carrier is down but the exchange is not over, and calling someone else
|
||||
// then is exactly the "it never stops" behaviour.
|
||||
const busy = Object.values(txStates).some((tx) => {
|
||||
if (tx?.transmitting) return true;
|
||||
// A DX call still set means the exchange is not finished — but ONLY while
|
||||
// the sender still intends to transmit. The watchdog stops transmission
|
||||
// and leaves the DX call behind, and reading the call alone left auto-call
|
||||
// waiting for a QSO that had already been given up on, for ever.
|
||||
if (!tx?.dx_call?.trim()) return false;
|
||||
if (tx.tx_enabled === false) return false;
|
||||
// Backstop for a sender that never reports the toggle: an exchange with no
|
||||
// transmission for three minutes is over, whatever the DX call still says.
|
||||
// Measured from the last TIME THE CARRIER WAS UP — Status itself arrives
|
||||
// every second and so can never go stale.
|
||||
const last = lastTxAtRef.current.get(tx.instance ?? '');
|
||||
return last === undefined || now - last < 180_000;
|
||||
})
|
||||
// The hold closes the gap between sending a Reply and the receiver saying
|
||||
// it has acted on it — about a second. Without it the OTHER instance still
|
||||
// looks idle in that window and gets a call of its own.
|
||||
|| now < autoHoldUntilRef.current;
|
||||
for (const d of decodes) {
|
||||
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
|
||||
if (autoSeenRef.current.has(seenKey)) continue;
|
||||
// Only decodes from the CURRENT period are worth answering: replying to a
|
||||
// slot that has closed asks the far end to match a decode it has dropped.
|
||||
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
|
||||
const e = spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
||||
// NOT marked seen until its status has resolved. Statuses land a few
|
||||
// hundred milliseconds after the decode, so consuming it on first sight
|
||||
// would throw away almost every decode unjudged — the effect re-runs when
|
||||
// spotStatus changes, and this is what lets it look again.
|
||||
if (!e) continue;
|
||||
autoSeenRef.current.add(seenKey);
|
||||
const verdict = shouldAutoCall(autoCall, d, e as any, {
|
||||
busy,
|
||||
calledAt: autoCalledRef.current,
|
||||
now,
|
||||
myCall: station.callsign,
|
||||
});
|
||||
if (!verdict.call) continue;
|
||||
autoCalledRef.current.set(d.call.toUpperCase(), now);
|
||||
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
|
||||
// Same reason as a manual click: put the transmitter on the decode's band
|
||||
// before answering, or a second slice answers on the wrong one.
|
||||
FlexTXOnBand(d.band ?? '').catch(() => {});
|
||||
// Logged, always: an automatic transmission with no record of WHY is the
|
||||
// one thing an operator cannot argue with after the fact.
|
||||
LogUIError('auto-call', `calling ${d.call} — ${verdict.reason}`, '');
|
||||
AnswerDecode(
|
||||
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
|
||||
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
|
||||
).catch((e2: any) => setError(String(e2?.message ?? e2)));
|
||||
onCallsignInput(d.call, { force: true });
|
||||
break; // one per pass: a period can hold several, and we work one station
|
||||
}
|
||||
// The seen-set is bounded by the same half hour the decode list keeps.
|
||||
if (autoSeenRef.current.size > 20000) autoSeenRef.current.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus, autoCall, txStates]);
|
||||
// Staged like the cluster's, so a period arriving as one burst of fifty
|
||||
// packets costs one status lookup and one render, not fifty of each.
|
||||
const pendingDecodesRef = useRef<DecodeRow[]>([]);
|
||||
@@ -2213,6 +2324,11 @@ export default function App() {
|
||||
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
|
||||
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
|
||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
// Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass
|
||||
// already draws exactly that when it is given neither presets nor onStop —
|
||||
// withholding them IS the compact mode, so there is no second layout to keep
|
||||
// in step with the first.
|
||||
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
||||
|
||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||
// picked award references to the QSO field/extras each award actually reads.
|
||||
@@ -3131,6 +3247,14 @@ export default function App() {
|
||||
// even between overs, and on a sender that never reports its transmit
|
||||
// text at all.
|
||||
setTxState(m as TxMsgRow);
|
||||
if (m?.instance) {
|
||||
setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow }));
|
||||
// When this receiver last actually TRANSMITTED, which is not the same as
|
||||
// when it last spoke: Status arrives about once a second whether the
|
||||
// carrier is up or not, so it can never say how long an exchange has
|
||||
// been stalled.
|
||||
if (m.transmitting) lastTxAtRef.current.set(m.instance, Date.now());
|
||||
}
|
||||
// The period history takes only real transmissions — Status repeats
|
||||
// itself once a second whether the carrier is up or not.
|
||||
if (!m?.transmitting || !String(m?.msg ?? '').trim()) return;
|
||||
@@ -4313,6 +4437,7 @@ export default function App() {
|
||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
|
||||
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
@@ -4363,6 +4488,7 @@ export default function App() {
|
||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||
case 'tools.decodes': openDecodesTab(); break;
|
||||
case 'tools.grids': openGridsTab(); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
@@ -5192,18 +5318,26 @@ export default function App() {
|
||||
|| (!!e?.new_grid && clusterStatusFilter.has('new-grid'));
|
||||
if (!matches) return false;
|
||||
}
|
||||
// LoTW only, and the spotter's continent. Both are properties of the
|
||||
// station rather than judgements about the spot, so they AND with the
|
||||
// status chips instead of joining that OR: "a new band, and from Europe".
|
||||
if (clusterLotwOnly || clusterSpotterConts.size > 0) {
|
||||
// The spotter's continent comes from the SPOT, never from spotStatus.
|
||||
// spotStatus is keyed by call|band|mode, and one DX call is spotted by
|
||||
// skimmers on every continent within the same minute — so they all shared
|
||||
// whichever spotter arrived first, and picking AF left the Indian and
|
||||
// American skimmers exactly where they were. The spot carries its own.
|
||||
//
|
||||
// A spot whose spotter could not be resolved IS dropped here: the value
|
||||
// arrives with the row, so there is no window to flicker through, and
|
||||
// silently keeping unresolved rows is what made the old bug invisible.
|
||||
if (clusterSpotterConts.size > 0) {
|
||||
if (!clusterSpotterConts.has((s as any).spotter_continent ?? '')) return false;
|
||||
}
|
||||
// LoTW is a property of the DX station, so it stays on the status entry —
|
||||
// where call|band|mode is exactly the right key.
|
||||
if (clusterLotwOnly) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
// An unresolved spot is not filtered out. The status arrives a moment
|
||||
// after the row does, and dropping it meanwhile made the list flicker.
|
||||
if (e) {
|
||||
if (clusterLotwOnly && !e.lotw) return false;
|
||||
if (clusterSpotterConts.size > 0 && e.spotter_continent && !clusterSpotterConts.has(e.spotter_continent)) return false;
|
||||
}
|
||||
if (e && !e.lotw) return false;
|
||||
}
|
||||
if (clusterHideWorked) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
@@ -5455,6 +5589,15 @@ export default function App() {
|
||||
decodes={decodes}
|
||||
txMsgs={txMsgs}
|
||||
txState={txState}
|
||||
txStates={txStates}
|
||||
autoCallOn={autoCall.enabled}
|
||||
// Written through the same key Preferences uses, so the two can never
|
||||
// disagree about whether the machine is armed.
|
||||
onToggleAutoCall={() => setAutoCall((prev) => {
|
||||
const next = { ...prev, enabled: !prev.enabled };
|
||||
writeUiPref(autoCallKey, JSON.stringify(next));
|
||||
return next;
|
||||
})}
|
||||
spotStatus={spotStatus as any}
|
||||
myCall={station.callsign}
|
||||
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
||||
@@ -5467,11 +5610,42 @@ export default function App() {
|
||||
// for the VFO. The entry is still filled, so the QSO can be logged here.
|
||||
onCall={(d) => {
|
||||
onCallsignInput(d.call, { force: true });
|
||||
// With two slices on two bands, the Reply reaches the right INSTANCE but
|
||||
// the radio still transmits on whichever slice holds the TX flag. Move
|
||||
// it to the decode's band first, or an 80 m answer goes out on 20 m.
|
||||
// A no-op on any backend without slices.
|
||||
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
|
||||
AnswerDecode(
|
||||
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
|
||||
d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf,
|
||||
// The RAW mode marker, not the resolved name: the receiving
|
||||
// application matches the Reply against its decode list field for
|
||||
// field, and JTDX drops one that says "FT8" where it decoded "~".
|
||||
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
|
||||
).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}}
|
||||
// Wipes the live view only — nothing here is stored, and the staging
|
||||
// buffer goes too or the next flush would put back what was just cleared.
|
||||
onClear={(instance) => {
|
||||
if (!instance) {
|
||||
pendingDecodesRef.current = [];
|
||||
setDecodes([]);
|
||||
setTxMsgs([]);
|
||||
setTxState(null);
|
||||
setTxStates({});
|
||||
return;
|
||||
}
|
||||
// One receiver only: the other pane keeps everything it was showing,
|
||||
// which is the whole reason for clearing just one.
|
||||
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => (d.instance ?? '') !== instance);
|
||||
setDecodes((a) => a.filter((d) => (d.instance ?? '') !== instance));
|
||||
setTxMsgs((a) => a.filter((m) => (m.instance ?? '') !== instance));
|
||||
setTxStates((prev) => { const n = { ...prev }; delete n[instance]; return n; });
|
||||
}}
|
||||
// An empty instance lets the backend fall back to whichever application
|
||||
// last reported its status — the normal single-receiver case.
|
||||
onHalt={(instance) => {
|
||||
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6489,12 +6663,14 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||
rotator is configured or a DX bearing exists. */}
|
||||
rotator is configured or a DX bearing exists. Compact mode drops the
|
||||
controls column, so the widget is just the dial and needs only its
|
||||
width. */}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}>
|
||||
<RotorCompass
|
||||
presets={rotorPresets}
|
||||
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
presets={rotorCompact ? undefined : rotorPresets}
|
||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
headings={beamHeadings}
|
||||
boomHeading={boomHeading}
|
||||
@@ -6773,6 +6949,21 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{gridsTabOpen && (
|
||||
<TabsTrigger value="grids" className="gap-1.5">
|
||||
{t('gsm.title')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close grid squares"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeGridsTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{decodesTabOpen && (
|
||||
<TabsTrigger value="decodes" className="gap-1.5">
|
||||
{t('dec.tab')}
|
||||
@@ -6832,7 +7023,8 @@ export default function App() {
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="w-full pr-8 font-mono"
|
||||
placeholder="Search callsign…"
|
||||
placeholder={t('rq.searchPh')}
|
||||
title={t('rq.searchTip')}
|
||||
value={filterCallsign}
|
||||
onChange={(e) => setFilterCallsign(e.target.value.toUpperCase())}
|
||||
/>
|
||||
@@ -7201,7 +7393,26 @@ export default function App() {
|
||||
updating) while you work on other tabs. */}
|
||||
{qslTabOpen && (
|
||||
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<QSLManagerPanel onEditQSO={openEdit} />
|
||||
<QSLManagerPanel
|
||||
onEditQSO={openEdit}
|
||||
// The same row actions the Recent QSOs grid offers. Only the
|
||||
// selection-based exports: exporting "the filter" would mean
|
||||
// the Recent-QSOs filter, not the rows shown here.
|
||||
actions={{
|
||||
onUpdateFromCty: bulkUpdateFromCty,
|
||||
onUpdateFromQRZ: bulkUpdateFromQRZ,
|
||||
onUpdateFromClublog: bulkUpdateFromClublog,
|
||||
onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
|
||||
onSendTo: bulkSendTo,
|
||||
onSendRecording: bulkSendRecording,
|
||||
onSendEQSL: (ids) => setEqslQsoId(ids[0] ?? null),
|
||||
onBulkEdit: openBulkEdit,
|
||||
onExportSelected: exportSelectedADIF,
|
||||
onExportSelectedFields: exportSelectedFields,
|
||||
onExportCabrilloSelected: exportSelectedCabrillo,
|
||||
onDelete: (ids) => setDeletingIds(ids),
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
@@ -7240,6 +7451,12 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{gridsTabOpen && (
|
||||
<TabsContent value="grids" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<GridSquareMap myGrid={station.my_grid} className="flex-1 min-h-0" />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{decodesTabOpen && (
|
||||
<TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
{renderDecodesPanel()}
|
||||
|
||||
Reference in New Issue
Block a user