mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Pass 2
This commit is contained in:
+17
-9
@@ -22,7 +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 { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
@@ -100,7 +100,7 @@ export function App() {
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const pushSubscription = usePushSubscription();
|
||||
const pushSubscription = usePush();
|
||||
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||
const {
|
||||
showNewMessage,
|
||||
@@ -625,20 +625,27 @@ export function App() {
|
||||
getStateKey(activeConversation.type, activeConversation.id)
|
||||
)
|
||||
: false,
|
||||
onTogglePush: () => {
|
||||
onTogglePush: async () => {
|
||||
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);
|
||||
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,
|
||||
@@ -673,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())));
|
||||
|
||||
+6
-8
@@ -456,16 +456,14 @@ export const api = {
|
||||
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' }),
|
||||
getPushConversations: () => fetchJson<string[]>('/push/conversations'),
|
||||
togglePushConversation: (key: string) =>
|
||||
fetchJson<string[]>('/push/conversations/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, BellRing, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-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';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
@@ -30,6 +30,7 @@ interface ChatHeaderProps {
|
||||
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;
|
||||
@@ -54,6 +55,7 @@ export function ChatHeader({
|
||||
pushSubscribed,
|
||||
pushEnabledForConversation,
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
@@ -66,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)
|
||||
@@ -296,63 +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
|
||||
</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 → Local
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
|
||||
@@ -86,6 +86,7 @@ interface ConversationPaneProps {
|
||||
pushSubscribed?: boolean;
|
||||
pushEnabledForConversation?: boolean;
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
@@ -163,6 +164,7 @@ export function ConversationPane({
|
||||
pushSubscribed,
|
||||
pushEnabledForConversation,
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
@@ -300,6 +302,7 @@ export function ConversationPane({
|
||||
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,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BellRing, ChevronRight, Logs, MessageSquare, Send, Settings, Trash2 } from 'lucide-react';
|
||||
import { ChevronRight, Logs, MessageSquare, Send, Settings, X } from 'lucide-react';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { usePushSubscription } from '../../hooks/usePushSubscription';
|
||||
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';
|
||||
@@ -43,31 +45,55 @@ import {
|
||||
setStatusDotPulseEnabled as saveStatusDotPulse,
|
||||
} from '../../utils/statusDotPulse';
|
||||
|
||||
function PushDeviceManagement() {
|
||||
/** 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,
|
||||
isSubscribed,
|
||||
allSubscriptions,
|
||||
pushConversations,
|
||||
loading,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
currentSubscriptionId,
|
||||
toggleConversation,
|
||||
deleteSubscription,
|
||||
testPush,
|
||||
refreshSubscriptions,
|
||||
} = usePushSubscription();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
} = usePush();
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) refreshSubscriptions();
|
||||
}, [expanded, refreshSubscriptions]);
|
||||
refreshSubscriptions();
|
||||
}, [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">
|
||||
<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.'}
|
||||
@@ -77,82 +103,107 @@ function PushDeviceManagement() {
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
{!currentSubscriptionId && (
|
||||
<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"
|
||||
{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`}
|
||||
>
|
||||
<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`}
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
@@ -161,9 +212,13 @@ function PushDeviceManagement() {
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
contacts,
|
||||
channels,
|
||||
className,
|
||||
}: {
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
@@ -441,6 +496,10 @@ export function SettingsLocalSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PushDeviceManagement contacts={contacts} channels={channels} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -516,10 +575,6 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PushDeviceManagement />
|
||||
|
||||
{/* ── Style Reference (collapsible) ── */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -29,7 +28,6 @@ function generateLabel(): string {
|
||||
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, '/');
|
||||
@@ -39,14 +37,64 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function usePushSubscription() {
|
||||
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);
|
||||
|
||||
// Check support on mount
|
||||
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 &&
|
||||
@@ -56,105 +104,104 @@ export function usePushSubscription() {
|
||||
setIsSupported(supported);
|
||||
|
||||
if (supported) {
|
||||
// Check if this browser already has an active push subscription
|
||||
// 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) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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();
|
||||
setAllSubscriptions(subs);
|
||||
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 (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;
|
||||
}
|
||||
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);
|
||||
|
||||
// Register/get service worker
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
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);
|
||||
|
||||
// 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);
|
||||
if (requiresRecreate) {
|
||||
await pushSub!.unsubscribe();
|
||||
pushSub = null;
|
||||
}
|
||||
},
|
||||
[isSupported, refreshSubscriptions]
|
||||
);
|
||||
|
||||
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 {
|
||||
// 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(() => {});
|
||||
}
|
||||
@@ -168,50 +215,20 @@ export function usePushSubscription() {
|
||||
}
|
||||
}, [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 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 => {
|
||||
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);
|
||||
return pushConversations.includes(conversationKey);
|
||||
},
|
||||
[currentSubscriptionId, allSubscriptions]
|
||||
[pushConversations]
|
||||
);
|
||||
|
||||
const deleteSubscription = useCallback(
|
||||
@@ -219,7 +236,6 @@ export function usePushSubscription() {
|
||||
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();
|
||||
@@ -247,14 +263,15 @@ export function usePushSubscription() {
|
||||
isSubscribed: !!currentSubscriptionId,
|
||||
currentSubscriptionId,
|
||||
allSubscriptions,
|
||||
pushConversations,
|
||||
loading,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
addConversation,
|
||||
removeConversation,
|
||||
toggleConversation,
|
||||
isConversationPushEnabled,
|
||||
deleteSubscription,
|
||||
testPush,
|
||||
refreshSubscriptions,
|
||||
refreshConversations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,7 +16,9 @@ applyFontScale(getSavedFontScale());
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<PushSubscriptionProvider>
|
||||
<App />
|
||||
</PushSubscriptionProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -513,9 +513,9 @@ export interface TelemetryHistoryEntry {
|
||||
export interface PushSubscriptionInfo {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
label: string;
|
||||
filter_mode: 'all_messages' | 'all_dms' | 'selected';
|
||||
filter_conversations: string[];
|
||||
created_at: number;
|
||||
last_success_at: number | null;
|
||||
failure_count: number;
|
||||
|
||||
Reference in New Issue
Block a user