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,
+ })
+ );
+}