From 926f2c690b25887d74cccbeadf425511535ae524 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 25 Sep 2026 09:44:53 -0400 Subject: [PATCH 1/3] feat(messages): refine direct message opening Signed-off-by: Clay Delk --- src/bundled/channels/ChannelsPage.tsx | 4 ++ .../NewMessage.recovery.test.tsx | 1 + .../direct-messages/NewMessage.test.tsx | 59 ++++++++++++++++- src/features/direct-messages/NewMessage.tsx | 63 ++++++++++++++++--- .../direct-messages/RecipientPicker.tsx | 49 +++++++++++++-- src/features/direct-messages/usePeople.ts | 6 +- src/features/relay/direct-messages.test.ts | 24 +++++++ src/features/relay/direct-messages.ts | 12 ++-- tests/browser/new-message.spec.mjs | 5 ++ 9 files changed, 203 insertions(+), 20 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index a9291f1d1..0afa2a25a 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -837,6 +837,10 @@ function ChannelWorkspace({ scope={scope} extensions={extensions} onPreparing={(pubkeys) => handoff?.prepareDm(pubkeys)} + onOpened={(channelId) => { + handoff?.clearPreparingDm(); + select(channelId); + }} onStarted={(channelId, id) => { handoff?.clearPreparingDm(); setSent({ channelId, id }); diff --git a/src/features/direct-messages/NewMessage.recovery.test.tsx b/src/features/direct-messages/NewMessage.recovery.test.tsx index 91e105385..501ccdb78 100644 --- a/src/features/direct-messages/NewMessage.recovery.test.tsx +++ b/src/features/direct-messages/NewMessage.recovery.test.tsx @@ -148,6 +148,7 @@ function setup( , diff --git a/src/features/direct-messages/NewMessage.test.tsx b/src/features/direct-messages/NewMessage.test.tsx index 5a3615ba4..ce46a9704 100644 --- a/src/features/direct-messages/NewMessage.test.tsx +++ b/src/features/direct-messages/NewMessage.test.tsx @@ -136,6 +136,7 @@ function setup() { }, media: (url: string) => url, } as unknown as RelaySession; + const onOpened = vi.fn(); const onStarted = vi.fn(); let controlState = { status: "ready", @@ -170,13 +171,19 @@ function setup() { }); const mount = () => render( - , + , ); return { session, directMessages, messages, outbox, + onOpened, onStarted, mount, control, @@ -298,7 +305,7 @@ it("opens blank and focused, adds multiple recipients, deduplicates, and enforce expect(screen.getAllByRole("option")).toHaveLength(10); await t.user.type(recipient(), "Person 2"); await screen.findByRole("option", { name: "Person 2, Agent" }); - await t.user.keyboard("{Enter}"); + await t.user.keyboard("{ArrowDown}{Enter}"); expect(recipient()).toHaveValue(""); expect(recipient()).toHaveFocus(); expect(screen.getByRole("button", { name: "Remove Person 2" })).toBeVisible(); @@ -306,7 +313,7 @@ it("opens blank and focused, adds multiple recipients, deduplicates, and enforce await waitFor(() => expect(screen.getAllByRole("option").length).toBeGreaterThan(0), ); - await t.user.keyboard("{Enter}"); + await t.user.keyboard("{ArrowDown}{Enter}"); } expect( screen.getAllByRole("button", { name: /^Remove Person/ }), @@ -319,6 +326,52 @@ it("opens blank and focused, adds multiple recipients, deduplicates, and enforce expect(t.directMessages.open).not.toHaveBeenCalled(); }); +it("opens the exact conversation without sending a message", async () => { + const t = setup(); + t.mount(); + await t.user.click(await screen.findByRole("option", { name: "Person 1" })); + + await t.user.click(screen.getByRole("button", { name: "Open conversation" })); + + await waitFor(() => expect(t.onOpened).toHaveBeenCalledWith(channel)); + expect(t.directMessages.open).toHaveBeenCalledWith( + [people[0]?.pubkey], + expect.any(AbortSignal), + ); + expect(t.messages.send).not.toHaveBeenCalled(); + expect(t.onStarted).not.toHaveBeenCalled(); +}); + +it("requires an arrow key before Enter selects a recipient", async () => { + const t = setup(); + t.mount(); + await screen.findByRole("option", { name: "Person 1" }); + + await t.user.keyboard("{Enter}"); + expect( + screen.queryByRole("button", { name: "Remove Person 1" }), + ).not.toBeInTheDocument(); + + await t.user.keyboard("{ArrowDown}{Enter}"); + expect(screen.getByRole("button", { name: "Remove Person 1" })).toBeVisible(); +}); + +it("matches an exact public key without exposing short key substrings", async () => { + const t = setup(); + t.mount(); + await screen.findByRole("option", { name: "Person 1" }); + + await t.user.type(recipient(), people[0]?.pubkey ?? ""); + expect(await screen.findByRole("option", { name: "Person 1" })).toBeVisible(); + await t.user.clear(recipient()); + await t.user.type(recipient(), (people[0]?.pubkey ?? "").slice(0, 7)); + await waitFor(() => + expect( + screen.queryByRole("option", { name: "Person 1" }), + ).not.toBeInTheDocument(), + ); +}); + it("removes once for pointerdown plus click, then Backspace; effects outlive chips", async () => { const t = setup(); t.mount(); diff --git a/src/features/direct-messages/NewMessage.tsx b/src/features/direct-messages/NewMessage.tsx index ba8d1539b..353e8c9bb 100644 --- a/src/features/direct-messages/NewMessage.tsx +++ b/src/features/direct-messages/NewMessage.tsx @@ -104,18 +104,20 @@ function recovered(operations: readonly OutgoingEvent[], viewer?: string) { }; } -/** Empty conversation until its first message is confirmed by the regular outbox. */ +/** Recipient-first conversation that can open directly or send through the durable outbox. */ export function NewMessage({ session, scope, extensions, onPreparing, + onOpened, onStarted, }: { session: RelaySession; scope: string; extensions?: ConversationExtensions | undefined; onPreparing?(pubkeys: readonly string[]): void; + onOpened(channelId: string): void; onStarted(channelId: string, messageId: string): void; }) { const [recipients, setRecipients] = useState(() => @@ -130,7 +132,7 @@ export function NewMessage({ const [restoredDraft, setRestoredDraft] = useState(); const [draftRevision, setDraftRevision] = useState(0); const retiring = useRef(undefined); - const [busy, setBusy] = useState(false); + const [busy, setBusy] = useState<"opening" | "sending" | undefined>(); const [error, setError] = useState(""); const [draftChannel] = useState(() => crypto.randomUUID()); const attempt = useRef(null); @@ -149,7 +151,7 @@ export function NewMessage({ } const failed = pending && session.directMessages.delivery(pending.id) === "failed"; - const locked = !ready || busy || (!!pending && !failed); + const locked = !ready || !!busy || (!!pending && !failed); useLayoutEffect(() => { const confirmed = pending && session.directMessages.delivery(pending.id); if (pending) { @@ -233,6 +235,38 @@ export function NewMessage({ writeView(scope, "direct-message:recipients", people.map(recipientView)); setError(""); } + async function open() { + if ( + attempt.current || + !ready || + !recipients.length || + !session.directMessages.available + ) + return; + const controller = new AbortController(); + attempt.current = controller; + setBusy("opening"); + setError(""); + try { + validateAgents(); + const pubkeys = recipients.map((person) => person.pubkey); + onPreparing?.(pubkeys); + const id = await session.directMessages.open(pubkeys, controller.signal); + controller.signal.throwIfAborted(); + clearView(scope, "direct-message:recipients"); + onOpened(id); + } catch (reason) { + if (!controller.signal.aborted) + setError( + reason instanceof Error + ? reason.message + : "Could not open the conversation. Try again.", + ); + } finally { + if (!controller.signal.aborted) setBusy(undefined); + if (attempt.current === controller) attempt.current = null; + } + } async function send(draft: MentionDraft) { if ( attempt.current || @@ -244,7 +278,7 @@ export function NewMessage({ return; const controller = new AbortController(); attempt.current = controller; - setBusy(true); + setBusy("sending"); setError(""); const key = keyFor(recipients); let current: Pending | undefined = pending; @@ -321,7 +355,7 @@ export function NewMessage({ : "Could not send your message. Try again.", ); } finally { - if (!controller.signal.aborted) setBusy(false); + if (!controller.signal.aborted) setBusy(undefined); if (attempt.current === controller) attempt.current = null; } } @@ -335,7 +369,20 @@ export function NewMessage({ />
- {busy &&

Sending message…

} + {recipients.length > 0 && !pending && ( + + )} + {busy && ( +

+ {busy === "opening" ? "Opening conversation…" : "Sending message…"} +

+ )} {!session.directMessages.available && (

Starting direct messages is unavailable on this connection. @@ -355,7 +402,7 @@ export function NewMessage({ {pending && !failed && (