Skip to content

Commit e2cd972

Browse files
author
code4bones
committed
Restore PWA contact request on add
1 parent 76581b6 commit e2cd972

5 files changed

Lines changed: 78 additions & 5 deletions

File tree

‎pwa/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pwa/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@code4bones/branch-pwa",
3-
"version": "0.1.0-beta.141",
3+
"version": "0.1.0-beta.142",
44
"private": true,
55
"type": "module",
66
"scripts": {

‎pwa/src/app/ChatListSidebar.tsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import { formatChatListTimestamp } from "./format-time.js";
1010
import { DeliveryStateIcon } from "./MessageLog.js";
1111
import { chatPath, MESSAGE_REQUESTS_PATH, SETTINGS_PATH } from "./paths.js";
1212
import { pwaReleaseVersion } from "./pwa-release.js";
13+
import { sendContactRequest } from "../connectivity/seal-and-send.js";
1314
import { useBranchID } from "../identity/use-branch-id.js";
1415
import { useChatList, useContactDiscoveries, useContactFolders, useContactPresence, useContactTyping, useContacts, useIdentity, useInboundAttachmentOffer, useIncomingMessageRequests, useTransportStatus, type ChatListEntry } from "../state/hooks.js";
1516
import { filterChatListByFolder } from "../state/contact-folder-filter.js";
17+
import { useAppStoreApi } from "../state/StoreProvider.js";
1618
import { parseBranchID } from "@code4bones/branch-core";
1719

1820
export function ChatListSidebar(): React.JSX.Element {
@@ -286,6 +288,8 @@ function IncomingAttachmentBadge({ peerId }: { readonly peerId: string }): React
286288
function AddContactModal({ open, onClose }: { readonly open: boolean; readonly onClose: () => void }): React.JSX.Element {
287289
const contacts = useContacts();
288290
const discovery = useContactDiscoveries();
291+
const identity = useIdentity();
292+
const storeApi = useAppStoreApi();
289293
const [branchId, setBranchId] = useState("");
290294
const [error, setError] = useState<string | null>(null);
291295

@@ -303,8 +307,28 @@ function AddContactModal({ open, onClose }: { readonly open: boolean; readonly o
303307

304308
const add = (row: typeof discovery.rows[number]): void => {
305309
if (row.displayName === null || row.peerId === null || row.hpkePublicKey === null) return;
306-
if (!contacts.contacts.some((contact) => contact.peerId === row.peerId)) contacts.upsertContact({ contactId: crypto.randomUUID(), displayName: row.displayName, peerId: row.peerId, hpkePublicKey: row.hpkePublicKey, lastRouteHint: null });
310+
if (contacts.contacts.some((contact) => contact.peerId === row.peerId)) {
311+
discovery.remove(row.branchId);
312+
return;
313+
}
314+
contacts.upsertContact({ contactId: crypto.randomUUID(), displayName: row.displayName, peerId: row.peerId, hpkePublicKey: row.hpkePublicKey, lastRouteHint: null });
307315
discovery.remove(row.branchId);
316+
const localIdentity = identity.identity;
317+
if (localIdentity === null || localIdentity.displayName === null) return;
318+
void sendContactRequest({
319+
senderPeerId: localIdentity.peerId,
320+
senderHpkePublicKey: localIdentity.hpkePublicKey,
321+
senderDisplayName: localIdentity.displayName,
322+
recipientPeerId: row.peerId,
323+
recipientHpkePublicKey: row.hpkePublicKey
324+
}).then(() => {
325+
storeApi.getState().recordTransportTrace("contact request: sent");
326+
}).catch(() => {
327+
// The contact card is still useful locally. A missed live request is
328+
// neither a rejection nor an offline-delivery promise, so do not queue
329+
// or fabricate an invite outcome.
330+
storeApi.getState().recordTransportTrace("contact request: not sent");
331+
});
308332
};
309333

310334
return (

‎pwa/src/connectivity/seal-and-send.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,34 @@ export interface PresenceControlOptions {
3535
readonly pingId: string;
3636
}
3737

38+
export const contactRequestBody = "Contact request";
39+
40+
// Contact discovery deliberately returns only the recipient's signed card to
41+
// the requester. Adding that card locally must not silently make the other
42+
// endpoint a known contact. Send one explicit, live-only legacy first-contact
43+
// payload so the recipient's existing Message Requests UI can ask for consent.
44+
// This is not an outbox entry: there is no offline invite queue, retry, or
45+
// delivery claim when the recipient has already left the live route.
46+
export async function sendContactRequest(options: {
47+
readonly senderPeerId: string;
48+
readonly senderHpkePublicKey: string;
49+
readonly senderDisplayName: string;
50+
readonly recipientPeerId: string;
51+
readonly recipientHpkePublicKey: string;
52+
}): Promise<void> {
53+
await sealAndSendPayload({
54+
senderPeerId: options.senderPeerId,
55+
recipientPeerId: options.recipientPeerId,
56+
recipientHpkePublicKey: options.recipientHpkePublicKey,
57+
deliveryId: createDeliveryID(),
58+
plaintext: encodeBetaPwaMessagePayload({
59+
body: contactRequestBody,
60+
replyHpkePublicKey: options.senderHpkePublicKey,
61+
senderDisplayName: options.senderDisplayName
62+
})
63+
});
64+
}
65+
3866
// Seals a visible user message and tracks only its relay-forward outcome.
3967
// Control envelopes use the same HPKE boundary but deliberately have no
4068
// message-log state and no delivery claim.

‎pwa/tests/app-shell.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
} from "@code4bones/branch-core";
3333

3434
import { openIncomingEnvelope } from "../src/connectivity/open-envelope.js";
35-
import { createDeliveryID } from "../src/connectivity/seal-and-send.js";
35+
import { contactRequestBody, createDeliveryID } from "../src/connectivity/seal-and-send.js";
3636
import { encodeChatTextApplicationPayload } from "../src/connectivity/application-payload.js";
3737
import {
3838
applicationCapabilitiesControlDescriptor,
@@ -572,6 +572,27 @@ void test("PWA classifies an authenticated unknown sender as a local message req
572572
assert.throws(() => { classifyIncomingMessage({ plaintext, senderPeerId: "bad", knownContactId: null }); });
573573
});
574574

575+
void test("adding a discovered contact emits one live consent request without an outbox", async () => {
576+
const senderPeerId = Buffer.alloc(32, 8).toString("base64url");
577+
const replyHpkePublicKey = Buffer.alloc(32, 9).toString("base64url");
578+
const plaintext = encodeBetaPwaMessagePayload({ body: contactRequestBody, replyHpkePublicKey, senderDisplayName: "Alice" });
579+
const sidebar = await readFile(chatListSidebarPath, "utf8");
580+
const sender = await readFile(sealAndSendPath, "utf8");
581+
582+
assert.equal(contactRequestBody, "Contact request");
583+
assert.deepEqual(classifyIncomingMessage({ plaintext, senderPeerId, knownContactId: null }), {
584+
kind: "message_request",
585+
senderPeerId,
586+
senderHpkePublicKey: replyHpkePublicKey,
587+
senderDisplayName: "Alice",
588+
body: contactRequestBody
589+
});
590+
assert.match(sidebar, /sendContactRequest/);
591+
assert.match(sidebar, /contact request: sent/);
592+
assert.match(sender, /export async function sendContactRequest/);
593+
assert.match(sender, /This is not an outbox entry/);
594+
});
595+
575596
void test("PWA presence controls are strict encrypted ping-pong payloads and never invite unknown traffic", () => {
576597
const senderPeerId = Buffer.alloc(32, 8).toString("base64url");
577598
const pingId = Buffer.alloc(16, 13).toString("base64url");

0 commit comments

Comments
 (0)