import { useState, useEffect } from 'react'; import { ChevronRight, Logs, MessageSquare, Send, Settings, X } from 'lucide-react'; import { toast } from '../ui/sonner'; import { usePush } from '../../contexts/PushSubscriptionContext'; import type { Channel, Contact } from '../../types'; import { getContactDisplayName } from '../../utils/pubkey'; import { Button } from '../ui/button'; import { Checkbox } from '../ui/checkbox'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Separator } from '../ui/separator'; import { cn } from '../../lib/utils'; import { ContactAvatar } from '../ContactAvatar'; import { captureLastViewedConversationFromHash, getReopenLastConversationEnabled, setReopenLastConversationEnabled, } from '../../utils/lastViewedConversation'; import { ThemeSelector } from './ThemeSelector'; import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel'; import { DISTANCE_UNIT_LABELS, DISTANCE_UNITS, setSavedDistanceUnit, } from '../../utils/distanceUnits'; import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; import { DEFAULT_FONT_SCALE, FONT_SCALE_SLIDER_STEP, MAX_FONT_SCALE, MIN_FONT_SCALE, getSavedFontScale, setSavedFontScale, } from '../../utils/fontScale'; import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput'; import { getTextReplaceEnabled, setTextReplaceEnabled as saveTextReplaceEnabled, getTextReplaceMapJson, setTextReplaceMapJson, DEFAULT_MAP_JSON, } from '../../utils/textReplace'; import { BATTERY_DISPLAY_CHANGE_EVENT, getShowBatteryPercent, setShowBatteryPercent as saveBatteryPercent, getShowBatteryVoltage, setShowBatteryVoltage as saveBatteryVoltage, } from '../../utils/batteryDisplay'; import { STATUS_DOT_PULSE_CHANGE_EVENT, getStatusDotPulseEnabled, setStatusDotPulseEnabled as saveStatusDotPulse, } from '../../utils/statusDotPulse'; /** Resolve a state key like "contact-abc123" or "channel-def456" to a display name. */ function resolveConversationName( stateKey: string, contacts: Contact[], channels: Channel[] ): string { if (stateKey.startsWith('contact-')) { const pubkey = stateKey.slice('contact-'.length); const contact = contacts.find((c) => c.public_key === pubkey); return contact ? getContactDisplayName(contact.name, contact.public_key) : pubkey.slice(0, 12); } if (stateKey.startsWith('channel-')) { const key = stateKey.slice('channel-'.length); const channel = channels.find((c) => c.key === key); if (channel?.name) return channel.name.startsWith('#') ? channel.name : `#${channel.name}`; return `#${key.slice(0, 12)}`; } return stateKey; } function PushDeviceManagement({ contacts = [], channels = [], }: { contacts?: Contact[]; channels?: Channel[]; }) { const { isSupported, allSubscriptions, pushConversations, loading, subscribe, currentSubscriptionId, toggleConversation, deleteSubscription, testPush, refreshSubscriptions, } = usePush(); useEffect(() => { refreshSubscriptions(); }, [refreshSubscriptions]); if (!isSupported) { return (

Web Push Notifications

{window.isSecureContext ? 'Push notifications are not supported by this browser.' : 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}

); } return (

Web Push Notifications

Receive notifications even when the browser is closed. Use the bell icon in any conversation header to enable push for that contact or channel, or subscribe this browser to receive notifications for all push-enabled conversations.

The set of channels or DMs that trigger push notifications are global per-install (i.e. all devices that register for Web Push will have the same set of channels/DMs that trigger notifications). Subscribing or unsubscribing a particular browser only controls whether that browser receives notifications for the configured set of channels/DMs.

{!currentSubscriptionId && ( )} {pushConversations.length > 0 && (
Push-enabled conversations
{pushConversations.map((key) => ( {resolveConversationName(key, contacts, channels)} ))}
)} {allSubscriptions.length > 0 && (
Registered Devices
{allSubscriptions.map((sub) => (
{sub.label || 'Unknown device'} {sub.id === currentSubscriptionId && ( Current device )}
{sub.last_success_at ? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}` : 'Never pushed'} {sub.failure_count > 0 && ` · ${sub.failure_count} failures`}
))}
)}
); } export function SettingsLocalSection({ onLocalLabelChange, contacts, channels, className, }: { onLocalLabelChange?: (label: LocalLabel) => void; contacts?: Contact[]; channels?: Channel[]; className?: string; }) { const { distanceUnit, setDistanceUnit } = useDistanceUnit(); const [reopenLastConversation, setReopenLastConversation] = useState( getReopenLastConversationEnabled ); const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text); const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color); const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled); const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent); const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage); const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled); const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled); const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson); const [textReplaceError, setTextReplaceError] = useState(null); const [fontScale, setFontScale] = useState(getSavedFontScale); const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale); const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale())); const commitFontScale = (nextScale: number) => { const normalized = setSavedFontScale(nextScale); setFontScale(normalized); setFontScaleSlider(normalized); setFontScaleInput(String(normalized)); }; const restoreFontScaleInput = () => { setFontScaleInput(String(fontScale)); }; const handleSliderChange = (nextScale: number) => { setFontScaleSlider(nextScale); setFontScaleInput(String(nextScale)); }; const handleSliderCommit = (nextScale: number) => { commitFontScale(nextScale); }; const handleToggleReopenLastConversation = (enabled: boolean) => { setReopenLastConversation(enabled); setReopenLastConversationEnabled(enabled); if (enabled) { captureLastViewedConversationFromHash(); } }; return (

These settings apply only to this device/browser.

Color Scheme

Local Label

{ const text = e.target.value; setLocalLabelText(text); setLocalLabel(text, localLabelColor); onLocalLabelChange?.({ text, color: localLabelColor }); }} placeholder="e.g. Home Base, Field Radio 2" aria-label="Local label text" className="flex-1" /> { const color = e.target.value; setLocalLabelColor(color); setLocalLabel(localLabelText, color); onLocalLabelChange?.({ text: localLabelText, color }); }} aria-label="Local label color" className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5" />

Display a colored banner at the top of the page to identify this instance.

Controls how distances are shown throughout the app.

UI Tweaks

handleToggleReopenLastConversation(checked === true)} className="mt-0.5" />

Automatically reopen to the last-open channel or contact when the app loads to the bare URL.

{ const v = checked === true; setAutoFocusInput(v); setAutoFocusInputEnabled(v); }} className="mt-0.5" />

Place the cursor in the message input when switching conversations. Desktop only.

{ const v = checked === true; setBatteryPercent(v); saveBatteryPercent(v); window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT)); }} className="mt-0.5" />

Display the radio's battery percentage in the status bar. Data updates every 60 seconds and may take up to a minute to appear after connecting.

{ const v = checked === true; setBatteryVoltage(v); saveBatteryVoltage(v); window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT)); }} className="mt-0.5" />

Display the radio's battery voltage in the status bar (in mV). Data updates every 60 seconds and may take up to a minute to appear after connecting.

{ const v = checked === true; setStatusDotPulse(v); saveStatusDotPulse(v); window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT)); }} className="mt-0.5" />

Flash the connection status dot in color as packets arrive: blue for channel, purple for DM, cyan for advert, dark green for other.

{ const v = checked === true; setTextReplaceEnabled(v); saveTextReplaceEnabled(v); }} className="mt-0.5" />

Automatically replace characters as you type in the message input. Define replacements as a JSON object mapping source strings to their replacements.

{textReplaceEnabled && (