This commit is contained in:
Jack Kingsman
2026-04-16 18:56:57 -07:00
parent 31bd4a0744
commit af76546287
27 changed files with 1352 additions and 473 deletions
+106 -58
View File
@@ -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 &rarr; 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&apos;s push service and will arrive even when you&apos;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"