From a9f2e515e10fd067c4b284bb142ffa26d119ca08 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 20 Jun 2026 20:18:28 +0200 Subject: [PATCH] chore: release v0.12 --- app.go | 1 + chat.go | 219 ++++++++++++++++++++++++ frontend/src/App.tsx | 84 ++++++++- frontend/src/components/ChatPopover.tsx | 81 +++++++++ frontend/src/version.ts | 2 +- frontend/wailsjs/go/main/App.d.ts | 8 + frontend/wailsjs/go/main/App.js | 16 ++ frontend/wailsjs/go/models.ts | 36 ++++ telemetry.go | 2 +- 9 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 chat.go create mode 100644 frontend/src/components/ChatPopover.tsx diff --git a/app.go b/app.go index 397cd25..2dc90fc 100644 --- a/app.go +++ b/app.go @@ -823,6 +823,7 @@ func (a *App) startup(ctx context.Context) { // when disabled in Preferences or until the PostHog key is configured. go a.sendTelemetryHeartbeat() go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL + go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence fmt.Println("OpsLog: db ready at", a.dbPath) } diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..5cb5120 --- /dev/null +++ b/chat.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "hamlog/internal/applog" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Multi-operator chat over the SHARED MySQL logbook. The database is the message +// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so +// operators on the same shared log (e.g. a special-event call) can talk. No +// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is +// only available on a MySQL logbook (SQLite/solo has no one else to talk to). + +const ( + chatPollInterval = 3 * time.Second + chatPresenceEvery = 20 * time.Second + chatRetentionDays = 7 + chatHistoryDefault = 80 + chatPresenceStaleSecs = 120 // a presence row older than this = offline +) + +// ChatMessage is one chat line. +type ChatMessage struct { + ID int64 `json:"id"` + Operator string `json:"operator"` + Station string `json:"station"` + Message string `json:"message"` + CreatedAt string `json:"created_at"` // ISO UTC +} + +// ChatPresence is one operator currently online (recent heartbeat). +type ChatPresence struct { + Operator string `json:"operator"` + Station string `json:"station"` + AgoSecs int `json:"ago_secs"` +} + +// chatActive reports whether chat can run (shared MySQL logbook). +func (a *App) chatActive() bool { + return a.logDb != nil && a.dbBackend == "mysql" +} + +// ChatAvailable lets the UI show/hide the chat icon (only on a shared log). +func (a *App) ChatAvailable() bool { return a.chatActive() } + +func (a *App) ensureChatTables() error { + if _, err := a.logDb.ExecContext(a.ctx, + "CREATE TABLE IF NOT EXISTS chat_messages ("+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "+ + "operator VARCHAR(32), station VARCHAR(32), "+ + "message TEXT, created_at DATETIME)"); err != nil { + return err + } + _, err := a.logDb.ExecContext(a.ctx, + "CREATE TABLE IF NOT EXISTS chat_presence ("+ + "operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)") + return err +} + +// SendChatMessage posts a message to the shared chat and returns the stored row +// (with its id) so the UI can show it immediately; the poll loop dedupes by id. +func (a *App) SendChatMessage(text string) (ChatMessage, error) { + text = strings.TrimSpace(text) + if text == "" { + return ChatMessage{}, nil + } + if len(text) > 1000 { + text = text[:1000] + } + if !a.chatActive() { + return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook") + } + op, station := a.liveStatusOperator() + if op == "" { + return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first") + } + if err := a.ensureChatTables(); err != nil { + return ChatMessage{}, err + } + res, err := a.logDb.ExecContext(a.ctx, + "INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())", + op, station, text) + if err != nil { + return ChatMessage{}, err + } + id, _ := res.LastInsertId() + return ChatMessage{ID: id, Operator: op, Station: station, Message: text, + CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil +} + +// GetChatHistory returns the most recent messages (oldest first) for the panel. +func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) { + if !a.chatActive() { + return nil, nil + } + if limit <= 0 || limit > 500 { + limit = chatHistoryDefault + } + if err := a.ensureChatTables(); err != nil { + return nil, err + } + rows, err := a.logDb.QueryContext(a.ctx, + "SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ChatMessage + for rows.Next() { + var m ChatMessage + if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + // Reverse to chronological order (we queried newest-first to honour LIMIT). + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out, rows.Err() +} + +// GetOnlineOperators lists operators with a recent presence heartbeat. +func (a *App) GetOnlineOperators() ([]ChatPresence, error) { + if !a.chatActive() { + return nil, nil + } + if err := a.ensureChatTables(); err != nil { + return nil, err + } + rows, err := a.logDb.QueryContext(a.ctx, + "SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+ + "FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator", + chatPresenceStaleSecs) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ChatPresence + for rows.Next() { + var p ChatPresence + if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// chatLoop polls for new messages and heartbeats presence while on a shared +// MySQL logbook. Started once at startup; a cheap no-op otherwise. +func (a *App) chatLoop() { + defer func() { _ = recover() }() + var lastID int64 = -1 // -1 = not yet baselined + lastPresence := time.Time{} + lastPurge := time.Time{} + t := time.NewTicker(chatPollInterval) + defer t.Stop() + for range t.C { + if !a.chatActive() { + lastID = -1 // re-baseline if the backend changes + continue + } + if err := a.ensureChatTables(); err != nil { + continue + } + now := time.Now() + // Presence heartbeat. + if now.Sub(lastPresence) >= chatPresenceEvery { + if op, station := a.liveStatusOperator(); op != "" { + _, _ = a.logDb.ExecContext(a.ctx, + "INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+ + "ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()", + op, station) + } + lastPresence = now + } + // Baseline on first run so existing history isn't replayed as "new" + // (the panel loads it via GetChatHistory). + if lastID < 0 { + row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages") + _ = row.Scan(&lastID) + continue + } + // Emit new messages. + rows, err := a.logDb.QueryContext(a.ctx, + "SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID) + if err != nil { + continue + } + for rows.Next() { + var m ChatMessage + if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil { + continue + } + if m.ID > lastID { + lastID = m.ID + } + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "chat:message", m) + } + } + rows.Close() + // Purge old messages occasionally (hourly). + if now.Sub(lastPurge) >= time.Hour { + _, err := a.logDb.ExecContext(a.ctx, + "DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays) + if err != nil { + applog.Printf("chat: purge failed: %v", err) + } + lastPurge = now + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f38fb51..15697d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, - Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, + Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { @@ -30,6 +30,7 @@ import { WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, + ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, GetAwardDefs, GetUIPref, @@ -63,6 +64,7 @@ import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { BulkEditModal } from '@/components/BulkEditModal'; +import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; @@ -622,6 +624,61 @@ export default function App() { setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; }); } + // === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK === + const [chatAvailable, setChatAvailable] = useState(false); + const [chatOpen, setChatOpen] = useState(false); + const [chatMsgs, setChatMsgs] = useState([]); + const [chatOnline, setChatOnline] = useState([]); + const [chatUnread, setChatUnread] = useState(0); + const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen; + const chatSeen = useRef>(new Set()); + // Availability (only on a shared MySQL logbook; re-checked as profiles switch). + useEffect(() => { + let alive = true; + const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {}); + chk(); + const id = window.setInterval(chk, 10000); + return () => { alive = false; window.clearInterval(id); }; + }, []); + // Incoming messages — append + bump unread when the panel is closed. + useEffect(() => { + const off = EventsOn('chat:message', (m: ChatMsg) => { + if (!m || chatSeen.current.has(m.id)) return; + chatSeen.current.add(m.id); + setChatMsgs((p) => [...p, m].slice(-300)); + if (!chatOpenRef.current) setChatUnread((u) => u + 1); + }); + return () => { off?.(); }; + }, []); + // On open: clear unread, load history + the online list (refreshed). + useEffect(() => { + if (!chatOpen) return; + setChatUnread(0); + GetChatHistory(80).then((h: any) => { + const list = (h ?? []) as ChatMsg[]; + list.forEach((m) => chatSeen.current.add(m.id)); + setChatMsgs((prev) => { + const byId = new Map(); + [...list, ...prev].forEach((m) => byId.set(m.id, m)); + return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300); + }); + }).catch(() => {}); + const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {}); + lo(); + const id = window.setInterval(lo, 15000); + return () => window.clearInterval(id); + }, [chatOpen]); + async function chatSend(t: string) { + try { + const m = (await SendChatMessage(t)) as any as ChatMsg; + if (m && m.id && !chatSeen.current.has(m.id)) { + chatSeen.current.add(m.id); + setChatMsgs((p) => [...p, m].slice(-300)); + } + } catch (e: any) { setError(String(e?.message ?? e)); } + } + const chatShown = chatOpen && chatAvailable; + const [dvkEnabled, setDvkEnabled] = useState(false); const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); @@ -2856,6 +2913,23 @@ export default function App() { > + {chatAvailable && ( + + )}
@@ -3132,8 +3206,14 @@ export default function App() { {/* Reserved free space to the right. The WinKeyer CW keyer and/or the Digital Voice Keyer take this slot when enabled (Log4OM-style); otherwise it shows the QRZ profile photo. */} - {!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && ( + {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
+ {chatShown && ( +
+ setChatOpen(false)} /> +
+ )} {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a rotator is configured or a DX bearing exists. */} {showRotor && (rotatorHeading.enabled || dxPath) && ( diff --git a/frontend/src/components/ChatPopover.tsx b/frontend/src/components/ChatPopover.tsx new file mode 100644 index 0000000..19f8379 --- /dev/null +++ b/frontend/src/components/ChatPopover.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react'; +import { MessageSquare, Send, Users, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string }; +export type ChatPresence = { operator: string; station: string; ago_secs: number }; + +function hhmm(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const p = (n: number) => String(n).padStart(2, '0'); + return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; +} + +// ChatPanel — presentational multi-op chat panel, docked in the aside row next +// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx. +export function ChatPanel({ msgs, online, myCall, onSend, onClose }: { + msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string; + onSend: (text: string) => void; onClose: () => void; +}) { + const [text, setText] = useState(''); + const listRef = useRef(null); + const me = (myCall || '').toUpperCase(); + useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]); + + function send() { + const t = text.trim(); + if (!t) return; + setText(''); + onSend(t); + } + + return ( +
+
+ + Chat + + + o.operator).join(', ')}> + {online.length} + + +
+ +
+ {msgs.length === 0 ? ( +
No messages yet.
+ ) : msgs.map((m) => { + const mine = m.operator.toUpperCase() === me; + return ( +
+
+ {!mine && {m.operator}} + {m.message} +
+ {hhmm(m.created_at)} +
+ ); + })} +
+ +
+ setText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }} + placeholder="Message…" + maxLength={1000} + className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary" + /> + +
+
+ ); +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 33d8e05..10a4b3a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,6 +1,6 @@ // Single source of truth for the app version shown in the UI (header + About). // Bump this on a release (the release script updates it alongside telemetry.go). -export const APP_VERSION = '0.11.3'; +export const APP_VERSION = '0.12'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 20daba4..c70d9f1 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -43,6 +43,8 @@ export function BulkUpdateQSL(arg1:Array,arg2:main.QSLBulkUpdate):Promis export function CWDecoderRunning():Promise; +export function ChatAvailable():Promise; + export function CheckForUpdate():Promise; export function ClearLookupCache():Promise; @@ -215,6 +217,8 @@ export function GetCATState():Promise; export function GetCWDecoderPitch():Promise; +export function GetChatHistory(arg1:number):Promise>; + export function GetClublogCtyInfo():Promise; export function GetClusterAutoConnect():Promise; @@ -253,6 +257,8 @@ export function GetLookupSettings():Promise; export function GetMySQLSettings():Promise; +export function GetOnlineOperators():Promise>; + export function GetPOTAToken():Promise; export function GetQSLDefaults():Promise; @@ -459,6 +465,8 @@ export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise; export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise>; +export function SendChatMessage(arg1:string):Promise; + export function SendClusterCommand(arg1:string):Promise; export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index df3a978..c406d06 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -58,6 +58,10 @@ export function CWDecoderRunning() { return window['go']['main']['App']['CWDecoderRunning'](); } +export function ChatAvailable() { + return window['go']['main']['App']['ChatAvailable'](); +} + export function CheckForUpdate() { return window['go']['main']['App']['CheckForUpdate'](); } @@ -402,6 +406,10 @@ export function GetCWDecoderPitch() { return window['go']['main']['App']['GetCWDecoderPitch'](); } +export function GetChatHistory(arg1) { + return window['go']['main']['App']['GetChatHistory'](arg1); +} + export function GetClublogCtyInfo() { return window['go']['main']['App']['GetClublogCtyInfo'](); } @@ -478,6 +486,10 @@ export function GetMySQLSettings() { return window['go']['main']['App']['GetMySQLSettings'](); } +export function GetOnlineOperators() { + return window['go']['main']['App']['GetOnlineOperators'](); +} + export function GetPOTAToken() { return window['go']['main']['App']['GetPOTAToken'](); } @@ -890,6 +902,10 @@ export function SearchAwardReferences(arg1, arg2, arg3, arg4) { return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4); } +export function SendChatMessage(arg1) { + return window['go']['main']['App']['SendChatMessage'](arg1); +} + export function SendClusterCommand(arg1) { return window['go']['main']['App']['SendClusterCommand'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3e97a9c..000af35 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1063,6 +1063,42 @@ export namespace main { this.digital_default = source["digital_default"]; } } + export class ChatMessage { + id: number; + operator: string; + station: string; + message: string; + created_at: string; + + static createFrom(source: any = {}) { + return new ChatMessage(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.operator = source["operator"]; + this.station = source["station"]; + this.message = source["message"]; + this.created_at = source["created_at"]; + } + } + export class ChatPresence { + operator: string; + station: string; + ago_secs: number; + + static createFrom(source: any = {}) { + return new ChatPresence(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.operator = source["operator"]; + this.station = source["station"]; + this.ago_secs = source["ago_secs"]; + } + } export class ClublogCtyInfo { enabled: boolean; loaded: boolean; diff --git a/telemetry.go b/telemetry.go index 1ba1d8e..bcfcf4d 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.11.3" + appVersion = "0.12" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.