Merge pull request #183 from jkingsman/web-push

Add web push
This commit is contained in:
Jack Kingsman
2026-04-16 23:12:39 -07:00
committed by GitHub
32 changed files with 2799 additions and 48 deletions
+33
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 { usePush } from './contexts/PushSubscriptionContext';
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 = usePush();
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
const {
showNewMessage,
@@ -615,6 +617,36 @@ 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: async () => {
if (
!activeConversation ||
(activeConversation.type !== 'contact' && activeConversation.type !== 'channel')
)
return;
const key = getStateKey(activeConversation.type, activeConversation.id);
const pushEnabled = pushSubscription.isConversationPushEnabled(key);
if (!pushEnabled && !pushSubscription.isSubscribed) {
const subscriptionId = await pushSubscription.subscribe();
if (!subscriptionId) {
return;
}
}
await pushSubscription.toggleConversation(key);
},
onOpenPushSettings: () => {
setSettingsSection('local');
if (!showSettings) handleToggleSettingsView();
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
repeaterAutoLoginKey,
@@ -648,6 +680,7 @@ export function App() {
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
contacts,
channels,
onBulkDeleteContacts: (deletedKeys: string[]) => {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
+25
View File
@@ -22,6 +22,7 @@ import type {
RadioTraceResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
PushSubscriptionInfo,
ResendChannelMessageResponse,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
@@ -441,4 +442,28 @@ 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'),
deletePushSubscription: (id: string) =>
fetchJson<{ deleted: boolean }>(`/push/subscriptions/${id}`, { method: 'DELETE' }),
testPushSubscription: (id: string) =>
fetchJson<{ status: string }>(`/push/subscriptions/${id}/test`, { method: 'POST' }),
getPushConversations: () => fetchJson<string[]>('/push/conversations'),
togglePushConversation: (key: string) =>
fetchJson<string[]>('/push/conversations/toggle', {
method: 'POST',
body: JSON.stringify({ key }),
}),
};
+113 -28
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
@@ -26,6 +26,11 @@ interface ChatHeaderProps {
onTrace: () => void;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleNotifications: () => void;
pushSupported?: boolean;
pushSubscribed?: boolean;
pushEnabledForConversation?: boolean;
onTogglePush?: () => void;
onOpenPushSettings?: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
@@ -46,6 +51,11 @@ export function ChatHeader({
onTrace,
onPathDiscovery,
onToggleNotifications,
pushSupported,
pushSubscribed,
pushEnabledForConversation,
onTogglePush,
onOpenPushSettings,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
@@ -58,14 +68,29 @@ export function ChatHeader({
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
const [notifDropdownOpen, setNotifDropdownOpen] = useState(false);
const notifDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setShowKey(false);
setPathDiscoveryOpen(false);
setChannelOverrideOpen(false);
setPathHashModeOverrideOpen(false);
setNotifDropdownOpen(false);
}, [conversation.id]);
// Close notification dropdown on outside click
useEffect(() => {
if (!notifDropdownOpen) return;
const handler = (e: MouseEvent) => {
if (notifDropdownRef.current && !notifDropdownRef.current.contains(e.target as Node)) {
setNotifDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [notifDropdownOpen]);
const activeChannel =
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
@@ -288,34 +313,94 @@ export function ChatHeader({
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && !activeContactIsRoomServer && (
<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={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
Notifications On
</span>
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
<div className="relative" ref={notifDropdownRef}>
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setNotifDropdownOpen((v) => !v)}
title="Notification settings"
aria-label="Notification settings"
aria-expanded={notifDropdownOpen}
>
<Bell
className={cn(
'h-4 w-4',
notificationsEnabled || pushEnabledForConversation
? 'text-primary'
: 'text-muted-foreground'
)}
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
aria-hidden="true"
/>
</button>
{notifDropdownOpen && (
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
{notificationsSupported && (
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
checked={notificationsEnabled}
disabled={notificationsPermission === 'denied'}
onChange={onToggleNotifications}
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground block leading-tight">
Desktop notifications (legacy)
</span>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
{notificationsPermission === 'denied'
? 'Blocked by browser — check site permissions'
: 'Alerts while this tab is open'}
</span>
</div>
</label>
)}
{pushSupported && onTogglePush && (
<>
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
checked={!!pushEnabledForConversation}
onChange={onTogglePush}
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground block leading-tight">
Web Push (beta testing)
</span>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
{pushSubscribed
? 'Alerts even when the browser is closed'
: 'Alerts even when the browser is closed. Requires HTTPS.'}
</span>
</div>
</label>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
All notification types require a trusted HTTPS context. Depending on your
browser, a snakeoil certificate may not be sufficient.
</span>
{onOpenPushSettings && (
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
Manage Web Push enabled devices in{' '}
<button
type="button"
onClick={() => {
setNotifDropdownOpen(false);
onOpenPushSettings();
}}
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Settings &rarr; Local
</button>
.
</p>
)}
</>
)}
</div>
)}
</button>
</div>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
@@ -82,6 +82,11 @@ interface ConversationPaneProps {
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
pushSupported?: boolean;
pushSubscribed?: boolean;
pushEnabledForConversation?: boolean;
onTogglePush?: () => void;
onOpenPushSettings?: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
repeaterAutoLoginKey: string | null;
@@ -155,6 +160,11 @@ export function ConversationPane({
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
pushSupported,
pushSubscribed,
pushEnabledForConversation,
onTogglePush,
onOpenPushSettings,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
repeaterAutoLoginKey,
@@ -288,6 +298,11 @@ export function ConversationPane({
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
pushSupported={pushSupported}
pushSubscribed={pushSubscribed}
pushEnabledForConversation={pushEnabledForConversation}
onTogglePush={onTogglePush}
onOpenPushSettings={onOpenPushSettings}
onTrace={onTrace}
onPathDiscovery={onPathDiscovery}
onToggleNotifications={onToggleNotifications}
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
Channel,
Contact,
HealthStatus,
RadioAdvertMode,
@@ -49,6 +50,7 @@ interface SettingsModalBaseProps {
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
channels?: Channel[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
@@ -86,6 +88,7 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedKey,
onToggleBlockedName,
contacts,
channels,
onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
@@ -228,6 +231,8 @@ export function SettingsModal(props: SettingsModalProps) {
{isSectionVisible('local') && (
<SettingsLocalSection
onLocalLabelChange={onLocalLabelChange}
contacts={contacts}
channels={channels}
className={sectionContentClass}
/>
)}
@@ -1,5 +1,9 @@
import { useState } from 'react';
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
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 { Input } from '../ui/input';
import { Label } from '../ui/label';
@@ -41,11 +45,180 @@ import {
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 (
<div className="space-y-3">
<Label>Web Push Notifications</Label>
<p className="text-sm 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-4">
<div className="space-y-1">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
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.
</p>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
{!currentSubscriptionId && (
<Button variant="outline" size="sm" onClick={() => void subscribe()} disabled={loading}>
{loading ? 'Subscribing...' : 'Subscribe This Browser'}
</Button>
)}
{pushConversations.length > 0 && (
<div className="space-y-2">
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Push-enabled conversations
</span>
<div className="flex flex-wrap gap-1.5">
{pushConversations.map((key) => (
<span
key={key}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm"
>
{resolveConversationName(key, contacts, channels)}
<button
type="button"
onClick={() => void toggleConversation(key)}
className="rounded-full p-0.5 hover:bg-accent transition-colors"
title="Remove"
aria-label={`Remove ${resolveConversationName(key, contacts, channels)} from push`}
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
</div>
</div>
)}
{allSubscriptions.length > 0 && (
<div className="space-y-2">
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Registered Devices
</span>
<div className="mt-2 space-y-2">
{allSubscriptions.map((sub) => (
<div
key={sub.id}
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 overflow-hidden">
<span className="truncate text-sm font-medium">
{sub.label || 'Unknown device'}
</span>
{sub.id === currentSubscriptionId && (
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-primary">
Current device
</span>
)}
</div>
<span className="text-xs 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-8 text-sm"
onClick={() => void testPush(sub.id)}
>
Test
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-sm text-destructive hover:text-destructive"
onClick={() => {
void deleteSubscription(sub.id).then(() => toast.success('Device removed'));
}}
>
Unsubscribe this device
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function SettingsLocalSection({
onLocalLabelChange,
contacts,
channels,
className,
}: {
onLocalLabelChange?: (label: LocalLabel) => void;
contacts?: Contact[];
channels?: Channel[];
className?: string;
}) {
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
@@ -323,6 +496,10 @@ export function SettingsLocalSection({
</p>
</div>
</div>
<Separator />
<PushDeviceManagement contacts={contacts} channels={channels} />
</div>
);
}
@@ -0,0 +1,35 @@
import { createContext, useContext, type ReactNode } from 'react';
import { usePushSubscription, type PushSubscriptionState } from '../hooks/usePushSubscription';
const noopAsync = async () => {};
const noopAsyncNull = async () => null;
const defaultState: PushSubscriptionState = {
isSupported: false,
isSubscribed: false,
currentSubscriptionId: null,
allSubscriptions: [],
pushConversations: [],
loading: false,
subscribe: noopAsyncNull,
unsubscribe: noopAsync,
toggleConversation: noopAsync,
isConversationPushEnabled: () => false,
deleteSubscription: noopAsync,
testPush: noopAsync,
refreshSubscriptions: async () => [],
refreshConversations: noopAsync,
};
const PushSubscriptionContext = createContext<PushSubscriptionState>(defaultState);
export function PushSubscriptionProvider({ children }: { children: ReactNode }) {
const push = usePushSubscription();
return (
<PushSubscriptionContext.Provider value={push}>{children}</PushSubscriptionContext.Provider>
);
}
export function usePush(): PushSubscriptionState {
return useContext(PushSubscriptionContext);
}
+277
View File
@@ -0,0 +1,277 @@
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;
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';
}
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;
}
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
if (!a || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
function getApplicationServerKeyBytes(
key: ArrayBuffer | ArrayBufferView | null | undefined
): Uint8Array | null {
if (!key) return null;
if (ArrayBuffer.isView(key)) {
return new Uint8Array(key.buffer, key.byteOffset, key.byteLength);
}
return new Uint8Array(key);
}
export interface PushSubscriptionState {
isSupported: boolean;
isSubscribed: boolean;
currentSubscriptionId: string | null;
allSubscriptions: PushSubscriptionInfo[];
/** Global list of push-enabled conversation state keys (device-independent). */
pushConversations: string[];
loading: boolean;
subscribe: () => Promise<string | null>;
unsubscribe: () => Promise<void>;
/** Toggle a conversation in the global push list (device-independent). */
toggleConversation: (conversationKey: string) => Promise<void>;
isConversationPushEnabled: (conversationKey: string) => boolean;
deleteSubscription: (subscriptionId: string) => Promise<void>;
testPush: (subscriptionId: string) => Promise<void>;
refreshSubscriptions: () => Promise<PushSubscriptionInfo[]>;
refreshConversations: () => Promise<void>;
}
export function usePushSubscription(): PushSubscriptionState {
const [isSupported, setIsSupported] = useState(false);
const [currentSubscriptionId, setCurrentSubscriptionId] = useState<string | null>(null);
const [allSubscriptions, setAllSubscriptions] = useState<PushSubscriptionInfo[]>([]);
const [pushConversations, setPushConversations] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const vapidKeyRef = useRef<string | null>(null);
const reconcileCurrentSubscription = useCallback(
(subs: PushSubscriptionInfo[], endpoint: string | null) => {
setAllSubscriptions(subs);
if (!endpoint) {
setCurrentSubscriptionId(null);
return;
}
const match = subs.find((sub) => sub.endpoint === endpoint);
setCurrentSubscriptionId(match?.id ?? null);
},
[]
);
useEffect(() => {
const supported =
window.isSecureContext &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window;
setIsSupported(supported);
if (supported) {
// Always load all registered devices so Settings can manage them even
// when this particular browser isn't subscribed.
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
// Check if THIS browser has an active push subscription and match it
// to a backend record.
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then(async (sub) => {
const existing = await subsPromise;
reconcileCurrentSubscription(existing, sub?.endpoint ?? null);
})
.catch(() => {});
// Load global conversation list
api
.getPushConversations()
.then(setPushConversations)
.catch(() => {});
}
}, [reconcileCurrentSubscription]);
const refreshSubscriptions = useCallback(async () => {
try {
const subs = await api.getPushSubscriptions();
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
return subs;
} catch {
return [];
}
}, [reconcileCurrentSubscription]);
const refreshConversations = useCallback(async () => {
try {
const convos = await api.getPushConversations();
setPushConversations(convos);
} catch {
// best effort
}
}, []);
const subscribe = useCallback(async (): Promise<string | null> => {
if (!isSupported) return null;
setLoading(true);
try {
const resp = await api.getVapidPublicKey();
vapidKeyRef.current = resp.public_key;
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
const reg = await navigator.serviceWorker.ready;
let pushSub = await reg.pushManager.getSubscription();
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
const requiresRecreate =
pushSub !== null && !uint8ArraysEqual(existingKeyBytes, vapidKeyBytes);
if (requiresRecreate) {
await pushSub!.unsubscribe();
pushSub = null;
}
if (!pushSub) {
pushSub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidKeyBytes.buffer as ArrayBuffer,
});
}
const json = pushSub.toJSON();
const result = await api.pushSubscribe({
endpoint: json.endpoint!,
p256dh: json.keys!.p256dh!,
auth: json.keys!.auth!,
label: generateLabel(),
});
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 {
const reg = await navigator.serviceWorker.ready;
const pushSub = await reg.pushManager.getSubscription();
if (pushSub) await pushSub.unsubscribe();
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 toggleConversation = useCallback(async (conversationKey: string) => {
try {
const updated = await api.togglePushConversation(conversationKey);
setPushConversations(updated);
} catch {
toast.error('Failed to update push preferences');
}
}, []);
const isConversationPushEnabled = useCallback(
(conversationKey: string): boolean => {
return pushConversations.includes(conversationKey);
},
[pushConversations]
);
const deleteSubscription = useCallback(
async (subscriptionId: string) => {
await api.deletePushSubscription(subscriptionId);
if (subscriptionId === currentSubscriptionId) {
setCurrentSubscriptionId(null);
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,
pushConversations,
loading,
subscribe,
unsubscribe,
toggleConversation,
isConversationPushEnabled,
deleteSubscription,
testPush,
refreshSubscriptions,
refreshConversations,
};
}
+9 -1
View File
@@ -6,6 +6,7 @@ import './themes.css';
import './styles.css';
import { getSavedTheme, applyTheme, initFollowOSListener } from './utils/theme';
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
import { PushSubscriptionProvider } from './contexts/PushSubscriptionContext';
// Apply saved theme before first render
applyTheme(getSavedTheme());
@@ -15,6 +16,13 @@ applyFontScale(getSavedFontScale());
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<PushSubscriptionProvider>
<App />
</PushSubscriptionProvider>
</StrictMode>
);
// Register service worker for Web Push (requires secure context)
if ('serviceWorker' in navigator && window.isSecureContext) {
navigator.serviceWorker.register('./sw.js').catch(() => {});
}
+70
View File
@@ -29,6 +29,13 @@ const mocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
},
push: {
isSupported: false,
isSubscribed: false,
subscribe: vi.fn<() => Promise<string | null>>(async () => null),
toggleConversation: vi.fn(async () => {}),
isConversationPushEnabled: vi.fn(() => false),
},
hookFns: {
fetchOlderMessages: vi.fn(async () => {}),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
@@ -51,6 +58,25 @@ vi.mock('../useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../contexts/PushSubscriptionContext', () => ({
usePush: () => ({
isSupported: mocks.push.isSupported,
isSubscribed: mocks.push.isSubscribed,
currentSubscriptionId: mocks.push.isSubscribed ? 'sub-1' : null,
allSubscriptions: [],
pushConversations: [],
loading: false,
subscribe: mocks.push.subscribe,
unsubscribe: vi.fn(async () => {}),
toggleConversation: mocks.push.toggleConversation,
isConversationPushEnabled: mocks.push.isConversationPushEnabled,
deleteSubscription: vi.fn(async () => {}),
testPush: vi.fn(async () => {}),
refreshSubscriptions: vi.fn(async () => []),
refreshConversations: vi.fn(async () => {}),
}),
}));
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>();
return {
@@ -209,6 +235,10 @@ const publicChannel = {
describe('App favorite toggle flow', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.push.isSupported = false;
mocks.push.isSubscribed = false;
mocks.push.subscribe.mockResolvedValue(null);
mocks.push.isConversationPushEnabled.mockReturnValue(false);
mocks.api.getRadioConfig.mockResolvedValue(baseConfig);
mocks.api.getSettings.mockResolvedValue({ ...baseSettings });
@@ -313,4 +343,44 @@ describe('App favorite toggle flow', () => {
expect(screen.queryByTestId('settings-modal-section')).not.toBeInTheDocument();
});
});
it('subscribes this browser before enabling web push for a conversation', async () => {
mocks.push.isSupported = true;
mocks.push.isSubscribed = false;
mocks.push.subscribe.mockResolvedValue('sub-1');
render(<App />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Notification settings' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }));
fireEvent.click(screen.getByRole('checkbox', { name: /web push/i }));
await waitFor(() => {
expect(mocks.push.subscribe).toHaveBeenCalledTimes(1);
expect(mocks.push.toggleConversation).toHaveBeenCalledWith(`channel-${publicChannel.key}`);
});
});
it('does not enable web push when subscription setup fails', async () => {
mocks.push.isSupported = true;
mocks.push.isSubscribed = false;
mocks.push.subscribe.mockResolvedValue(null);
render(<App />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Notification settings' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }));
fireEvent.click(screen.getByRole('checkbox', { name: /web push/i }));
await waitFor(() => {
expect(mocks.push.subscribe).toHaveBeenCalledTimes(1);
});
expect(mocks.push.toggleConversation).not.toHaveBeenCalled();
});
});
@@ -150,7 +150,7 @@ describe('ChatHeader key visibility', () => {
expect(screen.getAllByText('#Esperance')).toHaveLength(2);
});
it('shows enabled notification state and toggles when clicked', () => {
it('shows filled bell when notifications are enabled and toggles via dropdown', () => {
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
const onToggleNotifications = vi.fn();
@@ -164,12 +164,40 @@ describe('ChatHeader key visibility', () => {
/>
);
fireEvent.click(screen.getByText('Notifications On'));
// Bell button should be present; open the dropdown
const bellBtn = screen.getByRole('button', { name: 'Notification settings' });
fireEvent.click(bellBtn);
expect(screen.getByText('Notifications On')).toBeInTheDocument();
// Desktop notifications checkbox should be checked
const checkbox = screen.getByRole('checkbox', { name: /desktop notifications/i });
expect(checkbox).toBeChecked();
// Toggling calls the handler
fireEvent.click(checkbox);
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('keeps desktop notifications available when web push is also supported', () => {
const conversation: Conversation = { type: 'contact', id: '13'.repeat(32), name: 'Alice' };
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
pushSupported
pushSubscribed
pushEnabledForConversation
onTogglePush={vi.fn()}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }));
expect(screen.getByRole('checkbox', { name: /desktop notifications/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /web push/i })).toBeInTheDocument();
});
it('hides trace and notification controls for room-server contacts', () => {
const pubKey = '41'.repeat(32);
const contact: Contact = {
@@ -198,9 +226,7 @@ describe('ChatHeader key visibility', () => {
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument();
});
it('hides the delete button for the canonical Public channel', () => {
@@ -0,0 +1,203 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { usePushSubscription } from '../hooks/usePushSubscription';
const mocks = vi.hoisted(() => ({
api: {
getPushSubscriptions: vi.fn(),
getPushConversations: vi.fn(),
getVapidPublicKey: vi.fn(),
pushSubscribe: vi.fn(),
deletePushSubscription: vi.fn(),
togglePushConversation: vi.fn(),
testPushSubscription: vi.fn(),
},
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
function bytesToBase64Url(bytes: number[]): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
describe('usePushSubscription', () => {
const vapidOldBytes = [1, 2, 3, 4];
const vapidNewBytes = [5, 6, 7, 8];
const oldKey = new Uint8Array(vapidOldBytes).buffer;
const newKeyBase64 = bytesToBase64Url(vapidNewBytes);
let activeSubscription: {
endpoint: string;
options: { applicationServerKey: ArrayBuffer };
toJSON: () => { endpoint: string; keys: { p256dh: string; auth: string } };
unsubscribe: ReturnType<typeof vi.fn>;
} | null;
let replacementSubscription: {
endpoint: string;
options: { applicationServerKey: ArrayBuffer };
toJSON: () => { endpoint: string; keys: { p256dh: string; auth: string } };
unsubscribe: ReturnType<typeof vi.fn>;
};
let getSubscriptionMock: ReturnType<typeof vi.fn>;
let subscribeMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
activeSubscription = {
endpoint: 'https://push.example.test/sub-old',
options: { applicationServerKey: oldKey },
toJSON: () => ({
endpoint: 'https://push.example.test/sub-old',
keys: { p256dh: 'p256dh-old', auth: 'auth-old' },
}),
unsubscribe: vi.fn(async () => {
activeSubscription = null;
return true;
}),
};
replacementSubscription = {
endpoint: 'https://push.example.test/sub-new',
options: { applicationServerKey: new Uint8Array(vapidNewBytes).buffer },
toJSON: () => ({
endpoint: 'https://push.example.test/sub-new',
keys: { p256dh: 'p256dh-new', auth: 'auth-new' },
}),
unsubscribe: vi.fn(async () => true),
};
getSubscriptionMock = vi.fn(async () => activeSubscription);
subscribeMock = vi.fn(async () => {
activeSubscription = replacementSubscription;
return replacementSubscription;
});
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
});
Object.defineProperty(window, 'PushManager', {
configurable: true,
value: function PushManager() {},
});
Object.defineProperty(window, 'Notification', {
configurable: true,
value: function Notification() {},
});
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: Promise.resolve({
pushManager: {
getSubscription: getSubscriptionMock,
subscribe: subscribeMock,
},
}),
},
});
mocks.api.getPushConversations.mockResolvedValue([]);
mocks.api.getPushSubscriptions.mockResolvedValue([
{
id: 'sub-1',
endpoint: 'https://push.example.test/sub-old',
p256dh: 'p256dh-old',
auth: 'auth-old',
label: 'Chrome on macOS',
created_at: 1,
last_success_at: null,
failure_count: 0,
},
]);
mocks.api.getVapidPublicKey.mockResolvedValue({ public_key: newKeyBase64 });
mocks.api.pushSubscribe.mockResolvedValue({
id: 'sub-2',
endpoint: 'https://push.example.test/sub-new',
});
});
it('clears currentSubscriptionId when refresh no longer finds this browser on the backend', async () => {
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => {
expect(result.current.currentSubscriptionId).toBe('sub-1');
expect(result.current.isSubscribed).toBe(true);
});
mocks.api.getPushSubscriptions.mockResolvedValueOnce([]);
await act(async () => {
await result.current.refreshSubscriptions();
});
expect(result.current.currentSubscriptionId).toBeNull();
expect(result.current.isSubscribed).toBe(false);
expect(result.current.allSubscriptions).toEqual([]);
});
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
const oldSubscription = activeSubscription;
mocks.api.getPushSubscriptions
.mockReset()
.mockResolvedValueOnce([
{
id: 'sub-1',
endpoint: 'https://push.example.test/sub-old',
p256dh: 'p256dh-old',
auth: 'auth-old',
label: 'Chrome on macOS',
created_at: 1,
last_success_at: null,
failure_count: 0,
},
])
.mockResolvedValueOnce([
{
id: 'sub-2',
endpoint: 'https://push.example.test/sub-new',
p256dh: 'p256dh-new',
auth: 'auth-new',
label: 'Chrome on macOS',
created_at: 2,
last_success_at: null,
failure_count: 0,
},
]);
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => {
expect(result.current.isSupported).toBe(true);
});
await act(async () => {
await result.current.subscribe();
});
expect(oldSubscription?.unsubscribe).toHaveBeenCalledTimes(1);
expect(activeSubscription).toBe(replacementSubscription);
expect(subscribeMock).toHaveBeenCalledTimes(1);
expect(mocks.api.pushSubscribe).toHaveBeenCalledWith({
endpoint: 'https://push.example.test/sub-new',
p256dh: 'p256dh-new',
auth: 'auth-new',
label: expect.any(String),
});
expect(result.current.currentSubscriptionId).toBe('sub-2');
});
});
+11
View File
@@ -510,6 +510,17 @@ export interface TelemetryHistoryEntry {
data: Record<string, number> & { lpp_sensors?: TelemetryLppSensor[] };
}
export interface PushSubscriptionInfo {
id: string;
endpoint: string;
p256dh: string;
auth: string;
label: string;
created_at: number;
last_success_at: number | null;
failure_count: number;
}
export interface TraceResponse {
remote_snr: number | null;
local_snr: number | null;