mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-02 19:43:10 +02:00
Add web push
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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's push service and will arrive even when you'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"
|
||||
|
||||
260
frontend/src/hooks/usePushSubscription.ts
Normal file
260
frontend/src/hooks/usePushSubscription.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user