Add web push

This commit is contained in:
Jack Kingsman
2026-04-12 19:43:58 -07:00
parent 1db724073b
commit 31bd4a0744
23 changed files with 1881 additions and 9 deletions

View File

@@ -22,6 +22,7 @@ import { toast } from './components/ui/sonner';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { usePushSubscription } from './hooks/usePushSubscription';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
@@ -99,6 +100,7 @@ export function App() {
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const pushSubscription = usePushSubscription();
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
const {
showNewMessage,
@@ -615,6 +617,29 @@ export function App() {
);
}
},
pushSupported: pushSubscription.isSupported,
pushSubscribed: pushSubscription.isSubscribed,
pushEnabledForConversation:
activeConversation?.type === 'contact' || activeConversation?.type === 'channel'
? pushSubscription.isConversationPushEnabled(
getStateKey(activeConversation.type, activeConversation.id)
)
: false,
onTogglePush: () => {
if (
!activeConversation ||
(activeConversation.type !== 'contact' && activeConversation.type !== 'channel')
)
return;
const key = getStateKey(activeConversation.type, activeConversation.id);
if (!pushSubscription.isSubscribed) {
void pushSubscription.subscribe(key);
} else if (pushSubscription.isConversationPushEnabled(key)) {
void pushSubscription.removeConversation(key);
} else {
void pushSubscription.addConversation(key);
}
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
repeaterAutoLoginKey,

View File

@@ -22,6 +22,7 @@ import type {
RadioTraceResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
PushSubscriptionInfo,
ResendChannelMessageResponse,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
@@ -441,4 +442,30 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
method: 'POST',
}),
// Push Notifications
getVapidPublicKey: () => fetchJson<{ public_key: string }>('/push/vapid-public-key'),
pushSubscribe: (subscription: {
endpoint: string;
p256dh: string;
auth: string;
label?: string;
}) =>
fetchJson<PushSubscriptionInfo>('/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
}),
getPushSubscriptions: () => fetchJson<PushSubscriptionInfo[]>('/push/subscriptions'),
updatePushSubscription: (
id: string,
update: { label?: string; filter_mode?: string; filter_conversations?: string[] }
) =>
fetchJson<PushSubscriptionInfo>(`/push/subscriptions/${id}`, {
method: 'PATCH',
body: JSON.stringify(update),
}),
deletePushSubscription: (id: string) =>
fetchJson<{ deleted: boolean }>(`/push/subscriptions/${id}`, { method: 'DELETE' }),
testPushSubscription: (id: string) =>
fetchJson<{ status: string }>(`/push/subscriptions/${id}/test`, { method: 'POST' }),
};

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, BellRing, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
@@ -26,6 +26,10 @@ interface ChatHeaderProps {
onTrace: () => void;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleNotifications: () => void;
pushSupported?: boolean;
pushSubscribed?: boolean;
pushEnabledForConversation?: boolean;
onTogglePush?: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
@@ -46,6 +50,10 @@ export function ChatHeader({
onTrace,
onPathDiscovery,
onToggleNotifications,
pushSupported,
pushSubscribed,
pushEnabledForConversation,
onTogglePush,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
@@ -317,6 +325,35 @@ export function ChatHeader({
)}
</button>
)}
{pushSupported && !activeContactIsRoomServer && onTogglePush && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTogglePush}
title={
pushEnabledForConversation
? 'Disable push notifications for this conversation'
: pushSubscribed
? 'Enable push notifications for this conversation'
: 'Enable Web Push notifications (works when tab is closed)'
}
aria-label={
pushEnabledForConversation
? 'Disable push notifications'
: 'Enable push notifications'
}
>
<BellRing
className={`h-4 w-4 ${pushEnabledForConversation ? 'text-amber-500' : 'text-muted-foreground'}`}
fill={pushEnabledForConversation ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{pushEnabledForConversation && (
<span className="hidden md:inline text-[0.6875rem] font-medium text-amber-500">
Push On
</span>
)}
</button>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

View File

@@ -82,6 +82,10 @@ interface ConversationPaneProps {
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
pushSupported?: boolean;
pushSubscribed?: boolean;
pushEnabledForConversation?: boolean;
onTogglePush?: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
repeaterAutoLoginKey: string | null;
@@ -155,6 +159,10 @@ export function ConversationPane({
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
pushSupported,
pushSubscribed,
pushEnabledForConversation,
onTogglePush,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
repeaterAutoLoginKey,
@@ -288,6 +296,10 @@ export function ConversationPane({
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
pushSupported={pushSupported}
pushSubscribed={pushSubscribed}
pushEnabledForConversation={pushEnabledForConversation}
onTogglePush={onTogglePush}
onTrace={onTrace}
onPathDiscovery={onPathDiscovery}
onToggleNotifications={onToggleNotifications}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
import { useState, useEffect } from 'react';
import { BellRing, ChevronRight, Logs, MessageSquare, Send, Settings, Trash2 } from 'lucide-react';
import { toast } from '../ui/sonner';
import { usePushSubscription } from '../../hooks/usePushSubscription';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
@@ -41,6 +43,122 @@ import {
setStatusDotPulseEnabled as saveStatusDotPulse,
} from '../../utils/statusDotPulse';
function PushDeviceManagement() {
const {
isSupported,
isSubscribed,
allSubscriptions,
loading,
subscribe,
unsubscribe,
deleteSubscription,
testPush,
refreshSubscriptions,
} = usePushSubscription();
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (expanded) refreshSubscriptions();
}, [expanded, refreshSubscriptions]);
if (!isSupported) {
return (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<BellRing className="h-4 w-4" /> Web Push Notifications
</Label>
<p className="text-xs text-muted-foreground">
{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.'}
</p>
</div>
);
}
return (
<div className="space-y-3">
<Label className="flex items-center gap-2">
<BellRing className="h-4 w-4" /> Web Push Notifications
</Label>
<p className="text-xs text-muted-foreground">
Receive notifications even when the browser tab is closed. Notifications are delivered via
your browser&apos;s push service and will arrive even when you&apos;re not on the same
network as RemoteTerm.
</p>
{isSubscribed ? (
<Button
variant="outline"
size="sm"
onClick={() => void unsubscribe()}
disabled={loading}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{loading ? 'Updating...' : 'Unsubscribe This Browser'}
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => void subscribe()} disabled={loading}>
{loading ? 'Subscribing...' : 'Subscribe This Browser'}
</Button>
)}
{allSubscriptions.length > 0 && (
<div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn('h-3 w-3 transition-transform', expanded && 'rotate-90')} />
{allSubscriptions.length} registered device{allSubscriptions.length !== 1 ? 's' : ''}
</button>
{expanded && (
<div className="mt-2 space-y-1.5">
{allSubscriptions.map((sub) => (
<div
key={sub.id}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1.5 text-sm"
>
<div className="min-w-0 flex-1">
<span className="block truncate">{sub.label || 'Unknown device'}</span>
<span className="text-[0.625rem] text-muted-foreground">
{sub.last_success_at
? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}`
: 'Never pushed'}
{sub.failure_count > 0 && ` · ${sub.failure_count} failures`}
</span>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => void testPush(sub.id)}
>
Test
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => {
void deleteSubscription(sub.id).then(() => toast.success('Device removed'));
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
export function SettingsLocalSection({
onLocalLabelChange,
className,
@@ -398,6 +516,10 @@ function ThemePreview({ className }: { className?: string }) {
</div>
</div>
<Separator />
<PushDeviceManagement />
{/* ── Style Reference (collapsible) ── */}
<button
type="button"

View File

@@ -0,0 +1,260 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from '../components/ui/sonner';
import { api } from '../api';
import type { PushSubscriptionInfo } from '../types';
function generateLabel(): string {
const ua = navigator.userAgent;
// Extract browser + OS in a human-readable form
if (/Firefox/i.test(ua)) {
if (/Android/i.test(ua)) return 'Firefox on Android';
if (/Mac/i.test(ua)) return 'Firefox on macOS';
if (/Windows/i.test(ua)) return 'Firefox on Windows';
if (/Linux/i.test(ua)) return 'Firefox on Linux';
return 'Firefox';
}
if (/Chrome/i.test(ua) && !/Edg/i.test(ua)) {
if (/Android/i.test(ua)) return 'Chrome on Android';
if (/CrOS/i.test(ua)) return 'Chrome on ChromeOS';
if (/Mac/i.test(ua)) return 'Chrome on macOS';
if (/Windows/i.test(ua)) return 'Chrome on Windows';
if (/Linux/i.test(ua)) return 'Chrome on Linux';
return 'Chrome';
}
if (/Edg/i.test(ua)) return 'Edge';
if (/Safari/i.test(ua)) {
if (/iPhone|iPad/i.test(ua)) return 'Safari on iOS';
return 'Safari on macOS';
}
return 'Browser';
}
/** Convert a base64url string to a Uint8Array (for applicationServerKey) */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
export function usePushSubscription() {
const [isSupported, setIsSupported] = useState(false);
const [currentSubscriptionId, setCurrentSubscriptionId] = useState<string | null>(null);
const [allSubscriptions, setAllSubscriptions] = useState<PushSubscriptionInfo[]>([]);
const [loading, setLoading] = useState(false);
const vapidKeyRef = useRef<string | null>(null);
// Check support on mount
useEffect(() => {
const supported =
window.isSecureContext &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window;
setIsSupported(supported);
if (supported) {
// Check if this browser already has an active push subscription
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then(async (sub) => {
if (sub) {
// Look up this endpoint in backend to get the subscription ID
const existing = await api
.getPushSubscriptions()
.catch(() => [] as PushSubscriptionInfo[]);
const match = existing.find((s) => s.endpoint === sub.endpoint);
if (match) {
setCurrentSubscriptionId(match.id);
setAllSubscriptions(existing);
}
}
})
.catch(() => {});
}
}, []);
const refreshSubscriptions = useCallback(async () => {
try {
const subs = await api.getPushSubscriptions();
setAllSubscriptions(subs);
return subs;
} catch {
return [];
}
}, []);
const subscribe = useCallback(
async (conversationKey?: string): Promise<string | null> => {
if (!isSupported) return null;
setLoading(true);
try {
// Get VAPID key if not cached
if (!vapidKeyRef.current) {
const resp = await api.getVapidPublicKey();
vapidKeyRef.current = resp.public_key;
}
// Register/get service worker
const reg = await navigator.serviceWorker.ready;
// Reuse existing browser subscription if one exists, otherwise create new
let pushSub = await reg.pushManager.getSubscription();
if (!pushSub) {
pushSub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKeyRef.current).buffer as ArrayBuffer,
});
}
const json = pushSub.toJSON();
const endpoint = json.endpoint!;
const p256dh = json.keys!.p256dh!;
const auth = json.keys!.auth!;
// Register with backend
const result = await api.pushSubscribe({
endpoint,
p256dh,
auth,
label: generateLabel(),
});
// If subscribing for a specific conversation, set filter_mode to selected
if (conversationKey) {
await api.updatePushSubscription(result.id, {
filter_mode: 'selected',
filter_conversations: [conversationKey],
});
}
setCurrentSubscriptionId(result.id);
await refreshSubscriptions();
return result.id;
} catch (err) {
console.error('Push subscribe failed:', err);
toast.error('Failed to enable push notifications', {
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
});
return null;
} finally {
setLoading(false);
}
},
[isSupported, refreshSubscriptions]
);
const unsubscribe = useCallback(async () => {
setLoading(true);
try {
// Unsubscribe from browser Push API
const reg = await navigator.serviceWorker.ready;
const pushSub = await reg.pushManager.getSubscription();
if (pushSub) await pushSub.unsubscribe();
// Remove from backend
if (currentSubscriptionId) {
await api.deletePushSubscription(currentSubscriptionId).catch(() => {});
}
setCurrentSubscriptionId(null);
await refreshSubscriptions();
} catch (err) {
console.error('Push unsubscribe failed:', err);
} finally {
setLoading(false);
}
}, [currentSubscriptionId, refreshSubscriptions]);
const addConversation = useCallback(
async (conversationKey: string) => {
if (!currentSubscriptionId) return;
const sub = allSubscriptions.find((s) => s.id === currentSubscriptionId);
if (!sub) return;
const conversations = [...(sub.filter_conversations || [])];
if (!conversations.includes(conversationKey)) {
conversations.push(conversationKey);
}
await api.updatePushSubscription(currentSubscriptionId, {
filter_mode: 'selected',
filter_conversations: conversations,
});
await refreshSubscriptions();
},
[currentSubscriptionId, allSubscriptions, refreshSubscriptions]
);
const removeConversation = useCallback(
async (conversationKey: string) => {
if (!currentSubscriptionId) return;
const sub = allSubscriptions.find((s) => s.id === currentSubscriptionId);
if (!sub) return;
const conversations = (sub.filter_conversations || []).filter((k) => k !== conversationKey);
await api.updatePushSubscription(currentSubscriptionId, {
filter_conversations: conversations,
});
await refreshSubscriptions();
},
[currentSubscriptionId, allSubscriptions, refreshSubscriptions]
);
const isConversationPushEnabled = useCallback(
(conversationKey: string): boolean => {
if (!currentSubscriptionId) return false;
const sub = allSubscriptions.find((s) => s.id === currentSubscriptionId);
if (!sub) return false;
if (sub.filter_mode === 'all_messages') return true;
if (sub.filter_mode === 'all_dms') return conversationKey.startsWith('contact-');
return (sub.filter_conversations || []).includes(conversationKey);
},
[currentSubscriptionId, allSubscriptions]
);
const deleteSubscription = useCallback(
async (subscriptionId: string) => {
await api.deletePushSubscription(subscriptionId);
if (subscriptionId === currentSubscriptionId) {
setCurrentSubscriptionId(null);
// Also unsubscribe from browser Push API if it's our own
try {
const reg = await navigator.serviceWorker.ready;
const pushSub = await reg.pushManager.getSubscription();
if (pushSub) await pushSub.unsubscribe();
} catch {
// best effort
}
}
await refreshSubscriptions();
},
[currentSubscriptionId, refreshSubscriptions]
);
const testPush = useCallback(async (subscriptionId: string) => {
try {
await api.testPushSubscription(subscriptionId);
toast.success('Test notification sent');
} catch {
toast.error('Test notification failed');
}
}, []);
return {
isSupported,
isSubscribed: !!currentSubscriptionId,
currentSubscriptionId,
allSubscriptions,
loading,
subscribe,
unsubscribe,
addConversation,
removeConversation,
isConversationPushEnabled,
deleteSubscription,
testPush,
refreshSubscriptions,
};
}

View File

@@ -18,3 +18,8 @@ createRoot(document.getElementById('root')!).render(
<App />
</StrictMode>
);
// Register service worker for Web Push (requires secure context)
if ('serviceWorker' in navigator && window.isSecureContext) {
navigator.serviceWorker.register('./sw.js').catch(() => {});
}

View File

@@ -510,6 +510,17 @@ export interface TelemetryHistoryEntry {
data: Record<string, number> & { lpp_sensors?: TelemetryLppSensor[] };
}
export interface PushSubscriptionInfo {
id: string;
endpoint: string;
label: string;
filter_mode: 'all_messages' | 'all_dms' | 'selected';
filter_conversations: string[];
created_at: number;
last_success_at: number | null;
failure_count: number;
}
export interface TraceResponse {
remote_snr: number | null;
local_snr: number | null;