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(