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
76 changes: 76 additions & 0 deletions src/bundled/channels/ChannelMemberAvatars.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { RelaySession } from "../../features/relay/session";
import { ChannelMemberAvatars } from "./ChannelMemberAvatars";

afterEach(cleanup);

it("keeps the people preview and overflow current as profiles and membership arrive", () => {
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
let members = ["alice", "bob", "carol", "dana", "agent", "remote-agent"];
let list = { channels: [{ id: "channel", members }] };
let profiles = new Map<
string,
{ name: string; picture?: string; isAgent?: boolean }
>([
["alice", { name: "Alice" }],
["bob", { name: "Bob" }],
["carol", { name: "Carol" }],
["dana", { name: "Dana" }],
["remote-agent", { name: "Remote agent", isAgent: true }],
]);
const choices = { identities: [{ pubkey: "agent" }] };
const session = {
channels: { subscribeList: subscribe, list: () => list },
profiles: {
subscribe,
snapshot: () => profiles,
ensure: vi.fn().mockResolvedValue(undefined),
},
agentChoices: { subscribe, snapshot: () => choices },
media: (url: string) => url,
} as unknown as RelaySession;
render(<ChannelMemberAvatars session={session} channelId="channel" />);
expect(session.profiles.ensure).toHaveBeenLastCalledWith(
members,
"background",
);
expect(screen.getByTitle("Alice")).toBeInTheDocument();
expect(screen.getByTitle("Bob")).toBeInTheDocument();
expect(screen.getByTitle("Carol")).toBeInTheDocument();
expect(screen.queryByTitle("Dana")).not.toBeInTheDocument();
expect(screen.getByText("+1")).toBeInTheDocument();

act(() => {
profiles = new Map(profiles).set("dana", {
name: "Dana",
picture: "https://example.test/dana.png",
});
for (const listener of listeners) listener();
});
expect(screen.getByTitle("Dana").querySelector("img")).toHaveAttribute(
"src",
"https://example.test/dana.png",
);
expect(screen.queryByTitle("Carol")).not.toBeInTheDocument();
expect(screen.getByText("+1")).toBeInTheDocument();

act(() => {
members = ["bob"];
list = { channels: [{ id: "channel", members }] };
for (const listener of listeners) listener();
});
expect(session.profiles.ensure).toHaveBeenLastCalledWith(
["bob"],
"background",
);
expect(screen.getByTitle("Bob")).toBeInTheDocument();
expect(screen.queryByTitle("Dana")).not.toBeInTheDocument();
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
});
68 changes: 68 additions & 0 deletions src/bundled/channels/ChannelMemberAvatars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useSyncExternalStore } from "react";
import type { RelaySession } from "../../features/relay/session";
import { useIdentityNames } from "../../features/identity-names/react";
import { AvatarStack } from "../../shared/design-system/ui/AvatarStack";
import { UsersIcon } from "../../shared/design-system/icons";
import { avatarMedia } from "../../shared/avatar-source";
import { formatPublicKey } from "../../shared/identity/public-key";

