diff --git a/.env.example b/.env.example index d4c78ea..4f43126 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,12 @@ ADMIN_KEY= # SHADOWBROKER_SLOW_FETCH_CONCURRENCY=4 # SHADOWBROKER_STARTUP_HEAVY_CONCURRENCY=2 +# Infonet bootstrap/sync responsiveness. Defaults favor fast seed failure +# detection so stale onion peers do not make the terminal look hung. +# MESH_SYNC_TIMEOUT_S=5 +# MESH_SYNC_MAX_PEERS_PER_CYCLE=3 +# MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S=15 + # Google Earth Engine for VIIRS night lights change detection (optional). # pip install earthengine-api # GEE_SERVICE_ACCOUNT_KEY= diff --git a/backend/main.py b/backend/main.py index a60a341..fe996ac 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1386,7 +1386,12 @@ def _peer_sync_response(peer_url: str, body: dict[str, Any]) -> dict[str, Any]: if _infonet_private_transport_required() and not _is_private_infonet_transport(transport): raise RuntimeError(_infonet_private_transport_error()) - timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10) + settings = get_settings() + timeout = int( + getattr(settings, "MESH_SYNC_TIMEOUT_S", 0) + or getattr(settings, "MESH_RELAY_PUSH_TIMEOUT_S", 0) + or 10 + ) kwargs: dict[str, Any] = { "json": body, "timeout": timeout, @@ -1509,6 +1514,8 @@ def _run_public_sync_cycle() -> SyncWorkerState: records = _filter_infonet_sync_records(store.records()) peers = eligible_sync_peers(records, now=time.time()) + max_peers = max(1, int(getattr(get_settings(), "MESH_SYNC_MAX_PEERS_PER_CYCLE", 0) or 3)) + peers = peers[:max_peers] with _NODE_RUNTIME_LOCK: current_state = get_sync_state() if not peers: @@ -1571,14 +1578,25 @@ def _run_public_sync_cycle() -> SyncWorkerState: return updated last_error = error + settings = get_settings() + is_seed_peer = str(getattr(record, "role", "") or "").strip().lower() == "seed" + cooldown_s = int(getattr(settings, "MESH_RELAY_FAILURE_COOLDOWN_S", 120) or 120) + if is_seed_peer: + cooldown_s = int( + getattr(settings, "MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S", cooldown_s) + or cooldown_s + ) store.mark_failure( record.peer_url, "sync", error=error, - cooldown_s=int(get_settings().MESH_RELAY_FAILURE_COOLDOWN_S or 120), + cooldown_s=cooldown_s, now=time.time(), ) store.save() + failure_backoff_s = int(settings.MESH_SYNC_FAILURE_BACKOFF_S or 60) + if is_seed_peer: + failure_backoff_s = min(failure_backoff_s, max(1, cooldown_s)) updated = finish_sync( started, ok=False, @@ -1588,7 +1606,7 @@ def _run_public_sync_cycle() -> SyncWorkerState: fork_detected=forked, now=time.time(), interval_s=int(get_settings().MESH_SYNC_INTERVAL_S or 300), - failure_backoff_s=int(get_settings().MESH_SYNC_FAILURE_BACKOFF_S or 60), + failure_backoff_s=failure_backoff_s, ) with _NODE_RUNTIME_LOCK: set_sync_state(updated) @@ -9860,7 +9878,7 @@ async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str): return revoke_prekey_lookup_handle(handle) -@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)]) +@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest): return import_wormhole_dm_invite( diff --git a/backend/routers/wormhole.py b/backend/routers/wormhole.py index 0ccd5fe..e9ccfc0 100644 --- a/backend/routers/wormhole.py +++ b/backend/routers/wormhole.py @@ -735,7 +735,7 @@ async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str): return revoke_prekey_lookup_handle(handle) -@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)]) +@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest): return import_wormhole_dm_invite( diff --git a/backend/services/config.py b/backend/services/config.py index 7ef4848..2cb31da 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -46,9 +46,12 @@ class Settings(BaseSettings): MESH_NODE_MODE: str = "participant" MESH_SYNC_INTERVAL_S: int = 300 MESH_SYNC_FAILURE_BACKOFF_S: int = 60 + MESH_SYNC_TIMEOUT_S: int = 5 + MESH_SYNC_MAX_PEERS_PER_CYCLE: int = 3 MESH_RELAY_PUSH_TIMEOUT_S: int = 10 MESH_RELAY_MAX_FAILURES: int = 3 MESH_RELAY_FAILURE_COOLDOWN_S: int = 120 + MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S: int = 15 MESH_PEER_PUSH_SECRET: str = "" MESH_RNS_APP_NAME: str = "shadowbroker" MESH_RNS_ASPECT: str = "infonet" diff --git a/backend/services/mesh/mesh_infonet_sync_support.py b/backend/services/mesh/mesh_infonet_sync_support.py index 6c00b9c..ecb75d8 100644 --- a/backend/services/mesh/mesh_infonet_sync_support.py +++ b/backend/services/mesh/mesh_infonet_sync_support.py @@ -30,10 +30,19 @@ def eligible_sync_peers(records: list[PeerRecord], *, now: float | None = None) for record in records if record.bucket == "sync" and record.enabled and int(record.cooldown_until or 0) <= current_time ] + + def _seed_priority(record: PeerRecord) -> int: + role = str(record.role or "").strip().lower() + source = str(record.source or "").strip().lower() + if role == "seed" and source in {"bundle", "bootstrap_promoted"}: + return 0 + return 1 + return sorted( candidates, key=lambda record: ( -int(record.last_sync_ok_at or 0), + _seed_priority(record), int(record.failure_count or 0), int(record.added_at or 0), record.peer_url, diff --git a/backend/services/mesh/mesh_peer_store.py b/backend/services/mesh/mesh_peer_store.py index 39b0ec7..84b402f 100644 --- a/backend/services/mesh/mesh_peer_store.py +++ b/backend/services/mesh/mesh_peer_store.py @@ -258,6 +258,12 @@ class PeerStore: self._records[record.record_key()] = record return record + explicit_seed_refresh = ( + record.bucket == "sync" + and record.role == "seed" + and record.source in {"bundle", "bootstrap_promoted"} + ) + merged = PeerRecord( bucket=record.bucket, source=record.source, @@ -272,9 +278,9 @@ class PeerStore: last_seen_at=max(existing.last_seen_at, record.last_seen_at), last_sync_ok_at=max(existing.last_sync_ok_at, record.last_sync_ok_at), last_push_ok_at=max(existing.last_push_ok_at, record.last_push_ok_at), - last_error=record.last_error or existing.last_error, - failure_count=max(existing.failure_count, record.failure_count), - cooldown_until=max(existing.cooldown_until, record.cooldown_until), + last_error="" if explicit_seed_refresh else record.last_error or existing.last_error, + failure_count=0 if explicit_seed_refresh else max(existing.failure_count, record.failure_count), + cooldown_until=0 if explicit_seed_refresh else max(existing.cooldown_until, record.cooldown_until), metadata={**existing.metadata, **record.metadata}, ) self._records[record.record_key()] = merged diff --git a/backend/tests/mesh/test_mesh_infonet_sync_support.py b/backend/tests/mesh/test_mesh_infonet_sync_support.py index a715f12..bbaddee 100644 --- a/backend/tests/mesh/test_mesh_infonet_sync_support.py +++ b/backend/tests/mesh/test_mesh_infonet_sync_support.py @@ -37,6 +37,30 @@ def test_eligible_sync_peers_filters_bucket_and_cooldown(): assert [record.peer_url for record in candidates] == ["https://active.example"] +def test_eligible_sync_peers_prioritizes_explicit_bootstrap_seed(): + old_runtime = make_sync_peer_record( + peer_url="https://old-runtime.example", + transport="clearnet", + role="participant", + source="runtime", + now=100, + ) + seed = make_sync_peer_record( + peer_url="https://node.shadowbroker.info", + transport="clearnet", + role="seed", + source="bundle", + now=200, + ) + + candidates = eligible_sync_peers([old_runtime, seed], now=300) + + assert [record.peer_url for record in candidates] == [ + "https://node.shadowbroker.info", + "https://old-runtime.example", + ] + + def test_finish_sync_success_updates_schedule(): state = begin_sync(SyncWorkerState(), peer_url="https://seed.example", now=100) finished = finish_sync( diff --git a/backend/tests/mesh/test_mesh_peer_store.py b/backend/tests/mesh/test_mesh_peer_store.py index 67bd72a..bba8810 100644 --- a/backend/tests/mesh/test_mesh_peer_store.py +++ b/backend/tests/mesh/test_mesh_peer_store.py @@ -96,3 +96,38 @@ def test_peer_store_failure_and_success_lifecycle(tmp_path): assert recovered.cooldown_until == 0 assert recovered.last_error == "" assert recovered.last_sync_ok_at == 250 + + +def test_upsert_explicit_seed_clears_stale_cooldown(tmp_path): + store = PeerStore(tmp_path / "peer_store.json") + store.upsert( + make_sync_peer_record( + peer_url="https://node.shadowbroker.info", + transport="clearnet", + role="seed", + source="bundle", + now=100, + ) + ) + failed = store.mark_failure( + "https://node.shadowbroker.info", + "sync", + error="timed out", + cooldown_s=120, + now=110, + ) + assert failed.cooldown_until == 230 + + refreshed = store.upsert( + make_sync_peer_record( + peer_url="https://node.shadowbroker.info", + transport="clearnet", + role="seed", + source="bundle", + now=120, + ) + ) + + assert refreshed.failure_count == 0 + assert refreshed.cooldown_until == 0 + assert refreshed.last_error == "" diff --git a/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx b/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx index 4fe9515..1ee4cae 100644 --- a/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx +++ b/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx @@ -61,8 +61,29 @@ const mocks = vi.hoisted(() => ({ bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'), bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'), canUseWormholeBootstrap: vi.fn(async () => false), + bootstrapWormholeIdentity: vi.fn(async () => ({ + node_id: '!sb_local', + public_key: 'local-pub', + public_key_algo: 'Ed25519', + sequence: 1, + protocol_version: 'infonet/2', + })), + exportWormholeDmInvite: vi.fn(async () => ({ + ok: true, + invite: { + event_type: 'dm_invite', + payload: { + prekey_lookup_handle: 'handle-123', + expires_at: 2_000_000_000, + }, + }, + peer_id: '!sb_local', + trust_fingerprint: 'trustfp123456', + prekey_publish_pending: false, + })), fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })), fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })), + listWormholeDmInviteHandles: vi.fn(async () => ({ ok: true, addresses: [] })), prepareWormholeInteractiveLane: vi.fn(async () => ({ ready: true, settingsEnabled: true, @@ -75,10 +96,13 @@ const mocks = vi.hoisted(() => ({ trust_fingerprint: 'invitefp', trust_level: 'invite_pinned', })), + renameWormholeDmInviteHandle: vi.fn(async () => ({ ok: true })), + revokeWormholeDmInviteHandle: vi.fn(async () => ({ ok: true, revoked: true })), isWormholeReady: vi.fn(async () => true), isWormholeSecureRequired: vi.fn(async () => false), issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })), openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })), + writeClipboard: vi.fn(async () => undefined), })); vi.mock('@/lib/api', () => ({ @@ -152,8 +176,10 @@ vi.mock('@/mesh/wormholeDmBootstrapClient', () => ({ })); vi.mock('@/mesh/wormholeIdentityClient', () => ({ + bootstrapWormholeIdentity: mocks.bootstrapWormholeIdentity, fetchWormholeStatus: mocks.fetchWormholeStatus, fetchWormholeIdentity: mocks.fetchWormholeIdentity, + exportWormholeDmInvite: mocks.exportWormholeDmInvite, prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane, getWormholeDmInviteImportErrorResult: (error: unknown) => error && typeof error === 'object' && 'result' in (error as Record) @@ -162,8 +188,11 @@ vi.mock('@/mesh/wormholeIdentityClient', () => ({ importWormholeDmInvite: mocks.importWormholeDmInvite, isWormholeReady: mocks.isWormholeReady, isWormholeSecureRequired: mocks.isWormholeSecureRequired, + listWormholeDmInviteHandles: mocks.listWormholeDmInviteHandles, issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias, openWormholeSenderSeal: mocks.openWormholeSenderSeal, + renameWormholeDmInviteHandle: mocks.renameWormholeDmInviteHandle, + revokeWormholeDmInviteHandle: mocks.revokeWormholeDmInviteHandle, })); import MessagesView from '@/components/InfonetTerminal/MessagesView'; @@ -191,10 +220,21 @@ describe('MessagesView first-contact trust UX', () => { localStorage.clear(); contactsState = {}; vi.clearAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mocks.writeClipboard }, + configurable: true, + }); mocks.getContacts.mockImplementation(() => contactsState); mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState); mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' }); + mocks.bootstrapWormholeIdentity.mockResolvedValue({ + node_id: '!sb_local', + public_key: 'local-pub', + public_key_algo: 'Ed25519', + sequence: 1, + protocol_version: 'infonet/2', + }); mocks.prepareWormholeInteractiveLane.mockResolvedValue({ ready: true, settingsEnabled: true, @@ -215,6 +255,20 @@ describe('MessagesView first-contact trust UX', () => { mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' }); mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' }); mocks.canUseWormholeBootstrap.mockResolvedValue(false); + mocks.exportWormholeDmInvite.mockResolvedValue({ + ok: true, + invite: { + event_type: 'dm_invite', + payload: { + prekey_lookup_handle: 'handle-123', + expires_at: 2_000_000_000, + }, + }, + peer_id: '!sb_local', + trust_fingerprint: 'trustfp123456', + prekey_publish_pending: false, + }); + mocks.listWormholeDmInviteHandles.mockResolvedValue({ ok: true, addresses: [] }); }); afterEach(() => { @@ -285,7 +339,7 @@ describe('MessagesView first-contact trust UX', () => { expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled(); }); - it('warms the private lane in the background before sending secure mail', async () => { + it('sends sealed mail without waiting for the private delivery route', async () => { contactsState = { '!sb_pinned': { alias: 'Pinned Peer', @@ -296,6 +350,17 @@ describe('MessagesView first-contact trust UX', () => { }, }; mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' }); + mocks.prepareWormholeInteractiveLane.mockImplementation( + () => + new Promise(() => { + /* background route prep stays pending */ + }), + ); + mocks.sendDmMessage.mockResolvedValueOnce({ + ok: true, + queued: true, + private_transport_pending: true, + }); renderMessagesView(); await openComposeForRecipient('!sb_pinned', 'hello after warmup'); @@ -306,7 +371,8 @@ describe('MessagesView first-contact trust UX', () => { await waitFor(() => expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled(), { timeout: 5000 }); await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled(), { timeout: 5000 }); - await screen.findByText(/Mail delivered to Pinned Peer/i, {}, { timeout: 5000 }); + await screen.findByText(/Mail sealed locally for Pinned Peer/i, {}, { timeout: 5000 }); + expect(screen.queryByText(/still warming up/i)).not.toBeInTheDocument(); }, 10000); it('does not flatten witness policy not met into a generic witnessed root label', async () => { @@ -360,6 +426,70 @@ describe('MessagesView first-contact trust UX', () => { expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned'); }); + it('surfaces pending contact requests in the contact list with approve and deny actions', async () => { + localStorage.setItem( + 'sb_infonet_mailbox_v1:!sb_local', + JSON.stringify({ + version: 1, + items: [ + { + id: 'request-1', + msgId: 'request-1', + folder: 'inbox', + kind: 'request', + direction: 'inbound', + senderId: '!sb_requester', + recipientId: '!sb_local', + subject: 'Contact request from !sb_requester', + body: '!sb_requester wants to open a secure mailbox.', + timestamp: 1_778_624_800, + read: false, + transport: 'relay', + deliveryClass: 'request', + requestStatus: 'pending', + requestDhPubKey: 'requester-dh', + requestDhAlgo: 'X25519', + }, + ], + }), + ); + mocks.addContact.mockImplementation((peerId: string, dhPubKey: string, _alias?: string, dhAlgo?: string) => { + contactsState[peerId] = { + alias: 'Requester', + blocked: false, + dhPubKey, + dhAlgo, + trust_level: 'unpinned', + }; + }); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + + expect(await screen.findByText('Contact Requests')).toBeInTheDocument(); + expect(await screen.findByText('1 pending')).toBeInTheDocument(); + expect(await screen.findAllByText('!sb_requester')).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Deny' })).toBeEnabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + + await waitFor(() => expect(mocks.addContact).toHaveBeenCalledWith( + '!sb_requester', + 'peer-dh', + undefined, + 'X25519', + )); + await waitFor(() => + expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: '!sb_requester', + recipientDhPub: 'peer-dh', + }), + ), + ); + expect(await screen.findByText(/Contact accepted: Requester\./i)).toBeInTheDocument(); + }); + it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => { contactsState = { '!sb_reverify': { @@ -465,7 +595,7 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the address blob/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); @@ -475,6 +605,121 @@ describe('MessagesView first-contact trust UX', () => { ).toBeInTheDocument(); }); + it('generates and copies the full signed public address instead of the lookup handle', async () => { + renderMessagesView(); + + fireEvent.click(await screen.findByRole('button', { name: 'Generate Address' })); + + await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalled()); + const copied = String(mocks.writeClipboard.mock.calls[0][0] || ''); + expect(copied).toContain('"type": "shadowbroker.infonet.dm.invite"'); + expect(copied).toContain('"prekey_lookup_handle": "handle-123"'); + expect(copied).not.toBe('handle-123'); + expect(await screen.findByText(/Generated and copied/i)).toBeInTheDocument(); + expect(screen.getByText(/Signed invite ready/i)).toBeInTheDocument(); + expect(screen.queryByText(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument(); + }); + + it('does not advertise legacy handle-only addresses as copyable public addresses', async () => { + localStorage.setItem( + 'sb_infonet_dm_addresses_v1:!sb_local', + JSON.stringify({ + version: 1, + addresses: [ + { + id: 'legacy-address', + label: 'Legacy handle', + handle: 'd8ce691f751817e137066f2a1858e21689b0118f8ec485c1', + peerId: '', + trustFingerprint: '', + inviteBlob: '', + createdAt: 1_700_000_000, + }, + ], + }), + ); + + renderMessagesView(); + + expect(await screen.findByText(/Generate an address, then send it to someone/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + + expect(await screen.findByText('Legacy handle')).toBeInTheDocument(); + expect(screen.getByText('Address unavailable locally.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled(); + }); + + it('explains raw lookup handles instead of showing a JSON parser error', async () => { + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + target: { value: 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67' }, + }); + + expect(await screen.findByText(/only a short address ID/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import Address' })).toBeDisabled(); + expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument(); + expect(mocks.importWormholeDmInvite).not.toHaveBeenCalled(); + }); + + it('hides pasted signed address JSON until advanced details are opened', async () => { + const signedAddress = JSON.stringify({ + type: 'shadowbroker.infonet.dm.invite', + version: 1, + invite: { event_type: 'dm_invite', payload: {} }, + }); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); + + const addressField = screen.getByPlaceholderText(/Paste the full text copied/i); + fireEvent.paste(addressField, { + clipboardData: { + getData: () => signedAddress, + }, + }); + + expect(screen.getByDisplayValue(/Copied address received\. Ready to import\./i)).toBeInTheDocument(); + expect(screen.queryByDisplayValue(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Advanced Details' })); + + expect(screen.getByLabelText('Raw copied public address')).toHaveValue(signedAddress); + }); + + it('imports a copied address without waiting for secure mail warm-up', async () => { + mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' }); + mocks.prepareWormholeInteractiveLane.mockImplementation( + () => + new Promise(() => { + /* background warm-up stays pending */ + }), + ); + mocks.importWormholeDmInvite.mockResolvedValueOnce({ + ok: true, + peer_id: '!sb_now', + trust_fingerprint: 'invitefp-now', + trust_level: 'invite_pinned', + contact: {}, + }); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); + + expect(await screen.findByText(/INVITE PINNED for !sb_now \(invitefp-now\)\./i)).toBeInTheDocument(); + expect(mocks.importWormholeDmInvite).toHaveBeenCalled(); + expect(screen.queryByText(/Secure mail is still warming up/i)).not.toBeInTheDocument(); + }); + it('announces compat invite imports as TOFU PINNED with backend detail', async () => { mocks.importWormholeDmInvite.mockResolvedValueOnce({ ok: true, @@ -489,7 +734,7 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the address blob/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); @@ -538,7 +783,7 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the address blob/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); @@ -552,7 +797,7 @@ describe('MessagesView first-contact trust UX', () => { }); it('uses non-blocking secure-mail startup language while the DM lane warms', async () => { - mocks.fetchWormholeStatus.mockResolvedValueOnce({ ready: false, transport_tier: 'public_degraded' }); + mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' }); mocks.prepareWormholeInteractiveLane.mockImplementation( () => new Promise(() => { @@ -563,9 +808,9 @@ describe('MessagesView first-contact trust UX', () => { renderMessagesView(); expect( - await screen.findByText(/Private message delivery is connecting/i), + await screen.findByText(/Private delivery route is connecting/i), ).toBeInTheDocument(); - expect(screen.getByText(/generate and copy your public address now/i)).toBeInTheDocument(); + expect(screen.getByText(/Addresses, contacts, and sealed sends can proceed now/i)).toBeInTheDocument(); expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument(); expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts b/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts index aa75baa..39e1637 100644 --- a/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts +++ b/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts @@ -1327,6 +1327,7 @@ describe('wormholeIdentityClient strict profile hints', () => { expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, + requireAdminSession: false, body: JSON.stringify({ invite: { event_type: 'dm_invite' }, alias: 'field contact', @@ -1378,6 +1379,7 @@ describe('wormholeIdentityClient strict profile hints', () => { const prepared = await mod.prepareWormholeInteractiveLane({ bootstrapIdentity: true }); expect(connectWormhole).toHaveBeenCalledTimes(1); + expect(connectWormhole).toHaveBeenCalledWith({ requireAdminSession: false }); expect(joinWormhole).not.toHaveBeenCalled(); expect(prepared).toEqual( expect.objectContaining({ diff --git a/frontend/src/components/InfonetTerminal/MessagesView.tsx b/frontend/src/components/InfonetTerminal/MessagesView.tsx index f5235b6..6277624 100644 --- a/frontend/src/components/InfonetTerminal/MessagesView.tsx +++ b/frontend/src/components/InfonetTerminal/MessagesView.tsx @@ -89,6 +89,7 @@ import { canUseWormholeBootstrap, } from '@/mesh/wormholeDmBootstrapClient'; import { + bootstrapWormholeIdentity, fetchWormholeStatus, fetchWormholeIdentity, exportWormholeDmInvite, @@ -188,6 +189,7 @@ const FOLDERS: Array<{ key: MailFolder; label: string; icon: React.ReactNode }> ]; const MAIL_POLL_BASE_MS = 12_000; +const DM_LANE_BACKGROUND_PREP_TIMEOUT_MS = 5_000; const STORAGE_VERSION = 1; const SHADOWBROKER_WELCOME_ID = 'shadowbroker-welcome'; const MAIL_SUBJECT_PREFIX = 'MAIL_SUBJECT:'; @@ -335,7 +337,54 @@ function formatDmAddressDate(value?: number): string { } function dmAddressShareText(address: LocalDmAddress): string { - return String(address.handle || '').trim(); + return String(address.inviteBlob || '').trim(); +} + +function dmAddressStatusLabel(address: LocalDmAddress): string { + return dmAddressShareText(address) ? 'Signed invite ready' : 'Legacy handle only'; +} + +function dmAddressDisplayId(address: LocalDmAddress): string { + return shortHandle(address.handle || address.trustFingerprint || address.id); +} + +function isLikelyRawDmLookupHandle(value: string): boolean { + const trimmed = value.trim(); + return !trimmed.startsWith('{') && !trimmed.startsWith('[') && /^[a-zA-Z0-9_.:-]{16,}$/.test(trimmed); +} + +function parseDmInviteImportBlob(raw: string): Record { + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Public address must be a signed address object.'); + } + return parsed as Record; + } catch (error) { + if (isLikelyRawDmLookupHandle(raw)) { + throw new Error( + 'That is a short address ID, not a contact address. Ask them to click Copy Address in Secure Messages and paste the full copied address here.', + ); + } + if (error instanceof SyntaxError) { + throw new Error( + 'Public address is not valid JSON. Paste the full signed Public Address copied from Secure Messages.', + ); + } + throw error; + } +} + +function inviteImportDisplayText(raw: string, hint: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (isLikelyRawDmLookupHandle(trimmed)) { + return 'Short address ID pasted - use Copy Address instead.'; + } + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return hint ? 'Copied address could not be read.' : 'Copied address received. Ready to import.'; + } + return trimmed; } function encodeMailPayload(subject: string, body: string): string { @@ -632,6 +681,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }); const [inviteImportAlias, setInviteImportAlias] = useState(''); const [inviteImportBlob, setInviteImportBlob] = useState(''); + const [inviteImportDetailsOpen, setInviteImportDetailsOpen] = useState(false); const [inviteBusy, setInviteBusy] = useState(false); const [inviteScanOpen, setInviteScanOpen] = useState(false); const [inviteScanStatus, setInviteScanStatus] = useState(''); @@ -646,6 +696,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const [privateDeliveryBusyId, setPrivateDeliveryBusyId] = useState(''); const inviteVideoRef = useRef(null); const dmLaneWarmRef = useRef | null>(null); + const dmLaneBackgroundPrepStartedRef = useRef(false); const scopeId = identity?.nodeId || 'guest'; const qrScanAvailable = @@ -704,9 +755,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return; } - if (secureRequired && wormholeReadyState) { + if (secureRequired || !localIdentity) { try { - const wormholeIdentity = await fetchWormholeIdentity(); + let wormholeIdentity = await fetchWormholeIdentity().catch(() => null); + if (!wormholeIdentity) { + wormholeIdentity = await bootstrapWormholeIdentity(); + } purgeBrowserSigningMaterial(); purgeBrowserContactGraph(); await purgeBrowserDmState(); @@ -733,7 +787,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return () => { alive = false; }; - }, [secureRequired, wormholeReadyState]); + }, [secureRequired]); const dmLaneReady = wormholeTransportTier === 'private_control_only' || @@ -750,7 +804,13 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro () => dmAddresses.filter((address) => { const server = remoteDmHandles[address.handle]; - return !address.revokedAt && !server?.expired && !server?.exhausted && !server?.revoked; + return ( + Boolean(dmAddressShareText(address)) && + !address.revokedAt && + !server?.expired && + !server?.exhausted && + !server?.revoked + ); }), [dmAddresses, remoteDmHandles], ); @@ -771,6 +831,42 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return [...dmAddresses, ...serverOnly].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); }, [dmAddresses, remoteDmHandles]); const primaryDmAddress = activeDmAddresses[0] || null; + const inviteImportHint = useMemo(() => { + const raw = inviteImportBlob.trim(); + if (!raw) return ''; + if (isLikelyRawDmLookupHandle(raw)) { + return 'This is only a short address ID. It cannot add a contact by itself. Ask them to click Copy Address and paste that full copied address here.'; + } + if (!raw.startsWith('{') && !raw.startsWith('[')) { + return 'Paste the full copied public address. It should start with { and include shadowbroker.infonet.dm.invite.'; + } + try { + parseDmInviteImportBlob(raw); + } catch { + return 'Copied address could not be read. Ask them to click Copy Address again and paste the full copied text.'; + } + return ''; + }, [inviteImportBlob]); + const inviteImportFieldValue = useMemo( + () => inviteImportDisplayText(inviteImportBlob, inviteImportHint), + [inviteImportBlob, inviteImportHint], + ); + const inviteImportCanImport = Boolean(inviteImportBlob.trim()) && !inviteImportHint; + + const applyInviteImportText = useCallback( + (value: string) => { + const nextValue = String(value || '').trim(); + setInviteImportBlob(nextValue); + setInviteImportDetailsOpen(false); + if (composeError) { + setComposeError(''); + } + if (composeStatus) { + setComposeStatus(''); + } + }, + [composeError, composeStatus], + ); const resolveMessagingIdentity = useCallback(async () => { const localIdentity = getNodeIdentity(); @@ -778,7 +874,10 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return localIdentity; } try { - const wormholeIdentity = await fetchWormholeIdentity(); + let wormholeIdentity = await fetchWormholeIdentity().catch(() => null); + if (!wormholeIdentity) { + wormholeIdentity = await bootstrapWormholeIdentity(); + } return { publicKey: wormholeIdentity.public_key, privateKey: '', @@ -853,11 +952,19 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }, [dmLaneReady]); useEffect(() => { - if (dmLaneReady && wormholeReadyState && identity) { + void syncSecureMailRuntime(); + if (dmLaneReady || dmLaneBackgroundPrepStartedRef.current) { return; } - void ensureSecureMailLane('Preparing secure mail in the background...'); - }, [dmLaneReady, ensureSecureMailLane, identity, wormholeReadyState]); + dmLaneBackgroundPrepStartedRef.current = true; + void prepareWormholeInteractiveLane({ + minimumTransportTier: 'private_control_only', + timeoutMs: DM_LANE_BACKGROUND_PREP_TIMEOUT_MS, + }).catch(() => { + // The backend continues transport startup and queued-release work. The UI + // should not keep a user-visible action waiting on this background check. + }); + }, [dmLaneReady, syncSecureMailRuntime]); const handlePrivateDeliveryAction = useCallback( async (itemId: string, action: 'wait' | 'relay') => { @@ -1064,6 +1171,26 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro .sort(([left], [right]) => left.localeCompare(right)), [contacts], ); + const pendingContactRequests = useMemo( + () => + messages + .filter( + (item) => + item.kind === 'request' && + item.direction === 'inbound' && + item.folder !== 'trash' && + item.folder !== 'junk' && + item.folder !== 'spam' && + (item.requestStatus === 'pending' || item.requestStatus === 'unresolved'), + ) + .sort((left, right) => { + if (right.timestamp !== left.timestamp) { + return right.timestamp - left.timestamp; + } + return left.id.localeCompare(right.id); + }), + [messages], + ); const composeRecipient = draft.recipient.trim(); const composeRecipientContact = composeRecipient ? contacts[composeRecipient] : undefined; const composeTrustHint = composeRecipientContact ? buildDmTrustHint(composeRecipientContact) : null; @@ -1519,14 +1646,8 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setComposeError('Write a message first.'); return; } - if (!wormholeReadyState || !dmLaneReady || !activeIdentity) { - setComposeStatus('Preparing secure mail in the background...'); - const warmed = await ensureSecureMailLane('Preparing secure mail in the background...'); - if (!warmed) { - setComposeStatus(''); - setComposeError('Secure mail is still warming up in the background.'); - return; - } + if (!activeIdentity) { + setComposeStatus('Preparing secure identity...'); activeIdentity = await syncSecureMailRuntime(); } if (!activeIdentity) { @@ -1592,7 +1713,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro transport: sent.transport || '', deliveryClass: 'shared', }); - setComposeStatus(`Mail delivered to ${displayNameForPeer(recipient, hydratedContacts)}.`); + setComposeStatus( + sent.queued || sent.private_transport_pending + ? `Mail sealed locally for ${displayNameForPeer(recipient, hydratedContacts)}. Private delivery will release when the lane is ready.` + : `Mail delivered to ${displayNameForPeer(recipient, hydratedContacts)}.`, + ); setDraft({ recipient, subject: '', @@ -1667,7 +1792,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro deliveryClass: 'request', requestStatus: 'pending', }); - setComposeStatus(`Contact request sent to ${recipient}.`); + setComposeStatus( + sent.queued || sent.private_transport_pending + ? `Contact request sealed locally for ${recipient}. Private delivery will release when the lane is ready.` + : `Contact request sent to ${recipient}.`, + ); setDraft({ recipient, subject: '', @@ -1680,7 +1809,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } finally { setBusy(false); } - }, [contacts, dmLaneReady, draft, ensureSecureMailLane, identity, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState]); + }, [contacts, draft, identity, queueSentMail, secureRequired, syncSecureMailRuntime]); const handleImportInvite = useCallback(async () => { const raw = inviteImportBlob.trim(); @@ -1689,18 +1818,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setComposeError('Paste a signed DM invite first.'); return; } + if (isLikelyRawDmLookupHandle(raw)) { + setComposeStatus(''); + setComposeError(''); + return; + } setInviteBusy(true); setComposeError(''); setComposeStatus(''); try { - if (!wormholeReadyState) { - const warmed = await ensureSecureMailLane('Preparing secure mail in the background...'); - if (!warmed) { - throw new Error('Secure mail is still warming up in the background.'); - } - } - const parsed = JSON.parse(raw) as Record; + const parsed = parseDmInviteImportBlob(raw); const nestedInvite = parsed?.invite; const invite = nestedInvite && typeof nestedInvite === 'object' && !Array.isArray(nestedInvite) @@ -1719,10 +1847,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro : 'INVITE IMPORTED'); setContacts(hydratedContacts); setInviteImportBlob(''); + setInviteImportDetailsOpen(false); setInviteImportAlias(''); setComposeStatus( `${importedTrustLabel} for ${displayNameForPeer(result.peer_id, hydratedContacts)} (${shortFingerprint(result.trust_fingerprint)}).${result.detail ? ` ${result.detail}` : ''}`, ); + void syncSecureMailRuntime(); } catch (error) { setComposeStatus(''); const failure = getWormholeDmInviteImportErrorResult(error); @@ -1742,7 +1872,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } finally { setInviteBusy(false); } - }, [ensureSecureMailLane, inviteImportAlias, inviteImportBlob, wormholeReadyState]); + }, [inviteImportAlias, inviteImportBlob, syncSecureMailRuntime]); const refreshDmAddressHandles = useCallback(async () => { try { @@ -1802,7 +1932,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }; setDmAddresses((prev) => [record, ...prev.filter((item) => item.handle !== handle)].slice(0, 32)); setDmAddressLabel(''); - await navigator.clipboard?.writeText(handle).catch(() => undefined); + await navigator.clipboard?.writeText(dmAddressShareText(record)).catch(() => undefined); setDmAddressCopyStatus( exported.prekey_publish_pending ? `Generated and copied ${label} address. Private delivery will activate as soon as the lane finishes connecting.` @@ -1822,7 +1952,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro try { const shareText = dmAddressShareText(address); if (!shareText) { - throw new Error('This address has no local handle to copy.'); + throw new Error('This saved address only has a legacy lookup handle. Generate a new public address.'); } await navigator.clipboard?.writeText(shareText); setDmAddressCopyStatus(`Copied ${address.label || shortHandle(address.handle)}.`); @@ -1929,12 +2059,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setComposeError('This request cannot be accepted until the sender is resolved.'); return; } - if (!wormholeReadyState || !dmLaneReady || !activeIdentity) { - const warmed = await ensureSecureMailLane('Preparing secure mail in the background...'); - if (!warmed) { - setComposeError('Secure mail is still warming up in the background.'); - return; - } + if (!activeIdentity) { activeIdentity = await syncSecureMailRuntime(); } if (!activeIdentity) { @@ -2026,14 +2151,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro requestStatus: 'accepted', }); setContacts(getContacts()); - setComposeStatus(`Contact accepted: ${displayNameForPeer(mail.senderId, getContacts())}.`); + setComposeStatus( + sent.queued || sent.private_transport_pending + ? `Contact acceptance sealed locally for ${displayNameForPeer(mail.senderId, getContacts())}. Private delivery will release when the lane is ready.` + : `Contact accepted: ${displayNameForPeer(mail.senderId, getContacts())}.`, + ); } catch (error) { setComposeError(error instanceof Error ? error.message : 'accept failed'); } finally { setBusy(false); } }, - [dmLaneReady, ensureSecureMailLane, identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState], + [identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime], ); const handleDenyRequest = useCallback( @@ -2043,12 +2172,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro moveMessageToFolder(mail.id, 'trash'); return; } - if (!wormholeReadyState || !dmLaneReady || !activeIdentity) { - const warmed = await ensureSecureMailLane('Preparing secure mail in the background...'); - if (!warmed) { - setComposeError('Secure mail is still warming up in the background.'); - return; - } + if (!activeIdentity) { activeIdentity = await syncSecureMailRuntime(); } if (!activeIdentity) { @@ -2071,10 +2195,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const sharedKey = await deriveSharedKey(mail.requestDhPubKey); ciphertext = await encryptDM(denyPlaintext, sharedKey); } + let sentQueued = false; if (ciphertext) { const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`; const timestamp = Math.floor(Date.now() / 1000); - await sendOffLedgerConsentMessage({ + const sent = await sendOffLedgerConsentMessage({ apiBase: API_BASE, identity: activeIdentity, recipientId: mail.senderId, @@ -2083,6 +2208,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro msgId, timestamp, }); + sentQueued = Boolean(sent.queued || sent.private_transport_pending); queueSentMail({ msgId, kind: 'system', @@ -2096,14 +2222,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }); } moveMessageToFolder(mail.id, 'trash'); - setComposeStatus(`Request denied: ${displayNameForPeer(mail.senderId, getContacts())}.`); + setComposeStatus( + sentQueued + ? `Request denial sealed locally for ${displayNameForPeer(mail.senderId, getContacts())}. Private delivery will release when the lane is ready.` + : `Request denied: ${displayNameForPeer(mail.senderId, getContacts())}.`, + ); } catch (error) { setComposeError(error instanceof Error ? error.message : 'deny failed'); } finally { setBusy(false); } }, - [dmLaneReady, ensureSecureMailLane, identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState], + [identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime], ); const handleReply = useCallback((mail: MailItem) => { @@ -2123,7 +2253,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return 'Secure identity is loading.'; } if (!wormholeReadyState || !dmLaneReady) { - return 'Private message delivery is connecting. You can generate and copy your public address now.'; + return 'Private delivery route is connecting. Addresses, contacts, and sealed sends can proceed now.'; } if (syncing) { return 'SYNCING SECURE MAILBOX...'; @@ -2176,8 +2306,25 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro {primaryDmAddress ? ( <>
{primaryDmAddress.label || 'Default address'}
-
- {dmAddressShareText(primaryDmAddress)} +
+
+
Address ID
+
+ {dmAddressDisplayId(primaryDmAddress)} +
+
+
+
Payload
+
+ {dmAddressStatusLabel(primaryDmAddress)} +
+
+
+
Expires
+
+ {primaryDmAddress.expiresAt ? formatDmAddressDate(primaryDmAddress.expiresAt) : 'Rolling'} +
+
) : ( @@ -2458,7 +2605,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
{!wormholeReadyState || !dmLaneReady - ? 'Private message lane is still connecting. You can write now and send when it is ready.' + ? 'Private delivery route is still connecting. Sending now seals the message locally and releases it when the route is ready.' : 'To message someone new, paste their public address in Contacts first. Existing contacts can be messaged from here.'}
@@ -2617,7 +2764,85 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro Contacts
-
+
+
+
+
+ Contact Requests +
+
+ {pendingContactRequests.length} pending +
+
+ {pendingContactRequests.length === 0 ? ( +
No pending contact requests.
+ ) : ( + pendingContactRequests.map((request) => { + const unresolved = request.requestStatus === 'unresolved'; + return ( +
+
+
+
+ {displayNameForPeer(request.senderId, contacts)} +
+
+ {request.senderId} +
+
+ {unresolved ? 'Needs Sender Resolution' : 'Requested'} +
+
+ {messagePreview(request)} +
+
+ Received {formatTimestamp(request.timestamp)} +
+
+
+ + +
+
+ {unresolved && ( +
+ The request arrived through reduced sealed-sender metadata. It can be + dismissed now or approved after the sender is resolved. +
+ )} +
+ ); + }) + )} +
+ +
+
+ Approved Contacts +
{activeContacts.length === 0 ? (
No approved secure contacts yet.
) : ( @@ -2713,6 +2938,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro ); }) )} +
@@ -2824,8 +3050,25 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro {dmAddressBusy === `rename:${address.handle}` ? 'Saving...' : 'Save Label'} -
- {shareText || 'Address unavailable locally.'} +
+
+
Address ID
+
+ {dmAddressDisplayId(address)} +
+
+
+
Payload
+
+ {shareText ? 'Signed invite ready' : 'Address unavailable locally.'} +
+
+
+
Share
+
+ {shareText ? 'Copy sends the full signed address.' : 'Generate a new address.'} +
+
Revoking disables this public address for new first-contact requests. Existing approved @@ -2861,19 +3104,69 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro />