Release v0.9.79 runtime and messaging update

Ship the v0.9.79 runtime refresh with transport lane isolation, Infonet secure-message address management, MeshChat MQTT controls, selected asset trail behavior, telemetry panel refinements, onboarding updates, and desktop/package metadata alignment.

Also ignore local graphify work products so analysis folders do not leak into future commits.
This commit is contained in:
BigBodyCobain
2026-05-12 11:49:46 -06:00
parent 85636ce95c
commit b86a258535
49 changed files with 1779 additions and 772 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ import {
Heart,
} from 'lucide-react';
const CURRENT_VERSION = '0.9.75';
const CURRENT_VERSION = '0.9.79';
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening';
@@ -6,7 +6,9 @@ import {
Ban,
Check,
ChevronLeft,
Copy,
Inbox,
KeyRound,
Mail,
PencilLine,
RefreshCcw,
@@ -89,13 +91,18 @@ import {
import {
fetchWormholeStatus,
fetchWormholeIdentity,
exportWormholeDmInvite,
getWormholeDmInviteImportErrorResult,
importWormholeDmInvite,
isWormholeReady,
isWormholeSecureRequired,
listWormholeDmInviteHandles,
prepareWormholeInteractiveLane,
issueWormholePairwiseAlias,
openWormholeSenderSeal,
renameWormholeDmInviteHandle,
revokeWormholeDmInviteHandle,
type WormholeDmAddressRecord,
} from '@/mesh/wormholeIdentityClient';
import {
updatePrivateDeliveryAction,
@@ -149,6 +156,23 @@ interface MailboxSnapshot {
items: MailItem[];
}
interface LocalDmAddress {
id: string;
label: string;
handle: string;
peerId: string;
trustFingerprint: string;
inviteBlob: string;
createdAt: number;
revokedAt?: number;
expiresAt?: number;
}
interface LocalDmAddressSnapshot {
version: 1;
addresses: LocalDmAddress[];
}
interface ComposeDraft {
recipient: string;
subject: string;
@@ -179,6 +203,10 @@ function mailboxStorageKey(scopeId: string): string {
return `sb_infonet_mailbox_v1:${scopeId}`;
}
function dmAddressStorageKey(scopeId: string): string {
return `sb_infonet_dm_addresses_v1:${scopeId}`;
}
function sortMessages(items: MailItem[]): MailItem[] {
return [...items].sort((a, b) => {
if (b.timestamp !== a.timestamp) {
@@ -253,6 +281,63 @@ function saveMailbox(scopeId: string, items: MailItem[]): void {
}
}
function loadDmAddresses(scopeId: string): LocalDmAddress[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(dmAddressStorageKey(scopeId));
if (!raw) return [];
const parsed = JSON.parse(raw) as LocalDmAddressSnapshot;
if (parsed?.version !== STORAGE_VERSION || !Array.isArray(parsed.addresses)) {
return [];
}
return parsed.addresses
.filter((item) => item && typeof item.handle === 'string' && item.handle.trim())
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
} catch {
return [];
}
}
function saveDmAddresses(scopeId: string, addresses: LocalDmAddress[]): void {
if (typeof window === 'undefined') return;
try {
const payload: LocalDmAddressSnapshot = {
version: STORAGE_VERSION,
addresses: addresses.slice(0, 32),
};
localStorage.setItem(dmAddressStorageKey(scopeId), JSON.stringify(payload));
} catch {
/* ignore */
}
}
function inviteLookupHandle(invite: Record<string, unknown> | undefined): string {
const payload = invite?.payload;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return '';
}
return String((payload as Record<string, unknown>).prekey_lookup_handle || '').trim();
}
function shortHandle(value: string): string {
const clean = String(value || '').trim();
if (clean.length <= 16) return clean;
return `${clean.slice(0, 8)}...${clean.slice(-6)}`;
}
function formatDmAddressDate(value?: number): string {
if (!value) return 'never';
try {
return new Date(value * 1000).toLocaleString();
} catch {
return String(value);
}
}
function dmAddressShareText(address: LocalDmAddress): string {
return String(address.handle || '').trim();
}
function encodeMailPayload(subject: string, body: string): string {
const cleanSubject = subject.trim() || 'Secure Message';
return `${MAIL_SUBJECT_PREFIX}${cleanSubject}\n\n${body.trim()}`;
@@ -545,13 +630,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
subject: '',
body: '',
});
const [contactRequestTarget, setContactRequestTarget] = useState('');
const [inviteImportAlias, setInviteImportAlias] = useState('');
const [inviteImportBlob, setInviteImportBlob] = useState('');
const [inviteBusy, setInviteBusy] = useState(false);
const [inviteScanOpen, setInviteScanOpen] = useState(false);
const [inviteScanStatus, setInviteScanStatus] = useState('');
const [dmLaneWarmStatus, setDmLaneWarmStatus] = useState('');
const [dmAddressLabel, setDmAddressLabel] = useState('');
const [dmAddresses, setDmAddresses] = useState<LocalDmAddress[]>([]);
const [remoteDmHandles, setRemoteDmHandles] = useState<Record<string, WormholeDmAddressRecord>>({});
const [dmAddressEditLabels, setDmAddressEditLabels] = useState<Record<string, string>>({});
const [dmAddressBusy, setDmAddressBusy] = useState('');
const [dmAddressCopyStatus, setDmAddressCopyStatus] = useState('');
const [, setDmLaneWarmStatus] = useState('');
const [privateDelivery, setPrivateDelivery] = useState<PrivateDeliverySummary | null>(null);
const [privateDeliveryBusyId, setPrivateDeliveryBusyId] = useState('');
const inviteVideoRef = useRef<HTMLVideoElement | null>(null);
@@ -566,12 +656,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
useEffect(() => {
setMessages(loadMailbox(scopeId));
setDmAddresses(loadDmAddresses(scopeId));
}, [scopeId]);
useEffect(() => {
saveMailbox(scopeId, sortMessages(messages));
}, [messages, scopeId]);
useEffect(() => {
saveDmAddresses(scopeId, dmAddresses);
}, [dmAddresses, scopeId]);
useEffect(() => {
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -651,6 +746,31 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
),
[privateDelivery],
);
const activeDmAddresses = useMemo(
() =>
dmAddresses.filter((address) => {
const server = remoteDmHandles[address.handle];
return !address.revokedAt && !server?.expired && !server?.exhausted && !server?.revoked;
}),
[dmAddresses, remoteDmHandles],
);
const managedDmAddresses = useMemo(() => {
const seen = new Set(dmAddresses.map((address) => address.handle));
const serverOnly = Object.values(remoteDmHandles)
.filter((address) => address.handle && !seen.has(address.handle))
.map<LocalDmAddress>((address) => ({
id: `remote-dm-address-${address.handle}`,
label: address.label || 'DM address',
handle: address.handle,
peerId: '',
trustFingerprint: '',
inviteBlob: '',
createdAt: address.issued_at || 0,
expiresAt: address.expires_at || undefined,
}));
return [...dmAddresses, ...serverOnly].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
}, [dmAddresses, remoteDmHandles]);
const primaryDmAddress = activeDmAddresses[0] || null;
const resolveMessagingIdentity = useCallback(async () => {
const localIdentity = getNodeIdentity();
@@ -1562,25 +1682,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
}
}, [contacts, dmLaneReady, draft, ensureSecureMailLane, identity, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState]);
const handleSendContactRequest = useCallback(async () => {
const recipient = contactRequestTarget.trim();
if (!recipient) {
setComposeError('Enter an agent ID to send a contact request.');
return;
}
setDraft((prev) => ({
...prev,
recipient,
}));
setActiveTab('compose');
setComposeError('');
setComposeStatus(
requiresVerifiedFirstContact(contacts[recipient])
? 'Signed invite import is required before first contact. Unverified TOFU contact requests are disabled.'
: '',
);
}, [contactRequestTarget, contacts]);
const handleImportInvite = useCallback(async () => {
const raw = inviteImportBlob.trim();
if (!raw) {
@@ -1643,6 +1744,174 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
}
}, [ensureSecureMailLane, inviteImportAlias, inviteImportBlob, wormholeReadyState]);
const refreshDmAddressHandles = useCallback(async () => {
try {
const result = await listWormholeDmInviteHandles();
const next: Record<string, WormholeDmAddressRecord> = {};
for (const address of result.addresses || []) {
if (address.handle) {
next[address.handle] = address;
}
}
setRemoteDmHandles(next);
} catch {
/* best effort only */
}
}, []);
useEffect(() => {
if (!identity) return;
void refreshDmAddressHandles();
}, [identity, refreshDmAddressHandles]);
const handleGenerateDmAddress = useCallback(async () => {
setDmAddressBusy('generate');
setDmAddressCopyStatus('');
setComposeError('');
setComposeStatus('');
try {
const label = dmAddressLabel.trim() || `DM address ${new Date().toLocaleString()}`;
const exported = await exportWormholeDmInvite({ label });
if (!exported.ok || !exported.invite) {
throw new Error(exported.detail || 'DM address generation failed');
}
const handle = inviteLookupHandle(exported.invite as unknown as Record<string, unknown>);
if (!handle) {
throw new Error('DM address did not include a lookup handle.');
}
const inviteBlob = JSON.stringify(
{
type: 'shadowbroker.infonet.dm.invite',
version: 1,
label,
created_at: Math.floor(Date.now() / 1000),
invite: exported.invite,
},
null,
2,
);
const record: LocalDmAddress = {
id: randomId('dm-address'),
label,
handle,
peerId: exported.peer_id,
trustFingerprint: exported.trust_fingerprint,
inviteBlob,
createdAt: Math.floor(Date.now() / 1000),
expiresAt: Number((exported.invite.payload as Record<string, unknown>)?.expires_at || 0) || undefined,
};
setDmAddresses((prev) => [record, ...prev.filter((item) => item.handle !== handle)].slice(0, 32));
setDmAddressLabel('');
await navigator.clipboard?.writeText(handle).catch(() => undefined);
setDmAddressCopyStatus(
exported.prekey_publish_pending
? `Generated and copied ${label} address. Private delivery will activate as soon as the lane finishes connecting.`
: `Generated and copied ${label} address.`,
);
await refreshDmAddressHandles();
} catch (error) {
setComposeError(error instanceof Error ? error.message : 'DM address generation failed');
} finally {
setDmAddressBusy('');
}
}, [dmAddressLabel, refreshDmAddressHandles]);
const handleCopyDmAddress = useCallback(async (address: LocalDmAddress) => {
setDmAddressBusy(`copy:${address.handle}`);
setDmAddressCopyStatus('');
try {
const shareText = dmAddressShareText(address);
if (!shareText) {
throw new Error('This address has no local handle to copy.');
}
await navigator.clipboard?.writeText(shareText);
setDmAddressCopyStatus(`Copied ${address.label || shortHandle(address.handle)}.`);
} catch (error) {
setDmAddressCopyStatus(
error instanceof Error ? error.message : 'Copy failed. Select the address and copy it manually.',
);
} finally {
setDmAddressBusy('');
}
}, []);
const handleRevokeDmAddress = useCallback(
async (address: LocalDmAddress) => {
setDmAddressBusy(`revoke:${address.handle}`);
setDmAddressCopyStatus('');
try {
const result = await revokeWormholeDmInviteHandle(address.handle);
setDmAddresses((prev) =>
prev.map((item) =>
item.handle === address.handle
? { ...item, inviteBlob: '', revokedAt: Math.floor(Date.now() / 1000) }
: item,
),
);
setDmAddressCopyStatus(
result.revoked
? `Revoked ${address.label || shortHandle(address.handle)} for new first-contact and removed the local share text.`
: `${address.label || shortHandle(address.handle)} was already inactive.`,
);
await refreshDmAddressHandles();
} catch (error) {
setComposeError(error instanceof Error ? error.message : 'DM address revoke failed');
} finally {
setDmAddressBusy('');
}
},
[refreshDmAddressHandles],
);
const handleRenameDmAddress = useCallback(
async (address: LocalDmAddress, nextLabel: string) => {
const label = nextLabel.trim() || 'DM address';
setDmAddressBusy(`rename:${address.handle}`);
setDmAddressCopyStatus('');
setComposeError('');
try {
const result = await renameWormholeDmInviteHandle(address.handle, label);
if (!result.ok) {
throw new Error(result.detail || 'DM address label update failed');
}
setDmAddresses((prev) => {
const found = prev.some((item) => item.handle === address.handle);
const updated = prev.map((item) =>
item.handle === address.handle ? { ...item, label } : item,
);
return found ? updated : [{ ...address, label }, ...prev].slice(0, 32);
});
setRemoteDmHandles((prev) =>
prev[address.handle]
? { ...prev, [address.handle]: { ...prev[address.handle], label } }
: prev,
);
setDmAddressEditLabels((prev) => {
const next = { ...prev };
delete next[address.handle];
return next;
});
setDmAddressCopyStatus(`Renamed ${shortHandle(address.handle)} to ${label}.`);
await refreshDmAddressHandles();
} catch (error) {
setComposeError(error instanceof Error ? error.message : 'DM address label update failed');
} finally {
setDmAddressBusy('');
}
},
[refreshDmAddressHandles],
);
const handleForgetDmAddress = useCallback((address: LocalDmAddress) => {
setDmAddresses((prev) => prev.filter((item) => item.handle !== address.handle));
setDmAddressEditLabels((prev) => {
const next = { ...prev };
delete next[address.handle];
return next;
});
setDmAddressCopyStatus(`Forgot local record for ${address.label || shortHandle(address.handle)}.`);
}, []);
const handleStartInviteScan = useCallback(() => {
setComposeError('');
setComposeStatus('');
@@ -1850,23 +2119,20 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
}, []);
const statusLine = useMemo(() => {
if (dmLaneWarmStatus) {
return dmLaneWarmStatus;
}
if (!wormholeReadyState) {
return 'Secure mail is preparing the local obfuscated identity in the background.';
}
if (!dmLaneReady) {
return 'Secure mail is starting the direct obfuscated DM transport in the background.';
}
if (!identity) {
return 'Secure mail is preparing the local private identity in the background.';
return 'Secure identity is loading.';
}
if (!wormholeReadyState || !dmLaneReady) {
return 'Private message delivery is connecting. You can generate and copy your public address now.';
}
if (syncing) {
return 'SYNCING SECURE MAILBOX...';
}
return `SECURE MAIL READY - ${serverPendingCount} remote items still pending on the server.`;
}, [dmLaneReady, dmLaneWarmStatus, identity, serverPendingCount, syncing, wormholeReadyState]);
if (serverPendingCount > 0) {
return `SECURE MAIL READY - ${serverPendingCount} queued server item${serverPendingCount === 1 ? '' : 's'} still syncing.`;
}
return 'SECURE MAIL READY';
}, [dmLaneReady, identity, serverPendingCount, syncing, wormholeReadyState]);
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
@@ -1899,6 +2165,62 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
{statusLine}
</div>
<div className="border border-emerald-500/25 bg-emerald-950/5 px-4 py-4 mb-4 shrink-0">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="text-[11px] tracking-[0.2em] uppercase text-emerald-300 flex items-center">
<KeyRound size={14} className="mr-2" />
My Public Address
</div>
<div className="mt-2 text-sm text-gray-300">
{primaryDmAddress ? (
<>
<div className="text-white">{primaryDmAddress.label || 'Default address'}</div>
<div className="mt-2 overflow-auto break-all border border-emerald-500/20 bg-black/40 p-3 font-mono text-[12px] leading-relaxed text-emerald-200">
{dmAddressShareText(primaryDmAddress)}
</div>
</>
) : (
'Generate an address, then send it to someone so they can contact you.'
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{primaryDmAddress && (
<button
type="button"
onClick={() => void handleCopyDmAddress(primaryDmAddress)}
className="px-4 py-2 border border-cyan-500/30 text-cyan-300 text-xs tracking-[0.18em] uppercase"
>
<Copy size={13} className="inline mr-1" />
Copy Address
</button>
)}
<button
type="button"
onClick={() => void handleGenerateDmAddress()}
disabled={dmAddressBusy === 'generate'}
className="px-4 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
{dmAddressBusy === 'generate' ? 'Generating...' : primaryDmAddress ? 'New Address' : 'Generate Address'}
</button>
<button
type="button"
onClick={() => {
setActiveTab('contacts');
setDmAddressCopyStatus('Manage addresses below. Revoke an address to stop new first-contact through it.');
}}
className="px-4 py-2 border border-gray-700 bg-gray-950/20 text-gray-300 text-xs tracking-[0.18em] uppercase"
>
Manage
</button>
</div>
</div>
{dmAddressCopyStatus && (
<div className="mt-3 text-sm text-emerald-300">{dmAddressCopyStatus}</div>
)}
</div>
{(pollError || composeError || composeStatus) && (
<div className="space-y-2 mb-4 shrink-0">
{pollError && (
@@ -2135,13 +2457,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
</div>
<div className="mt-4 border border-amber-500/20 bg-amber-950/10 px-4 py-3 text-xs text-amber-300">
{dmLaneWarmStatus
? dmLaneWarmStatus
: !wormholeReadyState
? 'Secure mail is waking up in the background. You can finish the draft now and send when the lane is ready.'
: !dmLaneReady
? 'Secure mail is bringing the private lane online in the background.'
: 'If the recipient is not already in your contacts, sending from here opens with a secure contact request first. Full mail begins after they accept.'}
{!wormholeReadyState || !dmLaneReady
? 'Private message lane is still connecting. You can write now and send when it is ready.'
: 'To message someone new, paste their public address in Contacts first. Existing contacts can be messaged from here.'}
</div>
{privateDeliveryRows.length > 0 && (
@@ -2209,7 +2527,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
<button
onClick={() => {
setInviteImportAlias((prev) => prev || composeRecipient);
setContactRequestTarget(composeRecipient);
setActiveTab('contacts');
setComposeStatus(
`Import a signed invite for ${composeRecipient} before returning to Compose.`,
@@ -2352,7 +2669,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
<button
onClick={() => {
setInviteImportAlias((prev) => prev || peerId);
setContactRequestTarget(peerId);
setComposeStatus(
`Import a signed invite for ${displayNameForPeer(peerId, contacts)} in the panel below.`,
);
@@ -2400,70 +2716,163 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
</div>
</div>
<div className="border border-gray-800/80 p-6">
<div className="text-xs tracking-[0.2em] uppercase text-cyan-300 mb-4 flex items-center">
<UserPlus size={14} className="mr-2" />
Add Contact
</div>
<label className="text-xs tracking-[0.18em] uppercase text-gray-500">
Agent ID
<input
value={contactRequestTarget}
onChange={(event) => setContactRequestTarget(event.target.value)}
className="mt-2 w-full bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-cyan-500/40"
placeholder="!sb_..."
spellCheck={false}
/>
</label>
<div className="mt-4 text-sm text-gray-500">
Signed invite import is now the required first-contact path for new secure contact
requests. Verify the invite over QR or another trusted side channel first.
</div>
<div className="mt-6">
<div className="flex flex-wrap gap-3">
<div className="space-y-6">
<div className="border border-emerald-500/25 p-6 bg-emerald-950/5">
<div className="text-xs tracking-[0.2em] uppercase text-emerald-300 mb-4 flex items-center">
<KeyRound size={14} className="mr-2" />
Your Share Addresses
</div>
<div className="text-sm text-gray-400 leading-[1.65] mb-4">
This is what you give someone so they can message you. Generate more than one
if you want separate labels, then revoke any address you no longer want to accept
new first-contact requests through.
</div>
<div className="flex gap-2">
<input
value={dmAddressLabel}
onChange={(event) => setDmAddressLabel(event.target.value)}
className="flex-1 bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40"
placeholder="Label, e.g. Signal test, conference, one-off"
spellCheck={false}
/>
<button
onClick={() => void handleSendContactRequest()}
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
Prepare First Contact
</button>
<button
onClick={() => handleStartInviteScan()}
onClick={() => void handleGenerateDmAddress()}
disabled={dmAddressBusy === 'generate'}
className="px-4 py-3 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
Scan Invite QR
{dmAddressBusy === 'generate' ? 'Generating...' : 'Generate'}
</button>
</div>
{dmAddressCopyStatus && (
<div className="mt-3 text-sm text-emerald-300">{dmAddressCopyStatus}</div>
)}
<div className="mt-4 space-y-3">
{managedDmAddresses.length === 0 ? (
<div className="text-sm text-gray-500">No public address yet. Generate one above.</div>
) : (
managedDmAddresses.map((address) => {
const server = remoteDmHandles[address.handle];
const inactive = Boolean(address.revokedAt || server?.expired || server?.exhausted || server?.revoked);
const shareText = dmAddressShareText(address);
const editLabel = dmAddressEditLabels[address.handle] ?? address.label ?? '';
return (
<div key={address.id} className="border border-gray-800/70 p-3">
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm text-white break-words">
{address.label || 'DM address'}
</div>
<div className="mt-1 text-[11px] text-gray-500">
Created: {formatDmAddressDate(address.createdAt)}
{address.expiresAt ? ` / Expires: ${formatDmAddressDate(address.expiresAt)}` : ''}
</div>
<div className={`mt-2 text-[11px] tracking-[0.16em] uppercase ${inactive ? 'text-red-300' : 'text-emerald-300'}`}>
{inactive
? 'Inactive'
: `${server?.remaining_uses ?? 'active'} first-contact uses left`}
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
onClick={() => void handleCopyDmAddress(address)}
disabled={!shareText}
className="px-3 py-2 border border-cyan-500/30 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
title="Copy public address"
>
<Copy size={13} className="inline mr-1" />
Copy
</button>
<button
onClick={() => void handleRevokeDmAddress(address)}
disabled={inactive || dmAddressBusy === `revoke:${address.handle}`}
className="px-3 py-2 border border-red-500/30 text-red-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
>
{dmAddressBusy === `revoke:${address.handle}` ? 'Revoking...' : 'Revoke'}
</button>
{inactive && (
<button
onClick={() => handleForgetDmAddress(address)}
className="px-3 py-2 border border-gray-700 text-gray-300 text-xs tracking-[0.18em] uppercase"
>
Forget
</button>
)}
</div>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<input
value={editLabel}
onChange={(event) =>
setDmAddressEditLabels((prev) => ({
...prev,
[address.handle]: event.target.value,
}))
}
className="bg-transparent border border-gray-800 px-3 py-2 text-xs text-white outline-none focus:border-emerald-500/40"
placeholder="Address label"
spellCheck={false}
/>
<button
onClick={() => void handleRenameDmAddress(address, editLabel)}
disabled={
dmAddressBusy === `rename:${address.handle}` ||
editLabel.trim() === (address.label || 'DM address')
}
className="px-3 py-2 border border-emerald-500/30 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
>
{dmAddressBusy === `rename:${address.handle}` ? 'Saving...' : 'Save Label'}
</button>
</div>
<div className="border border-gray-800 bg-black/40 p-3 text-[11px] text-emerald-200 font-mono break-all">
{shareText || 'Address unavailable locally.'}
</div>
<div className="text-[11px] text-gray-500 leading-relaxed">
Revoking disables this public address for new first-contact requests. Existing approved
contacts remain contacts.
</div>
</div>
</div>
);
})
)}
</div>
</div>
<div className="mt-8 pt-6 border-t border-gray-800/80">
<div className="text-xs tracking-[0.2em] uppercase text-emerald-300 mb-4">
Import Verified Invite
<div className="border border-gray-800/80 p-6">
<div className="text-xs tracking-[0.2em] uppercase text-cyan-300 mb-4 flex items-center">
<UserPlus size={14} className="mr-2" />
Paste Someone&apos;s Address
</div>
<div className="text-sm text-gray-400 leading-[1.65]">
Paste the public address someone gave you. Once imported, they show up as a contact
and you can send secure mail from Compose.
</div>
<div className="mt-6">
<label className="text-xs tracking-[0.18em] uppercase text-gray-500">
Local Alias
<input
value={inviteImportAlias}
onChange={(event) => setInviteImportAlias(event.target.value)}
className="mt-2 w-full bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40"
placeholder="Optional display label"
placeholder="Optional label, e.g. Jordan, field laptop"
spellCheck={false}
/>
</label>
<label className="text-xs tracking-[0.18em] uppercase text-gray-500 block mt-4">
Signed Invite JSON
Public Address
<textarea
value={inviteImportBlob}
onChange={(event) => setInviteImportBlob(event.target.value)}
className="mt-2 w-full min-h-[200px] bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40 font-mono"
placeholder='Paste the full export blob or the nested "invite" object here...'
placeholder="Paste the address blob someone copied from their Secure Messages screen..."
spellCheck={false}
/>
</label>
<div className="mt-4 text-sm text-gray-500 leading-[1.65]">
Importing a signed invite pins first contact to a trusted out-of-band identity
instead of plain first-sight TOFU. Use this when the invite came from QR,
in-person exchange, or another authenticated side channel.
The pasted address is signed. Importing it creates the first-contact anchor for
that person without exposing your private keys.
</div>
{(inviteScanOpen || inviteScanStatus) && (
<div className="mt-4 border border-emerald-500/20 bg-black/30 p-4">
@@ -2490,17 +2899,24 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
)}
</div>
)}
<div className="mt-6">
<div className="mt-6 flex flex-wrap gap-3">
<button
onClick={() => void handleImportInvite()}
disabled={inviteBusy || !inviteImportBlob.trim()}
className="px-4 py-3 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
{inviteBusy ? 'Importing...' : 'Import Signed Invite'}
{inviteBusy ? 'Importing...' : 'Import Address'}
</button>
<button
onClick={() => handleStartInviteScan()}
className="px-4 py-3 border border-gray-700 bg-gray-950/20 text-gray-400 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
>
Camera Scan
</button>
</div>
</div>
</div>
</div>
</div>
)}
+7 -2
View File
@@ -275,6 +275,11 @@ function hasKnownRouteName(value?: string | null): boolean {
function flightHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>, dynamicRoute: DynamicRoute | null): boolean {
if (!entity) return false;
if (dynamicRoute?.orig_loc && dynamicRoute?.dest_loc) return true;
return flightPayloadHasKnownRoute(entity);
}
function flightPayloadHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>): boolean {
if (!entity) return false;
if (!('origin_loc' in entity) && !('origin_name' in entity)) return false;
const flight = entity as Flight;
return Boolean(
@@ -653,7 +658,7 @@ const MaplibreViewer = ({
};
}
if (isFlight && flightHasKnownRoute(entity, dynamicRoute)) {
if (isFlight && flightPayloadHasKnownRoute(entity)) {
setSelectedTrailPoints([]);
return () => {
cancelled = true;
@@ -1483,7 +1488,7 @@ const MaplibreViewer = ({
void interpTick;
const entity = findSelectedEntity(selectedEntity, data);
if (!entity || selectedTrailPoints.length < 2) return null;
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightHasKnownRoute(entity, dynamicRoute)) {
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightPayloadHasKnownRoute(entity)) {
return null;
}
+105 -35
View File
@@ -113,10 +113,13 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
meshQuickStatus,
meshSessionActive,
publicMeshAddress,
activePublicMeshAddress,
meshView,
setMeshView,
meshDirectTarget,
setMeshDirectTarget,
meshAddressDraft,
setMeshAddressDraft,
meshMqttSettings,
meshMqttForm,
setMeshMqttForm,
@@ -133,6 +136,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
publicIdentity,
hasStoredPublicLaneIdentity,
hasPublicLaneIdentity,
canUsePublicMeshInput,
hasId,
shouldShowIdentityWarning,
wormholeEnabled,
@@ -339,14 +343,13 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
void handleRequestAccess(targetId);
};
const meshActivationText =
meshQuickStatus?.text ||
(publicMeshBlockedByWormhole
publicMeshBlockedByWormhole
? hasStoredPublicLaneIdentity
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
: hasStoredPublicLaneIdentity
? 'MeshChat is off. Turn it on to use your saved public mesh key.'
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.');
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.';
const handleMeshActivationAction = () => {
if (hasStoredPublicLaneIdentity) {
void handleActivatePublicMeshSession();
@@ -358,6 +361,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
}
void handleQuickCreatePublicIdentity();
};
const normalizeMeshDirectAddress = (value: string) => {
const compact = value.trim().replace(/^!/, '').toLowerCase();
return /^[0-9a-f]{8}$/.test(compact) ? `!${compact}` : '';
};
const handleMeshDirectTargetSubmit = () => {
const target = normalizeMeshDirectAddress(meshAddressDraft);
if (!target) {
setSendError('enter node address like !1ee21986');
window.setTimeout(() => setSendError(''), 4000);
return;
}
setMeshDirectTarget(target);
setMeshView('channel');
window.setTimeout(() => inputRef.current?.focus(), 0);
};
const meshActivationLabel = identityWizardBusy
? 'GETTING MESH KEY'
: hasStoredPublicLaneIdentity
@@ -482,7 +500,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</div>
)}
{anonymousModeEnabled && !anonymousModeReady && (
{activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked
until Wormhole is running over Tor, I2P, or Mixnet.
@@ -1144,8 +1162,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
))}
</select>
</div>
<div className="flex items-center justify-between gap-2 px-3 py-1 border-b border-[var(--border-primary)]/20 shrink-0 bg-green-950/10">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 px-3 py-1 border-b border-[var(--border-primary)]/20 shrink-0 bg-green-950/10">
<div className="flex items-center gap-1 min-w-0 flex-wrap">
<button
onClick={() => setMeshView('channel')}
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
@@ -1176,24 +1194,71 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
>
SETTINGS
</button>
</div>
<div
className={`text-[10px] font-mono truncate ${
meshMqttConnected
? 'text-green-300/80'
: meshMqttEnabled
? 'text-amber-300/80'
: 'text-[var(--text-muted)]'
}`}
>
{meshSessionActive && publicMeshAddress
? `${meshMqttConnectionLabel} / ADDR ${publicMeshAddress.toUpperCase()}`
: publicMeshAddress
? `${meshMqttConnectionLabel} / KEY SAVED`
: `${meshMqttConnectionLabel} / NO ADDRESS`}
<button
onClick={() => {
setMeshAddressDraft(meshDirectTarget || '');
setMeshView('message');
}}
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
meshView === 'message'
? 'border-green-500/40 text-green-200 bg-green-950/25'
: 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-green-300'
}`}
>
MESSAGE
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
{meshView === 'message' && (
<div className="space-y-2 py-1 text-[11px] font-mono">
<div className="border border-green-700/35 bg-green-950/10 p-2">
<div className="text-green-300 tracking-[0.18em]">DIRECT MESHTASTIC MESSAGE</div>
<div className="mt-1 text-[10px] text-[var(--text-muted)] leading-[1.5]">
Enter a public Meshtastic node address. Direct MQTT publishes are public/degraded and depend on the target mesh hearing the broker bridge.
</div>
</div>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">NODE ADDRESS</span>
<input
value={meshAddressDraft}
onChange={(e) => setMeshAddressDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleMeshDirectTargetSubmit();
}
}}
placeholder="!1ee21986"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-green-200 outline-none placeholder:text-[var(--text-muted)] focus:border-green-500/50"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleMeshDirectTargetSubmit}
className="border border-green-600/45 bg-green-950/20 px-2 py-1.5 text-green-300 hover:bg-green-950/35"
>
USE ADDRESS
</button>
<button
onClick={() => {
setMeshDirectTarget('');
setMeshAddressDraft('');
setMeshView('channel');
window.setTimeout(() => inputRef.current?.focus(), 0);
}}
className="border border-cyan-700/40 bg-cyan-950/15 px-2 py-1.5 text-cyan-300 hover:bg-cyan-950/25"
>
BROADCAST
</button>
</div>
{meshDirectTarget && (
<div className="border border-amber-600/30 bg-amber-950/10 p-2 text-amber-200/85 leading-[1.5]">
Active direct target: {meshDirectTarget.toUpperCase()}. Type in the input below and press send, or clear it to return to channel broadcast.
</div>
)}
</div>
)}
{meshView === 'settings' && (
<div className="space-y-2 py-1 text-[11px] font-mono">
<div className="border border-cyan-800/35 bg-cyan-950/10 p-2">
@@ -1338,26 +1403,26 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)}
</div>
)}
{!meshSessionActive && meshView !== 'settings' && (
{!canUsePublicMeshInput && meshView !== 'settings' && (
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
MeshChat is off. Turn it on to connect the public mesh lane.
</div>
)}
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && (
{canUsePublicMeshInput && meshView === 'channel' && filteredMeshMessages.length === 0 && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No messages from {meshRegion} / {meshChannel}
</div>
)}
{meshSessionActive && meshView === 'inbox' && (
{canUsePublicMeshInput && meshView === 'inbox' && (
<>
{!publicMeshAddress && (
{!activePublicMeshAddress && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
Create or load a public mesh identity to see direct Meshtastic traffic.
</div>
)}
{publicMeshAddress && meshInboxMessages.length === 0 && (
{activePublicMeshAddress && meshInboxMessages.length === 0 && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No public direct messages addressed to {publicMeshAddress.toUpperCase()} yet.
No public direct messages addressed to {activePublicMeshAddress.toUpperCase()} yet.
</div>
)}
{meshInboxMessages.map((m, i) => (
@@ -1371,7 +1436,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</button>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-amber-200/70 mb-0.5">
TO {publicMeshAddress.toUpperCase()}
TO {activePublicMeshAddress.toUpperCase()}
</div>
<div className="break-words whitespace-pre-wrap text-amber-100/90">
{m.text}
@@ -2264,10 +2329,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
? `→ INFONET${selectedGate ? ` / ${selectedGate}` : ''}${privateInfonetTransportReady ? '' : ' / EXPERIMENTAL ENCRYPTION'}`
: '→ PRIVATE LANE LOCKED'
: activeTab === 'meshtastic'
? hasPublicLaneIdentity
? canUsePublicMeshInput
? meshDirectTarget
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
: `→ MESH / ${meshRegion} / ${meshChannel}`
? `→ MESH / TO ${meshDirectTarget.toUpperCase()} / FROM ${activePublicMeshAddress.toUpperCase()}`
: `→ MESH / ${meshRegion} / ${meshChannel} / ${activePublicMeshAddress.toUpperCase()}`
: publicMeshBlockedByWormhole
? '→ MESH BLOCKED / WORMHOLE ACTIVE'
: hasStoredPublicLaneIdentity
? '→ MESH OFF'
: '→ MESH LOCKED'
@@ -2279,7 +2346,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</span>
)}
</div>
{activeTab === 'meshtastic' && !hasPublicLaneIdentity && !sendError && (
{activeTab === 'meshtastic' && !sendError && (!canUsePublicMeshInput || meshQuickStatus) && (
<div
className={`px-3 pt-1 text-[12px] font-mono leading-[1.5] ${
meshQuickStatus?.type === 'err'
@@ -2289,7 +2356,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
: 'text-green-300/70'
}`}
>
{meshActivationText}
{meshQuickStatus?.text || meshActivationText}
</div>
)}
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
@@ -2319,7 +2386,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
NEED WORMHOLE
</span>
</button>
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
) : activeTab === 'meshtastic' && !canUsePublicMeshInput ? (
<button
onClick={handleMeshActivationAction}
disabled={identityWizardBusy}
@@ -2335,7 +2402,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</button>
) : activeTab === 'meshtastic' && meshDirectTarget ? (
<button
onClick={() => setMeshDirectTarget('')}
onClick={() => {
setMeshDirectTarget('');
setMeshAddressDraft('');
}}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-amber-700/40 bg-amber-950/10 text-amber-200 hover:bg-amber-950/20 hover:border-amber-500/50 transition-colors"
>
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
@@ -13,7 +13,7 @@ import {
extractNativeGateResyncTarget,
} from '@/lib/desktopControlContract';
import type { DesktopControlAuditReport } from '@/lib/desktopControlContract';
import { fetchPrivacyProfileSnapshot } from '@/mesh/controlPlaneStatusClient';
import { fetchPrivacyProfileSnapshot, setInfonetNodeEnabled } from '@/mesh/controlPlaneStatusClient';
import {
getNodeIdentity,
getStoredNodeDescriptor,
@@ -397,8 +397,9 @@ export function useMeshChatController({
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [meshSessionActive, setMeshSessionActive] = useState(false);
const [publicMeshAddress, setPublicMeshAddress] = useState('');
const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings'>('channel');
const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings' | 'message'>('channel');
const [meshDirectTarget, setMeshDirectTarget] = useState('');
const [meshAddressDraft, setMeshAddressDraft] = useState('');
const [meshMqttSettings, setMeshMqttSettings] = useState<MeshMqttSettings | null>(null);
const [meshMqttForm, setMeshMqttForm] = useState<MeshMqttForm>({
broker: 'mqtt.meshtastic.org',
@@ -427,14 +428,17 @@ export function useMeshChatController({
const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : '';
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress);
const publicIdentity = null;
const hasPublicLaneIdentity = meshSessionActive && Boolean(publicMeshAddress);
const activePublicMeshAddress = publicMeshAddress || storedPublicMeshAddress;
const hasPublicLaneIdentity = meshSessionActive && Boolean(activePublicMeshAddress);
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
const infonetAutoBootstrapRef = useRef(false);
const meshMqttRuntime = meshMqttSettings?.runtime;
const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
const canUsePublicMeshInput = Boolean(activePublicMeshAddress) && meshMqttEnabled && !publicMeshBlockedByWormhole;
const meshMqttRunning = Boolean(meshMqttRuntime?.running);
const meshMqttConnected = Boolean(meshMqttRuntime?.connected);
const meshMqttConnectionLabel = !meshMqttEnabled
@@ -546,16 +550,12 @@ export function useMeshChatController({
const displayPublicMeshSender = useCallback(
(sender: string) => {
if (!sender) return '???';
if (
hasPublicLaneIdentity &&
publicMeshAddress &&
sender.toLowerCase() === publicMeshAddress.toLowerCase()
) {
return publicMeshAddress.toUpperCase();
if (activePublicMeshAddress && sender.toLowerCase() === activePublicMeshAddress.toLowerCase()) {
return activePublicMeshAddress.toUpperCase();
}
return sender;
},
[hasPublicLaneIdentity, publicMeshAddress],
[activePublicMeshAddress],
);
const openIdentityWizard = useCallback(
@@ -1221,6 +1221,7 @@ export function useMeshChatController({
const inputRef = useRef<HTMLTextAreaElement>(null);
const cursorMirrorRef = useRef<HTMLDivElement>(null);
const cursorMarkerRef = useRef<HTMLSpanElement>(null);
const publicMeshPrivacyEnforcedRef = useRef(false);
useEffect(() => {
const el = messagesEndRef.current;
@@ -1329,15 +1330,21 @@ export function useMeshChatController({
() => infoMessages.filter((m) => !m.node_id || !mutedUsers.has(m.node_id)),
[infoMessages, mutedUsers],
);
const isBroadcastMeshMessage = useCallback((m: MeshtasticMessage) => {
const target = String(m.to || 'broadcast').trim().toLowerCase();
return target === '' || target === 'broadcast' || target === '^all';
}, []);
const filteredMeshMessages = useMemo(
() => meshMessages.filter((m) => !mutedUsers.has(m.from)),
[meshMessages, mutedUsers],
() => meshMessages.filter((m) => isBroadcastMeshMessage(m) && !mutedUsers.has(m.from)),
[isBroadcastMeshMessage, meshMessages, mutedUsers],
);
const meshInboxMessages = useMemo(() => {
if (!meshSessionActive || !publicMeshAddress) return [];
const target = publicMeshAddress.toLowerCase();
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
if (!activePublicMeshAddress) return [];
const target = activePublicMeshAddress.toLowerCase();
return meshMessages.filter(
(m) => !mutedUsers.has(m.from) && String(m.to || '').toLowerCase() === target,
);
}, [activePublicMeshAddress, meshMessages, mutedUsers]);
useEffect(() => {
if (!expanded || activeTab !== 'meshtastic') return;
@@ -1961,7 +1968,7 @@ export function useMeshChatController({
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
useEffect(() => {
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
if (!expanded || activeTab !== 'meshtastic' || !canUsePublicMeshInput) return;
let cancelled = false;
const fetchChannels = async () => {
try {
@@ -2020,12 +2027,12 @@ export function useMeshChatController({
cancelled = true;
clearInterval(iv);
};
}, [expanded, activeTab, meshRegion, meshSessionActive]);
}, [expanded, activeTab, meshRegion, canUsePublicMeshInput]);
// ─── Meshtastic Polling ──────────────────────────────────────────────────
useEffect(() => {
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
if (!expanded || activeTab !== 'meshtastic' || !canUsePublicMeshInput) return;
let cancelled = false;
const poll = async () => {
try {
@@ -2034,6 +2041,7 @@ export function useMeshChatController({
region: meshRegion,
channel: meshChannel,
});
if (meshView === 'inbox') params.set('include_direct', '1');
const res = await fetch(`${API_BASE}/api/mesh/messages?${params}`);
if (res.ok && !cancelled) {
const data = await res.json();
@@ -2049,13 +2057,13 @@ export function useMeshChatController({
cancelled = true;
clearInterval(iv);
};
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
}, [expanded, activeTab, meshRegion, meshChannel, meshView, canUsePublicMeshInput]);
useEffect(() => {
if (meshSessionActive) return;
if (canUsePublicMeshInput) return;
setMeshMessages([]);
setMeshQuickStatus(null);
}, [meshSessionActive]);
}, [canUsePublicMeshInput]);
// ─── DM Polling ──────────────────────────────────────────────────────────
@@ -2540,7 +2548,7 @@ export function useMeshChatController({
if (!msg || busy) return;
if (activeTab !== 'meshtastic' && !hasId) return;
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
const cooldownMs = activeTab === 'dms' ? 0 : activeTab === 'meshtastic' ? 6_000 : 30_000;
const now = Date.now();
const elapsed = now - lastSendTime;
if (cooldownMs > 0 && elapsed < cooldownMs) {
@@ -2550,8 +2558,8 @@ export function useMeshChatController({
return;
}
if (anonymousPublicBlocked && (activeTab === 'infonet' || activeTab === 'meshtastic')) {
setSendError('hidden transport required for public posting');
if (anonymousPublicBlocked && activeTab === 'infonet') {
setSendError('hidden transport required for infonet posting');
setTimeout(() => setSendError(''), 4000);
return;
}
@@ -2625,10 +2633,11 @@ export function useMeshChatController({
]);
setGateReplyContext(null);
} else if (activeTab === 'meshtastic') {
if (!meshSessionActive || !publicMeshAddress) {
const meshSenderAddress = activePublicMeshAddress;
if (!meshSenderAddress) {
setInputValue(msg);
setLastSendTime(0);
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
setSendError('public mesh identity needed');
openIdentityWizard({
type: 'err',
text: hasStoredPublicLaneIdentity
@@ -2639,6 +2648,10 @@ export function useMeshChatController({
setBusy(false);
return;
}
if (!meshSessionActive) {
setPublicMeshAddress(meshSenderAddress);
setMeshSessionActive(true);
}
if (!meshMqttEnabled) {
setInputValue(msg);
setLastSendTime(0);
@@ -2680,7 +2693,7 @@ export function useMeshChatController({
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
sender_id: publicMeshAddress,
sender_id: meshSenderAddress,
mesh_region: meshRegion,
}),
});
@@ -2700,12 +2713,28 @@ export function useMeshChatController({
return;
}
// Re-fetch — backend injects our msg into the bridge feed after publish
const directTarget = meshDestination !== 'broadcast'
? meshDestination.startsWith('!')
? meshDestination.toUpperCase()
: `!${meshDestination}`.toUpperCase()
: '';
const routeDetail = Array.isArray(sendData.results) && sendData.results[0]?.reason
? String(sendData.results[0].reason)
: String(sendData.route_reason || 'MQTT broker accepted publish');
setMeshQuickStatus({
type: 'ok',
text: directTarget
? `Direct message queued for ${directTarget}. ${routeDetail}`
: `Channel message published to ${meshRegion}/${meshChannel}. ${routeDetail}`,
});
window.setTimeout(() => setMeshQuickStatus(null), 6000);
await new Promise((r) => setTimeout(r, 500));
const params = new URLSearchParams({
limit: '30',
region: meshRegion,
channel: meshChannel,
});
if (directTarget) params.set('include_direct', '1');
const mRes = await fetch(`${API_BASE}/api/mesh/messages?${params}`);
if (mRes.ok) {
const data = await mRes.json();
@@ -4138,7 +4167,7 @@ export function useMeshChatController({
privateInfonetTransportReady,
});
const inputDisabled =
!hasId ||
(activeTab !== 'meshtastic' && !hasId) ||
busy ||
(activeTab === 'infonet' && !privateInfonetReady) ||
(activeTab === 'infonet' && !selectedGate) ||
@@ -4148,7 +4177,7 @@ export function useMeshChatController({
wormholeReadyState &&
!selectedGateAccessReady) ||
(activeTab === 'infonet' && anonymousPublicBlocked) ||
(activeTab === 'meshtastic' && (!hasPublicLaneIdentity || !meshMqttEnabled)) ||
(activeTab === 'meshtastic' && !canUsePublicMeshInput) ||
(activeTab === 'dms' &&
(dmView !== 'chat' ||
!selectedContact ||
@@ -4192,6 +4221,10 @@ export function useMeshChatController({
[inputDisabled],
);
const disablePrivateNodeForPublicMesh = useCallback(async () => {
await setInfonetNodeEnabled(false);
}, []);
const disableWormholeForPublicMesh = useCallback(async () => {
const requireBackendLeave = wormholeEnabled || wormholeReadyState;
try {
@@ -4207,7 +4240,28 @@ export function useMeshChatController({
setWormholeRnsDirectReady(false);
setWormholeRnsPeers({ active: 0, configured: 0 });
setSecureModeCached(false);
}, [wormholeEnabled, wormholeReadyState]);
await disablePrivateNodeForPublicMesh();
}, [disablePrivateNodeForPublicMesh, wormholeEnabled, wormholeReadyState]);
useEffect(() => {
if (!meshSessionActive || !activePublicMeshAddress || !meshMqttEnabled) {
publicMeshPrivacyEnforcedRef.current = false;
return;
}
if (publicMeshPrivacyEnforcedRef.current) return;
publicMeshPrivacyEnforcedRef.current = true;
void disableWormholeForPublicMesh().catch((err) => {
publicMeshPrivacyEnforcedRef.current = false;
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: 'unknown error';
setMeshQuickStatus({
type: 'err',
text: `Could not isolate public Mesh lane: ${message}`,
});
});
}, [activePublicMeshAddress, disableWormholeForPublicMesh, meshMqttEnabled, meshSessionActive]);
const createPublicMeshIdentity = useCallback(
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
@@ -4286,9 +4340,9 @@ export function useMeshChatController({
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
const text = `MeshChat is on with saved address ${readyAddress}.`;
const text = `MeshChat is on. Address ${readyAddress}.`;
setIdentityWizardStatus({ type: 'ok', text });
setMeshQuickStatus({ type: 'ok', text });
setMeshQuickStatus(null);
return { ok: true as const, text };
} catch (err) {
const message =
@@ -4308,7 +4362,8 @@ export function useMeshChatController({
const target = String(address || '').trim();
if (!target) return;
setMeshDirectTarget(target);
setMeshView('inbox');
setMeshAddressDraft(target);
setMeshView('channel');
setSenderPopup(null);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
@@ -4319,7 +4374,7 @@ export function useMeshChatController({
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
setIdentityWizardStatus(status);
setMeshQuickStatus(status);
setMeshQuickStatus(result.ok ? null : status);
if (result.ok) {
window.setTimeout(() => setIdentityWizardOpen(false), 900);
}
@@ -4424,6 +4479,23 @@ export function useMeshChatController({
setIdentityWizardBusy(false);
}
}, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]);
useEffect(() => {
if (!expanded || activeTab !== 'infonet') {
infonetAutoBootstrapRef.current = false;
return;
}
if (privateInfonetReady) {
infonetAutoBootstrapRef.current = false;
return;
}
if (identityWizardBusy || infonetAutoBootstrapRef.current) return;
infonetAutoBootstrapRef.current = true;
void handleBootstrapPrivateIdentity().catch(() => {
infonetAutoBootstrapRef.current = false;
});
}, [activeTab, expanded, handleBootstrapPrivateIdentity, identityWizardBusy, privateInfonetReady]);
return {
// UI state
expanded,
@@ -4447,10 +4519,13 @@ export function useMeshChatController({
meshQuickStatus,
meshSessionActive,
publicMeshAddress,
activePublicMeshAddress,
meshView,
setMeshView,
meshDirectTarget,
setMeshDirectTarget,
meshAddressDraft,
setMeshAddressDraft,
meshMqttSettings,
meshMqttForm,
setMeshMqttForm,
@@ -4467,6 +4542,7 @@ export function useMeshChatController({
publicIdentity,
hasStoredPublicLaneIdentity,
hasPublicLaneIdentity,
canUsePublicMeshInput,
hasId,
shouldShowIdentityWarning,
wormholeEnabled,
+5 -127
View File
@@ -245,147 +245,26 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
type FlightTrailPoint = { lat?: number; lng?: number; alt?: number; ts?: number } | number[];
function readTrailTimestamp(point: FlightTrailPoint): number | null {
if (Array.isArray(point)) {
const ts = Number(point[3]);
return Number.isFinite(ts) && ts > 0 ? ts : null;
}
const ts = Number(point?.ts);
return Number.isFinite(ts) && ts > 0 ? ts : null;
}
function readTrailLatLng(point: FlightTrailPoint): { lat: number; lng: number } | null {
const lat = Number(Array.isArray(point) ? point[0] : point?.lat);
const lng = Number(Array.isArray(point) ? point[1] : point?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng };
}
function distanceNm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const earthRadiusNm = 3440.065;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * earthRadiusNm * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function formatObservedDuration(hours: number): string {
const minutes = Math.max(1, Math.round(hours * 60));
if (minutes < 60) return `${minutes} min`;
const wholeHours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return remainder ? `${wholeHours}h ${remainder}m` : `${wholeHours}h`;
}
function estimateObservedEmissions(flight: any): {
fuelGallons: number;
co2Kg: number;
durationLabel: string;
distanceLabel: string | null;
basisLabel: string;
} | null {
const fuelGph = Number(flight?.emissions?.fuel_gph);
const co2KgPerHour = Number(flight?.emissions?.co2_kg_per_hour);
const trail = Array.isArray(flight?.trail) ? (flight.trail as FlightTrailPoint[]) : [];
const isTrackedAircraft = flight?.type === 'tracked_flight' || Boolean(flight?.alert_category);
const minimumObservedHours = isTrackedAircraft ? 1 / 60 : 5 / 60;
if (!Number.isFinite(fuelGph) || !Number.isFinite(co2KgPerHour)) {
return null;
}
const timestamps = trail
.map(readTrailTimestamp)
.filter((ts): ts is number => ts !== null)
.sort((a, b) => a - b);
if (timestamps.length >= 2) {
const elapsedHours = (timestamps[timestamps.length - 1] - timestamps[0]) / 3600;
if (Number.isFinite(elapsedHours) && elapsedHours >= minimumObservedHours) {
let distance = 0;
let previous: { lat: number; lng: number } | null = null;
for (const point of trail) {
const current = readTrailLatLng(point);
if (previous && current) distance += distanceNm(previous, current);
if (current) previous = current;
}
return {
fuelGallons: Math.round(fuelGph * elapsedHours),
co2Kg: Math.round(co2KgPerHour * elapsedHours),
durationLabel: formatObservedDuration(elapsedHours),
distanceLabel: distance > 1 ? `${Math.round(distance).toLocaleString()} nm` : null,
basisLabel: 'trail history',
};
}
}
const origin = Array.isArray(flight?.origin_loc)
? { lng: Number(flight.origin_loc[0]), lat: Number(flight.origin_loc[1]) }
: null;
const current = { lat: Number(flight?.lat), lng: Number(flight?.lng) };
const speedKnots = Number(flight?.speed_knots);
if (
origin &&
Number.isFinite(origin.lat) &&
Number.isFinite(origin.lng) &&
Number.isFinite(current.lat) &&
Number.isFinite(current.lng) &&
Number.isFinite(speedKnots) &&
speedKnots > 50
) {
const flownNm = distanceNm(origin, current);
const elapsedHours = flownNm / speedKnots;
if (Number.isFinite(elapsedHours) && elapsedHours >= minimumObservedHours && elapsedHours <= 18) {
return {
fuelGallons: Math.round(fuelGph * elapsedHours),
co2Kg: Math.round(co2KgPerHour * elapsedHours),
durationLabel: formatObservedDuration(elapsedHours),
distanceLabel: `${Math.round(flownNm).toLocaleString()} nm`,
basisLabel: 'route progress',
};
}
}
return null;
}
function EmissionsEstimateBlock({ flight }: { flight: any }) {
const observed = estimateObservedEmissions(flight);
const emissions = flight?.emissions;
const context = observed
? `${observed.durationLabel} ${observed.basisLabel}${observed.distanceLabel ? ` / ${observed.distanceLabel}` : ''}`
: emissions
? 'Rate only until enough trail history accumulates'
: null;
const context = emissions ? 'Model-based cruise estimate' : null;
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
{observed ? 'FUEL BURNED' : 'FUEL RATE'}
</div>
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL RATE</div>
<div className="text-xs font-bold text-orange-400">
{observed ? (
<>{observed.fuelGallons.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">GAL</span></>
) : emissions ? (
{emissions ? (
<>{emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></>
) : 'UNKNOWN'}
</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
{observed ? 'CO2 PRODUCED' : 'CO2 RATE'}
</div>
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 RATE</div>
<div className="text-xs font-bold text-red-400">
{observed ? (
<>{observed.co2Kg.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG</span></>
) : emissions ? (
{emissions ? (
<>{emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></>
) : 'UNKNOWN'}
</div>
@@ -394,7 +273,6 @@ function EmissionsEstimateBlock({ flight }: { flight: any }) {
{context && (
<div className="mt-1.5 text-[10px] text-[var(--text-muted)] leading-relaxed">
{context}
{observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''}
</div>
)}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react';
const CURRENT_ONBOARDING_VERSION = '0.9.75-agentic-onboarding-1';
const CURRENT_ONBOARDING_VERSION = '0.9.79-agentic-onboarding-1';
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Database, Clock, X } from 'lucide-react';
const CURRENT_VERSION = '0.9.75';
const CURRENT_VERSION = '0.9.79';
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
interface StartupWarmupModalProps {
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import {
derivePublicMeshAddress,
getNodeIdentity,
hasSovereignty,
signEvent,
@@ -13,11 +12,28 @@ import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
import { validateEventPayload } from '@/mesh/meshSchema';
const MESH_NODE_ID_RE = /^![0-9a-f]{8}$/i;
const PUBLIC_MESH_ADDRESS_KEY = 'sb_public_meshtastic_address';
function isMeshtasticNodeId(value: string | undefined | null): boolean {
return !!value && MESH_NODE_ID_RE.test(value.trim());
}
function normalizePublicMeshAddress(value: string | undefined | null): string {
const raw = String(value || '').trim().toLowerCase();
const body = raw.startsWith('!') ? raw.slice(1) : raw;
if (!/^[0-9a-f]{8}$/.test(body)) return '';
return `!${body}`;
}
function readStoredPublicMeshAddress(): string {
if (typeof window === 'undefined') return '';
try {
return normalizePublicMeshAddress(window.localStorage.getItem(PUBLIC_MESH_ADDRESS_KEY));
} catch {
return '';
}
}
/** Inline send-message form for SIGINT popups — routes via MeshRouter */
export function SigintSendForm({
destination,
@@ -40,26 +56,11 @@ export function SigintSendForm({
const isDirectMesh = isMesh && isMeshtasticNodeId(destination);
useEffect(() => {
let cancelled = false;
if (!isMesh) {
setPublicMeshAddress('');
return;
}
const identity = getNodeIdentity();
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(identity.nodeId)
.then((addr) => {
if (!cancelled) setPublicMeshAddress(addr);
})
.catch(() => {
if (!cancelled) setPublicMeshAddress('');
});
return () => {
cancelled = true;
};
setPublicMeshAddress(readStoredPublicMeshAddress());
}, [isMesh]);
const handleSend = async () => {
@@ -71,6 +72,56 @@ export function SigintSendForm({
}
setStatus('sending');
try {
if (isMesh) {
const meshSender = normalizePublicMeshAddress(publicMeshAddress || readStoredPublicMeshAddress());
if (!meshSender) {
setStatus('error');
setDetail('public mesh key required');
return;
}
const payload = {
message: msg.trim(),
destination: destination || 'broadcast',
channel: channel || 'LongFast',
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
};
const v = validateEventPayload('message', payload);
if (!v.ok) {
setStatus('error');
setDetail(`invalid payload: ${v.reason}`);
return;
}
const res = await fetch(`${API_BASE}/api/mesh/meshtastic/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
destination: destination || 'broadcast',
message: msg.trim(),
sender_id: meshSender,
channel: channel || 'LongFast',
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
mesh_region: region || 'US',
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.ok) {
setStatus('sent');
const routeDetail = Array.isArray(data.results) && data.results[0]?.reason
? String(data.results[0].reason)
: String(data.route_reason || 'MQTT broker accepted publish');
setDetail(routeDetail);
setMsg('');
} else {
setStatus('error');
setDetail(String(data.detail || data.route_reason || 'send failed'));
}
return;
}
const identity = getNodeIdentity();
if (!identity || !hasSovereignty()) {
setStatus('error');
@@ -234,22 +285,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
const identity = getNodeIdentity();
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(identity.nodeId)
.then((addr) => {
if (!cancelled) setPublicMeshAddress(addr);
})
.catch(() => {
if (!cancelled) setPublicMeshAddress('');
});
return () => {
cancelled = true;
};
setPublicMeshAddress(readStoredPublicMeshAddress());
}, []);
const fetchData = useCallback(async () => {
@@ -281,6 +317,10 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
const regionData = channelStats?.roots?.[region] || channelStats?.regions?.[region];
const regionChannels = regionData?.channels || {};
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
const channelMessages = messages.filter((m) => {
const target = String(m.to || 'broadcast').trim().toLowerCase();
return target === '' || target === 'broadcast' || target === '^all';
});
if (loading)
return <div className="text-[11px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
@@ -317,13 +357,13 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
)}
{/* Message feed */}
{messages.length > 0 ? (
{channelMessages.length > 0 ? (
<>
<div className="text-[11px] text-green-400/60 tracking-widest mb-1">
MESSAGES {channel} ({region})
</div>
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
{messages.map((m: MeshtasticMessage, i: number) => {
{channelMessages.map((m: MeshtasticMessage, i: number) => {
const directedToYou =
!!publicMeshAddress &&
typeof m.to === 'string' &&