Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function setup(
<NewMessage
session={owner.session}
scope={scope}
onOpened={vi.fn()}
onStarted={onStarted}
extensions={extensions}
/>,
Expand Down
64 changes: 59 additions & 5 deletions src/features/direct-messages/NewMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -170,13 +171,19 @@ function setup() {
});
const mount = () =>
render(
<NewMessage session={session} scope={scope} onStarted={onStarted} />,
<NewMessage
session={session}
scope={scope}
onOpened={onOpened}
onStarted={onStarted}
/>,
);
return {
session,
directMessages,
messages,
outbox,
onOpened,
onStarted,
mount,
control,
Expand Down Expand Up @@ -298,15 +305,15 @@ 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();
for (let index = 0; index < 7; index++) {
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/ }),
Expand All @@ -319,6 +326,54 @@ 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();
for (const length of [8, 40]) {
await t.user.clear(recipient());
await t.user.type(recipient(), (people[0]?.pubkey ?? "").slice(0, length));
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();
Expand Down Expand Up @@ -547,9 +602,8 @@ it("keeps loaded pages when clearing search or returning to New message, scoped
expect(screen.getAllByRole("option").map((row) => row.textContent)).toEqual(
loaded,
);
expect(t.directMessages.people).toHaveBeenCalledTimes(requests);
fireEvent.scroll(screen.getByRole("listbox"));
await screen.findByRole("option", { name: "Person 3" });
expect(t.directMessages.people).toHaveBeenCalledTimes(requests + 1);
expect(t.directMessages.people).toHaveBeenLastCalledWith(
"",
3,
Expand Down
63 changes: 55 additions & 8 deletions src/features/direct-messages/NewMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand All @@ -130,7 +132,7 @@ export function NewMessage({
const [restoredDraft, setRestoredDraft] = useState<MentionDraft>();
const [draftRevision, setDraftRevision] = useState(0);
const retiring = useRef<string | undefined>(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<AbortController | null>(null);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 ||
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -335,7 +369,20 @@ export function NewMessage({
/>
<div className={styles.blank} data-new-message-body="" />
<div className={styles.feedback}>
{busy && <p role="status">Sending message…</p>}
{recipients.length > 0 && !pending && (
<Button
type="button"
disabled={locked || !session.directMessages.available}
onClick={() => void open()}
>
Open conversation
</Button>
)}
{busy && (
<p role="status">
{busy === "opening" ? "Opening conversation…" : "Sending message…"}
</p>
)}
{!session.directMessages.available && (
<p role="status">
Starting direct messages is unavailable on this connection.
Expand All @@ -355,7 +402,7 @@ export function NewMessage({
{pending && !failed && (
<Button
type="button"
disabled={busy}
disabled={!!busy}
onClick={() => pending && void send(pending.draft)}
>
Retry send
Expand All @@ -381,7 +428,7 @@ export function NewMessage({
locked,
disabled:
!ready ||
busy ||
!!busy ||
!recipients.length ||
!session.directMessages.available,
submit: (draft) => void send(draft),
Expand Down
49 changes: 45 additions & 4 deletions src/features/direct-messages/RecipientPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,53 @@ export function RecipientPicker({
);
const removal = useChipRemoval();
const atLimit = selected.length >= 8;
const candidates = directory.people.filter(
const existing = new Set<string>();
const interacted = new Set<string>();
const shared = new Set<string>();
for (const channel of session.channels.list().channels) {
for (const pubkey of channel.members ?? []) shared.add(pubkey);
if (channel.channelType !== "dm") continue;
for (const pubkey of channel.participants ?? []) interacted.add(pubkey);
if (channel.participants?.length === 1) {
const pubkey = channel.participants[0];
if (pubkey) existing.add(pubkey);
}
}
const relationshipRank = (person: Recipient) =>
!query.trim()
? 0
: existing.has(person.pubkey)
? 0
: interacted.has(person.pubkey)
? 1
: controlled.has(person.pubkey)
? 2
: shared.has(person.pubkey)
? 3
: 4;
const eligible = directory.people.filter(
(person) =>
person.pubkey !== session.viewer &&
(!person.isAgent || controlled.has(person.pubkey)) &&
!selected.some((item) => item.pubkey === person.pubkey),
);
const ordered = useRef<{ query: string; pubkeys: string[] }>({
query,
pubkeys: [],
});
if (ordered.current.query !== query) ordered.current = { query, pubkeys: [] };
const knownOrder = new Set(ordered.current.pubkeys);
ordered.current.pubkeys.push(
...eligible
.filter((person) => !knownOrder.has(person.pubkey))
.sort((left, right) => relationshipRank(left) - relationshipRank(right))
.map((person) => person.pubkey),
);
const byPubkey = new Map(eligible.map((person) => [person.pubkey, person]));
const candidates = ordered.current.pubkeys.flatMap((pubkey) => {
const person = byPubkey.get(pubkey);
return person ? [person] : [];
});
// Remember eligible namesakes while search text and selection change.
const known = useRef(new Map<string, Recipient>());
for (const person of [...candidates, ...selected])
Expand All @@ -67,9 +108,9 @@ export function RecipientPicker({
const discriminator = (person: Recipient) => identities.get(person.pubkey);
const label = (person: Recipient) =>
[person.name, discriminator(person)].filter(Boolean).join(" ");
const active =
candidates.find((person) => person.pubkey === highlight.pubkey) ??
candidates[0];
const active = candidates.find(
(person) => person.pubkey === highlight.pubkey,
);
const loadingRows = useRef(10);
useEffect(() => {
if (!directory.loading && !directory.error)
Expand Down
6 changes: 5 additions & 1 deletion src/features/direct-messages/usePeople.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ function empty(session: RelaySession, query: string): PeopleState {
}
function matching(people: Recipient[], query: string) {
const needle = normalize(query.trim());
return people.filter((person) => normalize(person.name).includes(needle));
return people.filter(
(person) =>
normalize(person.name).includes(needle) ||
(/^[0-9a-f]{64}$/.test(needle) && person.pubkey === needle),
);
}
export function usePeople(session: RelaySession, query: string) {
const cache = useMemo(() => {
Expand Down
24 changes: 24 additions & 0 deletions src/features/relay/direct-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,30 @@ it("reads paginated verified profiles, excludes the viewer, and cancels with the
}
});

it("reads an exact public-key profile without widening directory search", async () => {
const t = setup();
try {
const result = await t.dm.people(
t.other.pubkey,
1,
new AbortController().signal,
);
expect(result.people).toEqual([
expect.objectContaining({ pubkey: t.other.pubkey, name: "Other" }),
]);
expect(t.query.mock.calls.at(-1)?.[0]).toEqual([
{
kinds: [0],
authors: [t.other.pubkey],
limit: 30,
page: 1,
},
]);
} finally {
t.owner.dispose();
}
});

it.each(["", "C"])(
"keeps large-profile pages within the read budget without skipping matches: %s",
async (query) => {
Expand Down
Loading
Loading