mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
@@ -26,6 +26,11 @@ interface ChatHeaderProps {
|
||||
onTrace: () => void;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleNotifications: () => void;
|
||||
pushSupported?: boolean;
|
||||
pushSubscribed?: boolean;
|
||||
pushEnabledForConversation?: boolean;
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
@@ -46,6 +51,11 @@ export function ChatHeader({
|
||||
onTrace,
|
||||
onPathDiscovery,
|
||||
onToggleNotifications,
|
||||
pushSupported,
|
||||
pushSubscribed,
|
||||
pushEnabledForConversation,
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
@@ -58,14 +68,29 @@ export function ChatHeader({
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||
const [notifDropdownOpen, setNotifDropdownOpen] = useState(false);
|
||||
const notifDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
setPathHashModeOverrideOpen(false);
|
||||
setNotifDropdownOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
// Close notification dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!notifDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (notifDropdownRef.current && !notifDropdownRef.current.contains(e.target as Node)) {
|
||||
setNotifDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [notifDropdownOpen]);
|
||||
|
||||
const activeChannel =
|
||||
conversation.type === 'channel'
|
||||
? channels.find((channel) => channel.key === conversation.id)
|
||||
@@ -288,34 +313,94 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
|
||||
<div className="relative" ref={notifDropdownRef}>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||
title="Notification settings"
|
||||
aria-label="Notification settings"
|
||||
aria-expanded={notifDropdownOpen}
|
||||
>
|
||||
<Bell
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
notificationsEnabled || pushEnabledForConversation
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{notifDropdownOpen && (
|
||||
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
||||
{notificationsSupported && (
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={notificationsEnabled}
|
||||
disabled={notificationsPermission === 'denied'}
|
||||
onChange={onToggleNotifications}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Desktop notifications (legacy)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{notificationsPermission === 'denied'
|
||||
? 'Blocked by browser — check site permissions'
|
||||
: 'Alerts while this tab is open'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{pushSupported && onTogglePush && (
|
||||
<>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={!!pushEnabledForConversation}
|
||||
onChange={onTogglePush}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Web Push (beta testing)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{pushSubscribed
|
||||
? 'Alerts even when the browser is closed'
|
||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
All notification types require a trusted HTTPS context. Depending on your
|
||||
browser, a snakeoil certificate may not be sufficient.
|
||||
</span>
|
||||
{onOpenPushSettings && (
|
||||
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
||||
Manage Web Push enabled devices in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotifDropdownOpen(false);
|
||||
onOpenPushSettings();
|
||||
}}
|
||||
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
Settings → Local
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
|
||||
@@ -82,6 +82,11 @@ interface ConversationPaneProps {
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
pushSupported?: boolean;
|
||||
pushSubscribed?: boolean;
|
||||
pushEnabledForConversation?: boolean;
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
@@ -155,6 +160,11 @@ export function ConversationPane({
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
pushSupported,
|
||||
pushSubscribed,
|
||||
pushEnabledForConversation,
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
@@ -288,6 +298,11 @@ export function ConversationPane({
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
pushSupported={pushSupported}
|
||||
pushSubscribed={pushSubscribed}
|
||||
pushEnabledForConversation={pushEnabledForConversation}
|
||||
onTogglePush={onTogglePush}
|
||||
onOpenPushSettings={onOpenPushSettings}
|
||||
onTrace={onTrace}
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Channel,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
@@ -49,6 +50,7 @@ interface SettingsModalBaseProps {
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
@@ -86,6 +88,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
channels,
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
@@ -228,6 +231,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
{isSectionVisible('local') && (
|
||||
<SettingsLocalSection
|
||||
onLocalLabelChange={onLocalLabelChange}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronRight, Logs, MessageSquare, Send, Settings, X } from 'lucide-react';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { usePush } from '../../contexts/PushSubscriptionContext';
|
||||
import type { Channel, Contact } from '../../types';
|
||||
import { getContactDisplayName } from '../../utils/pubkey';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
@@ -41,11 +45,180 @@ import {
|
||||
setStatusDotPulseEnabled as saveStatusDotPulse,
|
||||
} from '../../utils/statusDotPulse';
|
||||
|
||||
/** Resolve a state key like "contact-abc123" or "channel-def456" to a display name. */
|
||||
function resolveConversationName(
|
||||
stateKey: string,
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): string {
|
||||
if (stateKey.startsWith('contact-')) {
|
||||
const pubkey = stateKey.slice('contact-'.length);
|
||||
const contact = contacts.find((c) => c.public_key === pubkey);
|
||||
return contact ? getContactDisplayName(contact.name, contact.public_key) : pubkey.slice(0, 12);
|
||||
}
|
||||
if (stateKey.startsWith('channel-')) {
|
||||
const key = stateKey.slice('channel-'.length);
|
||||
const channel = channels.find((c) => c.key === key);
|
||||
if (channel?.name) return channel.name.startsWith('#') ? channel.name : `#${channel.name}`;
|
||||
return `#${key.slice(0, 12)}`;
|
||||
}
|
||||
return stateKey;
|
||||
}
|
||||
|
||||
function PushDeviceManagement({
|
||||
contacts = [],
|
||||
channels = [],
|
||||
}: {
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
}) {
|
||||
const {
|
||||
isSupported,
|
||||
allSubscriptions,
|
||||
pushConversations,
|
||||
loading,
|
||||
subscribe,
|
||||
currentSubscriptionId,
|
||||
toggleConversation,
|
||||
deleteSubscription,
|
||||
testPush,
|
||||
refreshSubscriptions,
|
||||
} = usePush();
|
||||
|
||||
useEffect(() => {
|
||||
refreshSubscriptions();
|
||||
}, [refreshSubscriptions]);
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Web Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{window.isSecureContext
|
||||
? 'Push notifications are not supported by this browser.'
|
||||
: 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Web Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications even when the browser is closed. Use the bell icon in any
|
||||
conversation header to enable push for that contact or channel, or subscribe this browser
|
||||
to receive notifications for all push-enabled conversations.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The set of channels or DMs that trigger push notifications are global per-install (i.e.
|
||||
all devices that register for Web Push will have the same set of channels/DMs that trigger
|
||||
notifications). Subscribing or unsubscribing a particular browser only controls whether
|
||||
that browser receives notifications for the configured set of channels/DMs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!currentSubscriptionId && (
|
||||
<Button variant="outline" size="sm" onClick={() => void subscribe()} disabled={loading}>
|
||||
{loading ? 'Subscribing...' : 'Subscribe This Browser'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{pushConversations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Push-enabled conversations
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pushConversations.map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm"
|
||||
>
|
||||
{resolveConversationName(key, contacts, channels)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleConversation(key)}
|
||||
className="rounded-full p-0.5 hover:bg-accent transition-colors"
|
||||
title="Remove"
|
||||
aria-label={`Remove ${resolveConversationName(key, contacts, channels)} from push`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allSubscriptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Registered Devices
|
||||
</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{allSubscriptions.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{sub.label || 'Unknown device'}
|
||||
</span>
|
||||
{sub.id === currentSubscriptionId && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-primary">
|
||||
Current device
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sub.last_success_at
|
||||
? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}`
|
||||
: 'Never pushed'}
|
||||
{sub.failure_count > 0 && ` · ${sub.failure_count} failures`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-sm"
|
||||
onClick={() => void testPush(sub.id)}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-sm text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
void deleteSubscription(sub.id).then(() => toast.success('Device removed'));
|
||||
}}
|
||||
>
|
||||
Unsubscribe this device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
contacts,
|
||||
channels,
|
||||
className,
|
||||
}: {
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
@@ -323,6 +496,10 @@ export function SettingsLocalSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PushDeviceManagement contacts={contacts} channels={channels} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user