mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Dice5 } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from './ui/dialog';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
|
import { Input } from './ui/input';
|
|
import { Label } from './ui/label';
|
|
import { Checkbox } from './ui/checkbox';
|
|
import { Button } from './ui/button';
|
|
import { toast } from './ui/sonner';
|
|
|
|
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
|
|
|
interface BulkParseResult {
|
|
channelNames: string[];
|
|
invalidNames: string[];
|
|
}
|
|
|
|
interface NewMessageModalProps {
|
|
open: boolean;
|
|
undecryptedCount: number;
|
|
showBulkAddChannelTab?: boolean;
|
|
prefillRequest?: {
|
|
tab: 'hashtag';
|
|
hashtagName: string;
|
|
nonce: number;
|
|
} | null;
|
|
onClose: () => void;
|
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
|
}
|
|
|
|
function validateHashtagName(channelName: string): string | null {
|
|
if (!channelName) {
|
|
return 'Channel name is required';
|
|
}
|
|
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
|
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
|
|
const tokens = rawText
|
|
.split(/[\s,]+/)
|
|
.map((token) => token.trim())
|
|
.filter(Boolean);
|
|
|
|
const invalidNames: string[] = [];
|
|
const channelNames: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const token of tokens) {
|
|
const stripped = token.replace(/^#+/, '');
|
|
const validationError = validateHashtagName(stripped);
|
|
if (validationError) {
|
|
invalidNames.push(token);
|
|
continue;
|
|
}
|
|
|
|
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
|
|
const channelName = `#${normalized}`;
|
|
if (seen.has(channelName)) {
|
|
continue;
|
|
}
|
|
seen.add(channelName);
|
|
channelNames.push(channelName);
|
|
}
|
|
|
|
return { channelNames, invalidNames };
|
|
}
|
|
|
|
export function NewMessageModal({
|
|
open,
|
|
undecryptedCount,
|
|
showBulkAddChannelTab = false,
|
|
prefillRequest = null,
|
|
onClose,
|
|
onCreateContact,
|
|
onCreateChannel,
|
|
onCreateHashtagChannel,
|
|
onBulkAddHashtagChannels,
|
|
}: NewMessageModalProps) {
|
|
const [tab, setTab] = useState<Tab>('new-contact');
|
|
const [name, setName] = useState('');
|
|
const [contactKey, setContactKey] = useState('');
|
|
const [channelKey, setChannelKey] = useState('');
|
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
|
const [tryHistorical, setTryHistorical] = useState(false);
|
|
const [permitCapitals, setPermitCapitals] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
|
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const resetForm = () => {
|
|
setName('');
|
|
setContactKey('');
|
|
setChannelKey('');
|
|
setBulkChannelText('');
|
|
setTryHistorical(false);
|
|
setPermitCapitals(false);
|
|
setError('');
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
if (prefillRequest) {
|
|
setTab(prefillRequest.tab);
|
|
setName(prefillRequest.hashtagName);
|
|
setContactKey('');
|
|
setChannelKey('');
|
|
setBulkChannelText('');
|
|
setTryHistorical(false);
|
|
setPermitCapitals(false);
|
|
setError('');
|
|
setLoading(false);
|
|
requestAnimationFrame(() => {
|
|
hashtagInputRef.current?.focus();
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (showBulkAddChannelTab) {
|
|
setTab('bulk-hashtag');
|
|
setName('');
|
|
setContactKey('');
|
|
setChannelKey('');
|
|
setBulkChannelText('');
|
|
setTryHistorical(false);
|
|
setPermitCapitals(false);
|
|
setError('');
|
|
setLoading(false);
|
|
requestAnimationFrame(() => {
|
|
bulkTextareaRef.current?.focus();
|
|
});
|
|
return;
|
|
}
|
|
|
|
setTab('new-contact');
|
|
}, [open, prefillRequest, showBulkAddChannelTab]);
|
|
|
|
const handleCreate = async () => {
|
|
setError('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
if (tab === 'new-contact') {
|
|
if (!name.trim() || !contactKey.trim()) {
|
|
setError('Name and public key are required');
|
|
return;
|
|
}
|
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
|
} else if (tab === 'new-channel') {
|
|
if (!name.trim() || !channelKey.trim()) {
|
|
setError('Channel name and key are required');
|
|
return;
|
|
}
|
|
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
|
} else if (tab === 'hashtag') {
|
|
const channelName = name.trim();
|
|
const validationError = validateHashtagName(channelName);
|
|
if (validationError) {
|
|
setError(validationError);
|
|
return;
|
|
}
|
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
|
} else {
|
|
const { channelNames, invalidNames } = parseBulkHashtagNames(
|
|
bulkChannelText,
|
|
permitCapitals
|
|
);
|
|
if (channelNames.length === 0) {
|
|
setError('Enter at least one valid room name');
|
|
return;
|
|
}
|
|
if (invalidNames.length > 0) {
|
|
setError(`Invalid room names: ${invalidNames.join(', ')}`);
|
|
return;
|
|
}
|
|
await onBulkAddHashtagChannels(channelNames, tryHistorical);
|
|
}
|
|
|
|
resetForm();
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error('Failed to create conversation', {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
setError(err instanceof Error ? err.message : 'Failed to create');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateAndAddAnother = async () => {
|
|
setError('');
|
|
const channelName = name.trim();
|
|
const validationError = validateHashtagName(channelName);
|
|
if (validationError) {
|
|
setError(validationError);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
|
setName('');
|
|
hashtagInputRef.current?.focus();
|
|
} catch (err) {
|
|
toast.error('Failed to create conversation', {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
setError(err instanceof Error ? err.message : 'Failed to create');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const showHistoricalOption = undecryptedCount > 0;
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(isOpen) => {
|
|
if (!isOpen) {
|
|
resetForm();
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[560px]">
|
|
<DialogHeader>
|
|
<DialogTitle>New Conversation</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
|
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
|
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
|
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(value) => {
|
|
setTab(value as Tab);
|
|
resetForm();
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<TabsList
|
|
className={
|
|
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
|
}
|
|
>
|
|
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
|
{showBulkAddChannelTab && (
|
|
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
|
|
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="contact-name">Name</Label>
|
|
<Input
|
|
id="contact-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Contact name"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="contact-key">Public Key</Label>
|
|
<Input
|
|
id="contact-key"
|
|
value={contactKey}
|
|
onChange={(e) => setContactKey(e.target.value)}
|
|
placeholder="64-character hex public key"
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="channel-name">Channel Name</Label>
|
|
<Input
|
|
id="channel-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Channel name"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="channel-key">Channel Key</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="channel-key"
|
|
value={channelKey}
|
|
onChange={(e) => setChannelKey(e.target.value)}
|
|
placeholder="Pre-shared key (hex)"
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => {
|
|
const bytes = new Uint8Array(16);
|
|
crypto.getRandomValues(bytes);
|
|
const hex = Array.from(bytes)
|
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
setChannelKey(hex);
|
|
}}
|
|
title="Generate random key"
|
|
aria-label="Generate random key"
|
|
>
|
|
<Dice5 className="h-4 w-4" aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="hashtag" className="mt-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="hashtag-name">Hashtag Channel</Label>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-sm text-muted-foreground">#</span>
|
|
<Input
|
|
ref={hashtagInputRef}
|
|
id="hashtag-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="channel-name"
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 space-y-1">
|
|
<label className="flex cursor-pointer items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={permitCapitals}
|
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
|
className="h-4 w-4 rounded border-input accent-primary"
|
|
/>
|
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
|
</label>
|
|
<p className="pl-7 text-xs text-muted-foreground">
|
|
Not recommended; most companions normalize to lowercase
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{showBulkAddChannelTab && (
|
|
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
|
<textarea
|
|
ref={bulkTextareaRef}
|
|
id="bulk-hashtag-names"
|
|
aria-label="Bulk channel names"
|
|
value={bulkChannelText}
|
|
onChange={(e) => setBulkChannelText(e.target.value)}
|
|
placeholder={'#ops\nmesh-room\nanother-room'}
|
|
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
|
stripped automatically.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="flex cursor-pointer items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={permitCapitals}
|
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
|
className="h-4 w-4 rounded border-input accent-primary"
|
|
/>
|
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
|
</label>
|
|
<p className="pl-7 text-xs text-muted-foreground">
|
|
Not recommended; most companions normalize to lowercase
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
|
|
{showHistoricalOption && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-end space-x-2">
|
|
<Label
|
|
htmlFor="try-historical"
|
|
className="cursor-pointer text-sm text-muted-foreground"
|
|
>
|
|
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
|
{undecryptedCount !== 1 ? 's' : ''}
|
|
</Label>
|
|
<Checkbox
|
|
id="try-historical"
|
|
checked={tryHistorical}
|
|
onCheckedChange={(checked) => setTryHistorical(checked === true)}
|
|
/>
|
|
</div>
|
|
{tryHistorical && (
|
|
<p className="text-right text-xs text-muted-foreground">
|
|
Messages will stream in as they decrypt in the background
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="text-sm text-destructive" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
resetForm();
|
|
onClose();
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{tab === 'hashtag' && (
|
|
<Button variant="secondary" onClick={handleCreateAndAddAnother} disabled={loading}>
|
|
{loading ? 'Creating...' : 'Create & Add Another'}
|
|
</Button>
|
|
)}
|
|
<Button onClick={handleCreate} disabled={loading}>
|
|
{loading
|
|
? tab === 'bulk-hashtag'
|
|
? 'Adding...'
|
|
: 'Creating...'
|
|
: tab === 'bulk-hashtag'
|
|
? 'Add Channels'
|
|
: 'Create'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|