/** The members trigger previews people; the dialog still includes every member. */
export function ChannelMemberAvatars({
session,
channelId,
}: {
session: RelaySession;
channelId: string;
}) {
const list = useSyncExternalStore(
session.channels.subscribeList,
session.channels.list,
session.channels.list,
);
const profiles = useSyncExternalStore(
session.profiles.subscribe,
session.profiles.snapshot,
session.profiles.snapshot,
);
const choices = useSyncExternalStore(
session.agentChoices.subscribe,
session.agentChoices.snapshot,
session.agentChoices.snapshot,
);
const resolveName = useIdentityNames(session.names);
const channel =
list.channels.find((item) => item.id === channelId) ??
session.channels.get?.(channelId);
const members = channel?.members ?? [];
const memberKey = members.join(":");
useEffect(() => {
if (memberKey)
void session.profiles
.ensure(memberKey.split(":"), "background")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Promote pending profile batches when Members opens

Keep automatic toolbar enrichment in the background, but preserve the dialog’s foreground demand. ChannelMembersDialog.tsx:625–666 calls foreground ensure and waits for it before revealing the initial list; profile-directory.ts:99–102 only joins pending promises, so it never reaches the reader’s queued-job promotion. Even a small roster can remain queued behind another background read despite spare foreground capacity. Since the 10-second deadline starts when queued, opening Members can remain loading until the profile read times out and displays a name-loading error; Retry only recovers after the pending entry settles. The former foreground toolbar request avoided this serialization.

Have foreground ensure promote overlapping pending batches through the existing reader deduplication/promotion machinery, and cover this handoff with held work through the real directory/reader. The added mock-argument assertions do not exercise that interaction. This is source inference, not a measured latency result.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 50718cc. Each pending batch in profile-directory.ts now records its priority. When a foreground ensure joins a background batch, it re-issues the equal read at foreground priority, so the reader's dedupe joins the existing job and promotes it if it is still queued (no extra network read). Added a regression test in profile-details.test.ts that uses the real directory and reader: unrelated background work holds the background slot, the toolbar's background ensure queues behind it, and the foreground ensure must start it. The test fails on a2c0901 and passes with the fix. src/features/relay passes (2135 tests); typecheck and hooks are clean.

.catch(() => {});
}, [session, memberKey]);
const agents = new Set(choices.identities.map((agent) => agent.pubkey));
const people = members
.filter((id) => !agents.has(id) && !profiles.get(id)?.isAgent)
// Prefer recognizable photos over key-only placeholders.
.sort(
(a, b) =>
Number(!!profiles.get(b)?.picture) - Number(!!profiles.get(a)?.picture),
);
if (!people.length) return <UsersIcon size="1rem" aria-hidden="true" />;
return (
<AvatarStack
size="small"
max={3}
items={people.map((id) => ({
id,
name: resolveName(
id,
profiles.get(id)?.name ?? formatPublicKey(id) ?? "Member",
),
src: avatarMedia(profiles.get(id)?.picture, session.media),
shape: "circle",
}))}
/>
);
}
15 changes: 13 additions & 2 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useReturnHighlight } from "../../features/messages/use-return-highlight
import { ChannelBody } from "./ChannelBody";
import { tabPane, useChannelTabState } from "./useChannelTabState";
import { ChannelMembersDialog } from "./ChannelMembersDialog";
import { UsersIcon } from "../../shared/design-system/icons";
import { ChannelMemberAvatars } from "./ChannelMemberAvatars";
import {
soloDraft,
soloDraftTarget,
Expand Down Expand Up @@ -1650,6 +1650,12 @@ function ChannelWorkspace({
{current && (
<IconButton
size="sm"
variant="avatar"
style={{
width: "auto",
minWidth: "var(--size-control-sm)",
paddingInline: "var(--space-1)",
}}
aria-label="Access"
title="Access"
aria-haspopup="dialog"
Expand All @@ -1658,7 +1664,12 @@ function ChannelWorkspace({
onClick={(event) =>
openMembers(event.currentTarget)
}
icon={<UsersIcon size="1rem" aria-hidden="true" />}
icon={
<ChannelMemberAvatars
session={queries}
channelId={current.id}
/>
}
/>
)}
{drawer.launchers}
Expand Down
24 changes: 24 additions & 0 deletions src/features/relay/profile-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,27 @@ it("retires pending profile reads before reconnect and fences cache clear/dispos
directory.dispose();
}
});

it("promotes a queued background profile read when foreground demand joins it", async () => {
const wire = scriptedTransport(user.pubkey, keypair().pubkey);
const reader = createRelayReader(wire.transport);
const directory = createProfileDirectory(reader.reader);
try {
// Unrelated background work holds the reader's only background slot.
const held = reader.reader.read([{ kinds: [3], limit: 1 }], {
priority: "background",
});
const enrichment = directory.ensure([user.pubkey], "background");
expect(wire.pending).toHaveLength(1);
const demanded = directory.ensure([user.pubkey], "foreground");
expect(wire.pending).toHaveLength(2);
wire.pending[1]?.respond([profile(user, { name: "Mic" })]);
await Promise.all([enrichment, demanded]);
expect(directory.queries.snapshot().get(user.pubkey)?.name).toBe("Mic");
wire.next().respond([]);
await held;
} finally {
directory.dispose();
reader.dispose();
}
});
52 changes: 36 additions & 16 deletions src/features/relay/profile-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export function createProfileDirectory(
) {
const events = new ByteLru<RelayEvent>(1024, 2 * 1024 * 1024);
const owners = new WeakMap<RelayEvent, string>();
const pending = new Map<string, Promise<void>>();
const pending = new Map<
string,
{ work: Promise<void>; authors: string[]; priority: Priority }
>();
const listeners = new Set<() => void>();
let snapshot: ReadonlyMap<string, Profile> = new Map();
let controller = new AbortController();
Expand Down Expand Up @@ -97,24 +100,41 @@ export function createProfileDirectory(
const signal = controller.signal;
const waiting = new Set<Promise<void>>();
const missing = unique.filter((id) => {
const work = pending.get(id);
if (work) waiting.add(work);
return !work && (refresh || !snapshot.has(id));
const batch = pending.get(id);
if (batch) {
// Rejoin the equal read so the reader promotes it if still queued;
// waiting on the old promise alone would leave it behind background work.
if (priority === "foreground" && batch.priority === "background") {
batch.priority = priority;
void reader
.read([{ kinds: [0], authors: batch.authors, limit: 500 }], {
signal,
priority,
})
.catch(() => {});
}
waiting.add(batch.work);
}
return !batch && (refresh || !snapshot.has(id));
});
for (let offset = 0; offset < missing.length; offset += 500) {
const authors = missing.slice(offset, offset + 500);
const work = reader
.read([{ kinds: [0], authors, limit: 500 }], { signal, priority })
.then((result) => {
if (!signal.aborted)
accept(result.filter((event) => authors.includes(event.pubkey)));
})
.finally(() => {
for (const id of authors)
if (pending.get(id) === work) pending.delete(id);
});
for (const id of authors) pending.set(id, work);
waiting.add(work);
const batch = {
authors,
priority,
work: reader
.read([{ kinds: [0], authors, limit: 500 }], { signal, priority })
.then((result) => {
if (!signal.aborted)
accept(result.filter((event) => authors.includes(event.pubkey)));
})
.finally(() => {
for (const id of authors)
if (pending.get(id) === batch) pending.delete(id);
}),
};
for (const id of authors) pending.set(id, batch);
waiting.add(batch.work);
}
await Promise.all(waiting);
}
Expand Down
5 changes: 3 additions & 2 deletions src/shared/design-system/ui/AvatarStack.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@
width: 1.5rem;
height: 1.5rem;
}
/* Thread summaries retain their existing circular overflow badge. */
/* Keep short counts round; wider counts grow into a capsule. */
.root[data-size="small"] .count {
display: inline-grid;
place-items: center;
flex-shrink: 0;
width: 1.5rem;
min-width: 1.5rem;
padding-inline: var(--space-1h);
height: 1.5rem;
margin-inline-start: 0;
margin-inline-end: calc(-1 * var(--space-1));
Expand Down
Loading