From 0e2ef317c3d6a7fac3a0f09ba5c480c3172b5b5f Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 21 Jun 2026 02:30:01 +0200 Subject: [PATCH] feat: Added chat when MySQL is in use --- app.go | 12 +++++- chat.go | 9 +++++ frontend/src/App.tsx | 19 +++++++-- frontend/src/components/ChatPopover.tsx | 20 ++++++++-- frontend/src/components/DetailsPanel.tsx | 2 +- frontend/src/components/FlexPanel.tsx | 49 +++++++++++++++++++----- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ internal/cat/cat.go | 1 + internal/cat/flex.go | 21 ++++++++++ 10 files changed, 120 insertions(+), 19 deletions(-) diff --git a/app.go b/app.go index 2dc90fc..7fc392b 100644 --- a/app.go +++ b/app.go @@ -3766,7 +3766,11 @@ func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup. } r, err := p.Lookup(a.ctx, callsign) if errors.Is(err, lookup.ErrNotFound) { - return lookup.Result{}, fmt.Errorf("%s reachable but %q not found (creds look OK)", name, callsign) + // A "not found" (vs an auth error) means the login WORKED — the test + // callsign just isn't in this database, common for special-event calls + // (e.g. TM74TFR not on HamQTH). The credentials are valid, so lookups of + // other callsigns will work: report success, not failure. + return lookup.Result{Source: name, Callsign: callsign, Name: "credentials OK — " + callsign + " not in " + name}, nil } if err != nil { return lookup.Result{}, err @@ -6990,6 +6994,12 @@ func (a *App) FlexSetCWFilter(bw int) error { } return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) }) } +func (a *App) FlexSetFilter(lo int, hi int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetFilter(lo, hi) }) +} // SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without // requiring a trip through the full Settings panel. Persists the choice diff --git a/chat.go b/chat.go index 5cb5120..08e07bc 100644 --- a/chat.go +++ b/chat.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "fmt" "strings" "time" @@ -157,6 +158,7 @@ func (a *App) GetOnlineOperators() ([]ChatPresence, error) { func (a *App) chatLoop() { defer func() { _ = recover() }() var lastID int64 = -1 // -1 = not yet baselined + var lastDB *sql.DB // logbook the baseline belongs to lastPresence := time.Time{} lastPurge := time.Time{} t := time.NewTicker(chatPollInterval) @@ -164,8 +166,15 @@ func (a *App) chatLoop() { for range t.C { if !a.chatActive() { lastID = -1 // re-baseline if the backend changes + lastDB = nil continue } + // Profile switch swaps the logbook under us: re-baseline against the new + // DB so we don't query it with the previous log's id cursor. + if a.logDb != lastDB { + lastID = -1 + lastDB = a.logDb + } if err := a.ensureChatTables(); err != nil { continue } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15697d8..e00685b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -630,6 +630,7 @@ export default function App() { const [chatMsgs, setChatMsgs] = useState([]); const [chatOnline, setChatOnline] = useState([]); const [chatUnread, setChatUnread] = useState(0); + const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen; const chatSeen = useRef>(new Set()); // Availability (only on a shared MySQL logbook; re-checked as profiles switch). @@ -667,7 +668,7 @@ export default function App() { lo(); const id = window.setInterval(lo, 15000); return () => window.clearInterval(id); - }, [chatOpen]); + }, [chatOpen, chatEpoch]); async function chatSend(t: string) { try { const m = (await SendChatMessage(t)) as any as ChatMsg; @@ -1455,6 +1456,11 @@ export default function App() { useEffect(() => { const off = EventsOn('profile:changed', () => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); + // The chat is per shared logbook — clear the previous profile's messages + // and reload for the new logbook (or hide if it isn't a MySQL log). + setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0); + ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false)); + setChatEpoch((e) => e + 1); }); return () => { off(); }; }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]); @@ -3209,9 +3215,14 @@ export default function App() { {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
{chatShown && ( -
- setChatOpen(false)} /> + // relative + absolute inner: the chat takes the row height (set by the + // entry strip) WITHOUT its message list growing the row, like the + // Stats panel. The list scrolls inside this fixed height. +
+
+ setChatOpen(false)} /> +
)} {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a diff --git a/frontend/src/components/ChatPopover.tsx b/frontend/src/components/ChatPopover.tsx index 19f8379..41aa894 100644 --- a/frontend/src/components/ChatPopover.tsx +++ b/frontend/src/components/ChatPopover.tsx @@ -36,10 +36,22 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: { Chat - - o.operator).join(', ')}> - {online.length} - + {/* Online count — hover to see who's connected. */} +
+ + {online.length} + + {online.length > 0 && ( +
+
Online
+ {online.map((o) => ( +
+ {o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? · {o.station} : null} +
+ ))} +
+ )} +
diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index a60a371..3e3964d 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -214,7 +214,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai ))} -
+
{open === 'stats' && (
diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index daf53b0..bf837ad 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -7,7 +7,7 @@ import { FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, - FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, + FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; @@ -150,14 +150,17 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ )}
-
+ {/* LED bar — recessed track + gradient segments for a cleaner instrument look. */} +
{Array.from({ length: METER_SEGMENTS }).map((_, i) => { const on = i < lit; const frac = i / METER_SEGMENTS; const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent); return ( -
+
); })}
@@ -224,6 +227,7 @@ export function FlexPanel() { const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }]; const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }]; const CW_BW = [100, 200, 300, 400, 500]; + const SSB_BW = [1800, 2100, 2400, 2800, 3000, 4000, 6000]; const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0)); return ( @@ -357,9 +361,12 @@ export function FlexPanel() { change('nr', !st.nr, () => FlexSetNR(!st.nr))} onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} /> - change('anf', !st.anf, () => FlexSetANF(!st.anf))} - onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> + {/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */} + {!isCW && ( + change('anf', !st.anf, () => FlexSetANF(!st.anf))} + onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> + )}
{isCW && (
@@ -382,6 +389,31 @@ export function FlexPanel() {
)} + {!isCW && ( +
+
+ Filter +
+ {SSB_BW.map((bw) => ( + + ))} +
+ kHz +
+
+ )}
@@ -403,7 +435,6 @@ export function FlexPanel() { FAULT: {st.amp_fault} )}
-

Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).

)} @@ -439,7 +470,7 @@ export function FlexPanel() { const cur = [ sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return ( { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} /> + segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 12.33 ? '#f59e0b' : '#dc2626'; }} /> ); })(), fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return ( ; export function FlexSetCWSpeed(arg1:number):Promise; +export function FlexSetFilter(arg1:number,arg2:number):Promise; + export function FlexSetMic(arg1:number):Promise; export function FlexSetMon(arg1:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index c406d06..23ba2a8 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -290,6 +290,10 @@ export function FlexSetCWSpeed(arg1) { return window['go']['main']['App']['FlexSetCWSpeed'](arg1); } +export function FlexSetFilter(arg1, arg2) { + return window['go']['main']['App']['FlexSetFilter'](arg1, arg2); +} + export function FlexSetMic(arg1) { return window['go']['main']['App']['FlexSetMic'](arg1); } diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 598c6b6..02409a9 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -327,6 +327,7 @@ type FlexController interface { SetCWSidetone(bool) error SetSidetoneLevel(int) error SetCWFilter(int) error + SetFilter(lo, hi int) error // External amplifier (PowerGenius XL) operate/standby. SetAmpOperate(bool) error } diff --git a/internal/cat/flex.go b/internal/cat/flex.go index bb78d29..2c4b517 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -1220,6 +1220,27 @@ func (f *Flex) SetCWFilter(bw int) error { return nil } +// SetFilter sets the active RX slice's passband to an explicit low/high cut (Hz, +// audio offsets; negative for LSB). Used by the SSB width presets, where the +// frontend keeps the carrier-side edge and extends the far edge. +func (f *Flex) SetFilter(lo, hi int) error { + f.mu.Lock() + idx, rx := f.rxSliceLocked() + connected := f.conn != nil + if rx != nil { + rx.filterLo, rx.filterHi = lo, hi + } + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + if rx == nil || idx < 0 { + return fmt.Errorf("flex: no receive slice") + } + f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi)) + return nil +} + // boolWord renders a Flex on/off boolean as the word form some commands want. func boolWord(on bool) string { if on {