mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-09 21:58:41 +02:00
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:
@@ -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'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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user