From 5cd8f7e80f2f833bd231ad477be144397f7c1065 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 16 Apr 2026 12:40:30 -0700 Subject: [PATCH] Add local tunable for glittering status dot. Closes #200. --- frontend/src/components/StatusBar.tsx | 47 +++++++++++++- .../settings/SettingsLocalSection.tsx | 24 ++++++++ frontend/src/hooks/useRealtimeAppState.ts | 2 + frontend/src/utils/statusDotPulse.ts | 61 +++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/statusDotPulse.ts diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index eb97f3f..f6487b3 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -19,6 +19,14 @@ import { getShowBatteryVoltage, mvToPercent, } from '../utils/batteryDisplay'; +import { + STATUS_DOT_PULSE_CHANGE_EVENT, + STATUS_DOT_PULSE_DURATION_MS, + STATUS_DOT_PULSE_PACKET_EVENT, + getStatusDotPulseEnabled, + pulseColorFor, + type StatusDotPulseKind, +} from '../utils/statusDotPulse'; import { cn } from '@/lib/utils'; interface StatusBarProps { @@ -85,6 +93,40 @@ export function StatusBar({ : 'Radio Disconnected'; const [reconnecting, setReconnecting] = useState(false); const [currentTheme, setCurrentTheme] = useState(getSavedTheme); + const [pulseEnabled, setPulseEnabled] = useState(getStatusDotPulseEnabled); + const [pulseKind, setPulseKind] = useState(null); + + useEffect(() => { + const handler = () => setPulseEnabled(getStatusDotPulseEnabled()); + window.addEventListener(STATUS_DOT_PULSE_CHANGE_EVENT, handler); + return () => window.removeEventListener(STATUS_DOT_PULSE_CHANGE_EVENT, handler); + }, []); + + useEffect(() => { + if (!pulseEnabled) { + setPulseKind(null); + return; + } + let timer: number | null = null; + const handler = (event: Event) => { + const kind = (event as CustomEvent).detail; + setPulseKind(kind); + if (timer !== null) { + window.clearTimeout(timer); + } + timer = window.setTimeout(() => { + setPulseKind(null); + timer = null; + }, STATUS_DOT_PULSE_DURATION_MS); + }; + window.addEventListener(STATUS_DOT_PULSE_PACKET_EVENT, handler); + return () => { + window.removeEventListener(STATUS_DOT_PULSE_PACKET_EVENT, handler); + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [pulseEnabled]); useEffect(() => { const handleThemeChange = (event: Event) => { @@ -154,9 +196,12 @@ export function StatusBar({ radioState === 'initializing' || radioState === 'connecting' ? 'bg-warning' : connected - ? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]' + ? pulseKind + ? '' + : 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]' : 'bg-status-disconnected' )} + style={connected && pulseKind ? { backgroundColor: pulseColorFor(pulseKind) } : undefined} aria-hidden="true" /> {statusLabel} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 296bb97..039c263 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -35,6 +35,11 @@ import { getShowBatteryVoltage, setShowBatteryVoltage as saveBatteryVoltage, } from '../../utils/batteryDisplay'; +import { + STATUS_DOT_PULSE_CHANGE_EVENT, + getStatusDotPulseEnabled, + setStatusDotPulseEnabled as saveStatusDotPulse, +} from '../../utils/statusDotPulse'; export function SettingsLocalSection({ onLocalLabelChange, @@ -52,6 +57,7 @@ export function SettingsLocalSection({ const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled); const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent); const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage); + const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled); const [fontScale, setFontScale] = useState(getSavedFontScale); const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale); const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale())); @@ -222,6 +228,24 @@ export function SettingsLocalSection({

)} + +
diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index 724abb3..8133a13 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -12,6 +12,7 @@ import { getStateKey } from '../utils/conversationState'; import { mergeContactIntoList } from '../utils/contactMerge'; import { getContactDisplayName } from '../utils/pubkey'; import { appendRawPacketUnique } from '../utils/rawPacketIdentity'; +import { emitStatusDotPulse } from '../utils/statusDotPulse'; import type { Channel, Contact, @@ -253,6 +254,7 @@ export function useRealtimeAppState({ }, onRawPacket: (packet: RawPacket) => { recordRawPacketObservation?.(packet); + emitStatusDotPulse(packet.payload_type); setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets)); }, onMessageAcked: ( diff --git a/frontend/src/utils/statusDotPulse.ts b/frontend/src/utils/statusDotPulse.ts new file mode 100644 index 0000000..2276a81 --- /dev/null +++ b/frontend/src/utils/statusDotPulse.ts @@ -0,0 +1,61 @@ +export const STATUS_DOT_PULSE_CHANGE_EVENT = 'remoteterm-status-dot-pulse-change'; +export const STATUS_DOT_PULSE_PACKET_EVENT = 'remoteterm-status-dot-pulse-packet'; + +const STORAGE_KEY = 'remoteterm-status-dot-pulse'; + +export type StatusDotPulseKind = 'channel' | 'dm' | 'advert' | 'other'; + +export function getStatusDotPulseEnabled(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +export function setStatusDotPulseEnabled(enabled: boolean): void { + try { + if (enabled) { + localStorage.setItem(STORAGE_KEY, 'true'); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + // localStorage may be unavailable + } +} + +export function payloadTypeToPulseKind(payloadType: string | null | undefined): StatusDotPulseKind { + switch (payloadType) { + case 'GROUP_TEXT': + return 'channel'; + case 'TEXT_MESSAGE': + return 'dm'; + case 'ADVERT': + return 'advert'; + default: + return 'other'; + } +} + +const PULSE_COLORS: Record = { + channel: 'hsl(210, 90%, 55%)', // blue + dm: 'hsl(270, 75%, 60%)', // purple + advert: 'hsl(185, 85%, 55%)', // cyan + other: 'hsl(140, 80%, 22%)', // dark green +}; + +export function pulseColorFor(kind: StatusDotPulseKind): string { + return PULSE_COLORS[kind]; +} + +export const STATUS_DOT_PULSE_DURATION_MS = 250; + +export function emitStatusDotPulse(payloadType: string | null | undefined): void { + const kind = payloadTypeToPulseKind(payloadType); + window.dispatchEvent( + new CustomEvent(STATUS_DOT_PULSE_PACKET_EVENT, { + detail: kind, + }) + ); +}