diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx
index a72e85d23a..aab62654af 100644
--- a/src/app/shell/SearchResults.tsx
+++ b/src/app/shell/SearchResults.tsx
@@ -1,5 +1,8 @@
import { workflowLabel } from "../../features/relay/workflow-attribution";
-import { useIdentityNames } from "../../features/identity-names/react";
+import {
+ useIdentityNames,
+ useNamesRevision,
+} from "../../features/identity-names/react";
import { npubEncode } from "nostr-tools/nip19";
import {
useEffect,
@@ -84,6 +87,7 @@ function conversationName(
/** The most rows the People and Agents groups each show. */
const PERSON_ROWS = 6;
+const noMembers: readonly string[] = [];
export function SearchResults({
session,
@@ -321,6 +325,8 @@ export function SearchResults({
peopleActive,
);
useMentionArchives(session, showAuthorPicker || peopleActive);
+ // People labels come from one names scope built each render.
+ useNamesRevision(session.names, peopleActive);
// The viewer's own agents open a new chat, not a direct message: the
// same agents the new-chat composer accepts. Their earlier chats
// still show as conversations.
@@ -588,13 +594,14 @@ export function SearchResults({
name: person.name,
...(person.isAgent ? { isAgent: true } : {}),
}));
+ const named = session.names?.scope(candidates, displayFacts);
for (const { pubkey, source, agent } of eligible) {
const dm = relationships.dm(pubkey);
const agentName =
messageableAgents.get(pubkey)?.name ?? sessionAgents.get(pubkey)?.name;
people.push({
pubkey,
- label: resolveName(pubkey, source.name, candidates, displayFacts),
+ label: named?.(pubkey)?.name ?? source.name,
username: source.username,
agentName: agentName !== source.name ? agentName : undefined,
agent,
@@ -949,7 +956,7 @@ export function SearchResults({
displayId,
profiles.get(displayId)?.name ?? displayId.slice(0, 10),
list.channels.find((channel) => channel.id === message.channelId)
- ?.members ?? [],
+ ?.members ?? noMembers,
);
return {
key: message.id,
diff --git a/src/bundled/channels/useSoloShare.ts b/src/bundled/channels/useSoloShare.ts
index 1cd9d47264..61ecadabf2 100644
--- a/src/bundled/channels/useSoloShare.ts
+++ b/src/bundled/channels/useSoloShare.ts
@@ -1,5 +1,6 @@
import {
useEffect,
+ useMemo,
useRef,
useState,
useSyncExternalStore,
@@ -94,14 +95,17 @@ export function useSoloShare({
const chosen = list.channels.find(
(item) => item.id === destination && item.id !== channelId,
);
- const destinationMembers =
+ const readyMembers =
chosen &&
destinationRoster?.id === chosen.id &&
destinationRoster.status === "ready" &&
- !chosen.cached &&
- chosen.members
- ? [...new Set(chosen.members)]
+ !chosen.cached
+ ? chosen.members
: undefined;
+ const destinationMembers = useMemo(
+ () => (readyMembers ? [...new Set(readyMembers)] : undefined),
+ [readyMembers],
+ );
useEffect(() => {
void rosterReload;
if (!enabled) return;
@@ -199,9 +203,12 @@ export function useSoloShare({
.catch(() => {});
}, [session, destinationProfileKeys]);
const resolveName = useIdentityNames(session.names);
- const displayKeys = [
- ...new Set([...(source?.members ?? []), ...(destinationMembers ?? [])]),
- ];
+ const displayKeys = useMemo(
+ () => [
+ ...new Set([...(source?.members ?? []), ...(destinationMembers ?? [])]),
+ ],
+ [source?.members, destinationMembers],
+ );
const labels = new Map(
displayKeys.map((key) => [
key,
diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx
index 620caf21a6..9676db5dd7 100644
--- a/src/bundled/mentions/team-mentions.test.tsx
+++ b/src/bundled/mentions/team-mentions.test.tsx
@@ -675,3 +675,33 @@ it("directory pages rebuild the name index only for people they add", async () =
view.unmount();
h.library.dispose();
});
+
+it("an open chooser relabels choices when only names change", async () => {
+ const h = fixture();
+ let name = "Before";
+ let revision = 0;
+ const listeners = new Set<() => void>();
+ Object.assign(h.session, {
+ names: {
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ snapshot: () => revision,
+ scope: () => () => ({ name, source: "agent-directory" }),
+ },
+ });
+ function Chooser() {
+ const model = useMentionChoices(h.session, "parent", false, "", "open");
+ return <>{model.candidates.map((choice) => choice.label).join(",")}>;
+ }
+ render();
+ await waitFor(() => expect(screen.getByText("Before")).toBeInTheDocument());
+ act(() => {
+ name = "After";
+ revision++;
+ for (const listener of listeners) listener();
+ });
+ expect(screen.getByText("After")).toBeInTheDocument();
+ h.library.dispose();
+});
diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts
index 90725ea8be..823c74e542 100644
--- a/src/bundled/mentions/use-mention-choices.ts
+++ b/src/bundled/mentions/use-mention-choices.ts
@@ -12,13 +12,13 @@ import type { MentionRecipient } from "../../features/messages/mention-draft";
import type { Agent } from "../../features/agents/service";
import { useAgentChoices } from "../../features/agents/use-choices";
import { AgentsContext, useAgentsSnapshot } from "../../features/agents/react";
-import { useIdentityNames } from "../../features/identity-names/react";
import { DraftMentionRoster } from "../../features/messages/draft-mention-roster";
import { SelectedMentionContext } from "../../features/messages/selected-mention-context";
import { archivedMention } from "../../features/messages/mention-admission";
import { mentionHistory } from "../../features/messages/mention-history";
import { directoryChanges, mentionCandidates } from "./mention-candidates";
import { useListedChannel } from "../../features/relay/listed-channel";
+import { useNamesRevision } from "../../features/identity-names/react";
import {
exactMention,
matchingMentions,
@@ -223,7 +223,8 @@ export function useMentionChoices(
lifetime,
);
const archives = useMentionArchives(session, open);
- const resolve = useIdentityNames(session.names);
+ // Labels come from names scopes directly: watch every name change while open.
+ const names = useNamesRevision(session.names, open);
const [installed, install] = useState<{
session: ComposerSession;
key: string;
@@ -278,7 +279,7 @@ export function useMentionChoices(
void channel;
void agents;
void archives;
- void resolve;
+ void names;
void revision;
// A closed or disabled chooser shows nothing, so it names nothing.
if (!open)
@@ -296,7 +297,7 @@ export function useMentionChoices(
channel,
agents,
archives,
- resolve,
+ names,
revision,
]);
// Install membership first; agent discovery must not hide known members.
diff --git a/src/bundled/relay-notes/NotesPage.tsx b/src/bundled/relay-notes/NotesPage.tsx
index 7b035dd438..c49c1c0d96 100644
--- a/src/bundled/relay-notes/NotesPage.tsx
+++ b/src/bundled/relay-notes/NotesPage.tsx
@@ -222,7 +222,10 @@ export function NotesCommunity({
}: { session: RelaySession } & NotesNavigation) {
const choices = useAgentChoices(session);
const resolveName = useIdentityNames(session.names);
- const candidates = choices.identities.map((agent) => agent.pubkey);
+ const candidates = useMemo(
+ () => choices.identities.map((agent) => agent.pubkey),
+ [choices.identities],
+ );
const agents = choices.identities.map((agent) => ({
pubkey: agent.pubkey,
picture: agent.avatar,
diff --git a/src/bundled/todos/TodosPanel.tsx b/src/bundled/todos/TodosPanel.tsx
index ae23dc180b..564967dcdf 100644
--- a/src/bundled/todos/TodosPanel.tsx
+++ b/src/bundled/todos/TodosPanel.tsx
@@ -80,6 +80,7 @@ type Draft = {
base: string | null;
input: string;
};
+const noMembers: readonly string[] = [];
const empty: Draft = { content: "", original: "", base: null, input: "" };
const statuses: [Status, string][] = [
["todo", "To do"],
@@ -145,7 +146,7 @@ export function TodosPanel({
(channel) => channel.id === context.channelId,
)?.members;
const resolveName = (pubkey: string, fallback: string) =>
- resolveIdentity(pubkey, fallback, members ?? []);
+ resolveIdentity(pubkey, fallback, members ?? noMembers);
const key = `todos-draft-v1:${context.channelId}`;
const [saved] = useState(() => readDraft(context.scope, key));
const savedDirty = !!saved && saved.content !== saved.original;
diff --git a/src/features/identity-names/consumers.test.tsx b/src/features/identity-names/consumers.test.tsx
index 3a6859284b..0af84c50a7 100644
--- a/src/features/identity-names/consumers.test.tsx
+++ b/src/features/identity-names/consumers.test.tsx
@@ -4,7 +4,7 @@ stubAvatarBrowserApis();
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import { createRef } from "react";
+import { createRef, useSyncExternalStore } from "react";
import { Context } from "@deepseek-ai/cordis";
import userEvent from "@testing-library/user-event";
import { PluginRuntime } from "../../plugins/runtime";
@@ -385,8 +385,12 @@ it.each(["live", "reconnect", "clear-cache"] as const)(
it("uses channel scope in link previews and activity, and participant scope in sidebar DMs", async () => {
const f = fixture();
function Sidebar() {
+ const list = useSyncExternalStore(
+ f.session.channels.subscribeList,
+ f.session.channels.list,
+ );
const labels = useChannelLabels(
- f.session.channels.list().channels,
+ list.channels,
f.session.profiles,
f.session.names,
);
diff --git a/src/features/identity-names/react.test.tsx b/src/features/identity-names/react.test.tsx
new file mode 100644
index 0000000000..66fbbcfdaf
--- /dev/null
+++ b/src/features/identity-names/react.test.tsx
@@ -0,0 +1,214 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { StrictMode } from "react";
+import { afterEach, expect, it } from "vitest";
+import { npubEncode } from "nostr-tools/nip19";
+import { useIdentityNames } from "./react";
+import { bindNames, type NameSource } from "./service";
+import { createAgentDirectory } from "./testing";
+
+afterEach(cleanup);
+
+const [a, b, c] = ["a", "b", "c"].map((key) => key.repeat(64)) as [
+ string,
+ string,
+ string,
+];
+const suffix = (key: string) => npubEncode(key).slice(-4);
+
+function fixture() {
+ let profiles = new Map([[a, { name: "Alex" }]]);
+ const library = {
+ status: "ready",
+ definitions: [],
+ identities: [],
+ } as const;
+ const listeners = new Set<() => void>();
+ const source: NameSource = {
+ profiles: {
+ snapshot: () => profiles,
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ ensure: async () => {},
+ },
+ agentLibrary: {
+ snapshot: () => library,
+ subscribe: () => () => {},
+ refresh: async () => {},
+ retain: () => () => {},
+ },
+ };
+ const names = bindNames(source, {
+ snapshot: () => [createAgentDirectory()],
+ subscribe: () => () => {},
+ });
+ const set = (pubkey: string, name: string) => {
+ profiles = new Map([...profiles, [pubkey, { name }]]);
+ for (const listener of listeners) listener();
+ };
+ return {
+ names,
+ set,
+ publish: (pubkey: string, name: string) => act(() => set(pubkey, name)),
+ };
+}
+
+it("re-renders a names consumer only when a label it resolved changes", () => {
+ const { names, publish } = fixture();
+ let renders = 0;
+ function Author() {
+ renders++;
+ const resolve = useIdentityNames(names);
+ return {resolve(a, "Member")};
+ }
+ render();
+ expect(screen.getByText("Alex")).toBeInTheDocument();
+ const settled = renders;
+ // Another identity's profile leaves this author's label alone.
+ publish(c, "Casey");
+ expect(renders).toBe(settled);
+ // A namesake changes it.
+ publish(b, "Alex");
+ expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument();
+ publish(a, "Ana");
+ expect(screen.getByText("Ana")).toBeInTheDocument();
+ expect(renders).toBe(settled + 2);
+ names.dispose();
+});
+
+it("watches each candidate scope a key was resolved in", () => {
+ const { names, publish } = fixture();
+ const wide = [a, c];
+ const narrow = [a];
+ function Author() {
+ const resolve = useIdentityNames(names);
+ return (
+ <>
+ wide {resolve(a, "Member", wide)}
+ narrow {resolve(a, "Member", narrow)}
+ >
+ );
+ }
+ render();
+ publish(c, "Alex");
+ expect(screen.getByText(`wide Alex · ${suffix(a)}`)).toBeInTheDocument();
+ expect(screen.getByText("narrow Alex")).toBeInTheDocument();
+ names.dispose();
+});
+
+it("shows a name change made between render and subscribe", () => {
+ const { names, set } = fixture();
+ function Author() {
+ const resolve = useIdentityNames(names);
+ return {resolve(a, "Member")};
+ }
+ function Renamer() {
+ set(a, "Ana");
+ return null;
+ }
+ render(
+ <>
+
+
+ >,
+ );
+ expect(screen.getByText("Ana")).toBeInTheDocument();
+ names.dispose();
+});
+
+it("shows a name change made between two of its own labels", () => {
+ const { names, set } = fixture();
+ let renamed = false;
+ function Author() {
+ const resolve = useIdentityNames(names);
+ const first = resolve(a, "Member");
+ if (!renamed) {
+ renamed = true;
+ set(a, "Ana");
+ }
+ return (
+
+ {first} {resolve(c, "Member")}
+
+ );
+ }
+ render();
+ expect(screen.getByText("Ana Member")).toBeInTheDocument();
+ names.dispose();
+});
+
+it("follows label changes under StrictMode double rendering", () => {
+ const { names, publish } = fixture();
+ function Author() {
+ const resolve = useIdentityNames(names);
+ return {resolve(a, "Member")};
+ }
+ render(
+
+
+ ,
+ );
+ publish(b, "Alex");
+ expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument();
+ publish(a, "Ana");
+ expect(screen.getByText("Ana")).toBeInTheDocument();
+ names.dispose();
+});
+
+it("keeps watching after an older resolve callback runs", () => {
+ const { names, publish } = fixture();
+ const callbacks: ((pubkey: string, fallback: string) => string)[] = [];
+ function Author() {
+ const resolve = useIdentityNames(names);
+ callbacks.push(resolve);
+ return {resolve(a, "Member")};
+ }
+ render();
+ publish(b, "Alex");
+ expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument();
+ callbacks[0]?.(c, "Member");
+ publish(a, "Ana");
+ expect(screen.getByText("Ana")).toBeInTheDocument();
+ names.dispose();
+});
+
+it("re-renders on every name change once it resolves too many labels to watch", () => {
+ const { names, publish } = fixture();
+ const fallbacks = Array.from({ length: 501 }, (_, i) => `Member ${i}`);
+ let renders = 0;
+ function Author() {
+ renders++;
+ const resolve = useIdentityNames(names);
+ return (
+ {fallbacks.map((fallback) => resolve(a, fallback)).at(-1)}
+ );
+ }
+ render();
+ const settled = renders;
+ publish(c, "Casey");
+ expect(renders).toBe(settled + 1);
+ names.dispose();
+});
+
+it("re-renders once and starts over when a label's candidate list changes", () => {
+ const { names, publish } = fixture();
+ let renders = 0;
+ function Author({ members }: { members: readonly string[] }) {
+ renders++;
+ const resolve = useIdentityNames(names);
+ return {resolve(a, "Member", members)};
+ }
+ const view = render();
+ view.rerender();
+ const settled = renders;
+ publish(c, "Casey");
+ expect(renders).toBe(settled + 1);
+ publish(b, "Blair");
+ expect(renders).toBe(settled + 1);
+ publish(c, "Alex");
+ expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument();
+ names.dispose();
+});
diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts
index 2a602f9705..e8cbef8001 100644
--- a/src/features/identity-names/react.ts
+++ b/src/features/identity-names/react.ts
@@ -1,16 +1,20 @@
import type { NamingIdentity } from "./policy";
-import { useSyncExternalStore, useCallback } from "react";
+import { useSyncExternalStore, useCallback, useMemo } from "react";
import { useListedChannel } from "../relay/listed-channel";
import type { RelaySession } from "../relay/session";
import type { IdentityNameView } from "./service";
-const noop = () => () => {};
-const zero = () => 0;
-/** The interface arrives through the captured session, never a global lookup. */
+const noSubscribe = () => () => {};
+const watched = 500;
+/** The interface arrives through the captured session, never a global lookup.
+ * Pass stable `candidates` and `displayFacts` arrays: a new one re-renders on the next name change. */
export function useIdentityNames(names: IdentityNameView | undefined) {
- const revision = useSyncExternalStore(
- names?.subscribe ?? noop,
- names?.snapshot ?? zero,
- zero,
+ // Re-render only when a label this component resolved changes: one arriving
+ // profile should not repaint every name on screen.
+ const labels = useMemo(() => watchLabels(names), [names]);
+ const version = useSyncExternalStore(
+ names?.subscribe ?? noSubscribe,
+ labels.version,
+ labels.version,
);
return useCallback(
(
@@ -20,15 +24,73 @@ export function useIdentityNames(names: IdentityNameView | undefined) {
displayFacts?: readonly NamingIdentity[],
): string => {
// Invalidate memoized labels and completion choices on name changes.
- void revision;
- return (
- names?.resolve(pubkey, fallback, candidates, displayFacts) ?? fallback
- );
+ void version;
+ const label = names?.resolve(pubkey, fallback, candidates, displayFacts);
+ labels.show(version, [pubkey, fallback, candidates, displayFacts], label);
+ return label ?? fallback;
},
- [names, revision],
+ [names, labels, version],
);
}
+/** For callers that read `names.lookup` or `names.scope` directly, which
+ * `useIdentityNames` does not watch: re-render on every name change while active. */
+export function useNamesRevision(
+ names: IdentityNameView | undefined,
+ active: boolean,
+) {
+ return useSyncExternalStore(
+ (active && names?.subscribe) || noSubscribe,
+ () => names?.snapshot() ?? 0,
+ );
+}
+
+/** The labels one consumer rendered at its current version. The version moves
+ * when a name change would alter one of them, or they could not all be watched. */
+function watchLabels(names: IdentityNameView | undefined) {
+ type Args = Parameters;
+ const shown = new Map();
+ let overflow = false;
+ let version = 0;
+ let recorded = 0;
+ let seen = names?.snapshot();
+ return {
+ show(rendered: number, args: Args, label: string | undefined) {
+ // The first label rendered at a newer version starts a new record; a
+ // callback kept from an older render adds to the current one.
+ if (rendered > recorded) {
+ shown.clear();
+ overflow = false;
+ recorded = rendered;
+ }
+ if (!names || overflow) return;
+ const [pubkey, fallback, candidates, displayFacts] = args;
+ const key = `${pubkey}\n${fallback}`;
+ const entry = shown.get(key);
+ // One name in two scopes, such as after a member joins, would grow the
+ // record with every new list: re-render on the next change instead.
+ if (entry)
+ overflow ||=
+ entry.args[2] !== candidates || entry.args[3] !== displayFacts;
+ else if (shown.size < watched) shown.set(key, { args, label });
+ else overflow = true;
+ },
+ version() {
+ const revision = names?.snapshot();
+ if (revision === seen) return version;
+ seen = revision;
+ if (
+ overflow ||
+ [...shown.values()].some(
+ ({ args, label }) => names?.resolve(...args) !== label,
+ )
+ )
+ version++;
+ return version;
+ },
+ };
+}
+
const noMembers: readonly string[] = [];
/** Channel membership defines ambiguity, not the community-wide profile cache. */
export function useChannelIdentityNames(
diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx
index 628ccc7e87..042974ec61 100644
--- a/src/features/messages/RichComposerInput.tsx
+++ b/src/features/messages/RichComposerInput.tsx
@@ -4,7 +4,7 @@ import { useContext, type ReactNode } from "react";
import { DraftMentionRoster } from "./draft-mention-roster";
import { archivedMention } from "./mention-admission";
import { useLayoutEffect, useMemo, useRef } from "react";
-import { useIdentityNames } from "../identity-names/react";
+import { useNamesRevision } from "../identity-names/react";
import { useListedChannel } from "../relay/listed-channel";
import { InlineChip } from "../../shared/design-system/ui/InlineChip";
import type { ConversationExtensions } from "../conversation/contracts";
@@ -49,14 +49,16 @@ export function RichComposerInput({
);
const archives = useMentionArchives(session);
const roster = useContext(DraftMentionRoster)?.recipients;
- const resolveName = useIdentityNames(session.names);
// Recipients, not keystrokes, change chip names: typing prose must not
// copy every profile or scan every member of a large channel.
const recipientKey = JSON.stringify(
draft.recipients.map(({ pubkey, name }) => [pubkey, name]),
);
+ // Chip labels and facts come from names.lookup.
+ const names = useNamesRevision(session.names, recipientKey !== "[]");
const { profiles, qualifiers, labels } = useMemo(() => {
void archives;
+ void names;
const recipients = JSON.parse(recipientKey) as [string, string][];
const profiles = new Map(directory.profiles);
for (const [pubkey, name] of recipients)
@@ -91,7 +93,7 @@ export function RichComposerInput({
labels: new Map(
recipients.map(([pubkey, name]) => [
pubkey,
- resolveName(pubkey, name, candidates, displayFacts),
+ session.names?.lookup(pubkey, candidates, displayFacts)?.name ?? name,
]),
),
};
@@ -102,7 +104,7 @@ export function RichComposerInput({
roster,
members,
archives,
- resolveName,
+ names,
]);
const previous = useRef(labels);
const revealed = useRef(