Add local tunable for glittering status dot. Closes #200.

This commit is contained in:
Jack Kingsman
2026-04-16 12:40:30 -07:00
parent e8c50d0b2a
commit 5cd8f7e80f
4 changed files with 133 additions and 1 deletions

View File

@@ -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<StatusDotPulseKind | null>(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<StatusDotPulseKind>).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"
/>
<span className="hidden lg:inline text-muted-foreground">{statusLabel}</span>

View File

@@ -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({
</p>
)}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={statusDotPulse}
onChange={(e) => {
const v = e.target.checked;
setStatusDotPulse(v);
saveStatusDotPulse(v);
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">
Glitter status dot as packets arrive (blue = channel, purple = DM, cyan = advert, dark
green = other)
</span>
</label>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">

View File

@@ -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: (

View File

@@ -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<StatusDotPulseKind, string> = {
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<StatusDotPulseKind>(STATUS_DOT_PULSE_PACKET_EVENT, {
detail: kind,
})
);
}