diff --git a/docs/profiles.md b/docs/profiles.md index dfb828311..8b92d5566 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -66,16 +66,27 @@ Remove from outbox does not revoke an invitation already dispatched to the relay prose, drafts, delivery or recipient intent. Re-enable does not reopen it. - Profile enrichment in channels and threads remains a background batch. The panel fetches only its selected missing key, with explicit failure/missing retry. - Community/session replacement disposes the old view. About metadata stays in - the existing bounded directory and follows about-only replacements/removal. + Community/session replacement disposes the old view. About and self-declared + NIP-05 metadata stay in the existing bounded directory and follow + replacements/removal. - A profile replaces the existing thread/object slot, not a second parallel dock. Close/Escape returns focus to the original control, falling back to the stable originating thread control if opening the profile unmounted the thread. ## UI and iteration -Avatar, name, about, exact copyable npub, and an optional compact activity preview/action. Shared design-system Avatar and -Button use the host-loaded styles directly. The profile content marks its +Avatar, name, about, self-declared NIP-05 (not DNS-verified), exact copyable +npub, and an optional compact activity preview/action. **Message** is offered for a +foreign profile when the session can start direct messages; as in New message, +a known agent qualifies only while this community's ready native control manages +it, rechecked on click. It opens (or reopens) the one-to-one DM through the session's direct-message operation and +navigates to the relay-confirmed channel in the scope captured at click time. A +confirmed open also clears that DM's local sidebar hide. The person's +self-published NIP-38 `general` status (kind 30315, emoji tag plus text) is read +once when the profile opens; it is not live-updated, ignores NIP-40 expiry, and +renders custom `:shortcode:` emoji as plain text. Setting your own status is not +supported. Shared design-system +Avatar and Button use the host-loaded styles directly. The profile content marks its `data-buzz-ui` boundary and uses shared heading/body/mono roles; its stylesheet owns layout, not component overrides. No new theme owner, second global reset or shell migration. Designers own later refinement. diff --git a/src/bundled/channels/useHiddenDms.test.tsx b/src/bundled/channels/useHiddenDms.test.tsx index 9152abb39..8cc390218 100644 --- a/src/bundled/channels/useHiddenDms.test.tsx +++ b/src/bundled/channels/useHiddenDms.test.tsx @@ -30,6 +30,7 @@ function fixture() { NonNullable["observeSend"] >[0]; const outgoing = new Set(); + const opened = new Set<(id: string) => void>(); let onRead = () => {}; let nextRead: Promise | undefined; let historyIds = ["before"]; @@ -52,6 +53,12 @@ function fixture() { }), }, read, + directMessages: { + subscribeOpened(listener: (id: string) => void) { + opened.add(listener); + return () => opened.delete(listener); + }, + }, outbox: { observeSend(listener: ObserveSend) { outgoing.add(listener); @@ -110,6 +117,9 @@ function fixture() { }, ]); }, + open(channelId: string) { + for (const listener of opened) listener(channelId); + }, deliver(channelId: string) { for (const listener of outgoing) listener( @@ -439,3 +449,17 @@ it("does not restore a DM hidden again after an older send began", async () => { act(() => finish?.([])); expect(view.result.current.hiddenIds.has("dm")).toBe(true); }); + +it("restores a hidden DM when it is opened again", () => { + const h = fixture(); + const view = renderHook(() => + useHiddenDms("community:alice", h.session, h.list), + ); + act(() => view.result.current.hide("dm")); + expect(view.result.current.hiddenIds.has("dm")).toBe(true); + act(() => h.open("other")); + expect(view.result.current.hiddenIds.has("dm")).toBe(true); + act(() => h.open("dm")); + expect(view.result.current.hiddenIds.has("dm")).toBe(false); + expect(readView("community:alice", "hidden-dms", [])).toEqual([]); +}); diff --git a/src/bundled/channels/useHiddenDms.ts b/src/bundled/channels/useHiddenDms.ts index d5afed233..f26afd1f2 100644 --- a/src/bundled/channels/useHiddenDms.ts +++ b/src/bundled/channels/useHiddenDms.ts @@ -88,6 +88,10 @@ export function useHiddenDms( }, [update], ); + useEffect( + () => session.directMessages.subscribeOpened((id) => show([id])), + [session, show], + ); const hide = useCallback( (id: string) => { const latest = session.unread.snapshot({ diff --git a/src/bundled/profiles/ProfilePanel.message.test.tsx b/src/bundled/profiles/ProfilePanel.message.test.tsx new file mode 100644 index 000000000..371896354 --- /dev/null +++ b/src/bundled/profiles/ProfilePanel.message.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { Navigation } from "../../features/navigation/controller"; +import { createRelaySession } from "../../features/relay/session"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import { profileTarget } from "../../features/profiles/target"; +import { ProfilePanel } from "./ProfilePanel"; + +const viewer = "b".repeat(64); +const person = "a".repeat(64); +const other = "c".repeat(64); +const origin = "https://relay.example.test"; +afterEach(cleanup); + +function fixture(available = true) { + const owner = createRelaySession(null); + const open = + vi.fn<(keys: readonly string[], signal: AbortSignal) => Promise>(); + let choices = owner.session.agentChoices.snapshot(); + const choiceListeners = new Set<() => void>(); + const session = { + ...owner.session, + directMessages: { ...owner.session.directMessages, available, open }, + agentChoices: { + ...owner.session.agentChoices, + snapshot: () => choices, + subscribe(listener: () => void) { + choiceListeners.add(listener); + return () => choiceListeners.delete(listener); + }, + }, + }; + /** Makes `person` a known agent; `managed` means ready native control here. */ + const agent = (managed: boolean, notify = true) => { + choices = { + ...choices, + status: "ready", + identities: [{ pubkey: person, name: "Agent", managed }], + }; + if (notify) + act(() => { + for (const listener of choiceListeners) listener(); + }); + }; + let snapshot: RelaySnapshot = { + status: "ready", + generation: 1, + scope: `${origin}:${viewer}`, + viewer, + session, + }; + const listeners = new Set<() => void>(); + const relay: RelayData = { + snapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const navigate = vi.fn(async () => ({ status: "opened" as const })); + const navigation = { open: navigate } as unknown as Navigation; + const panel = (pubkey: string) => ( + {}} + /> + ); + const reconnect = (scope: string) => + act(() => { + snapshot = { ...snapshot, generation: 2, scope }; + for (const listener of listeners) listener(); + }); + return { owner, open, navigate, panel, reconnect, agent }; +} +function deferred() { + let resolve!: (id: string) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + return { promise, resolve, reject }; +} +const message = () => screen.queryByRole("button", { name: "Message" }); + +it("offers Message only for a foreign profile on a DM-capable connection", () => { + const unavailable = fixture(false); + const view = render(unavailable.panel(person)); + expect(message()).toBeNull(); + view.unmount(); + unavailable.owner.dispose(); + const f = fixture(); + const next = render(f.panel(viewer)); + expect(message()).toBeNull(); + next.rerender(f.panel(person)); + expect(message()).toBeTruthy(); + f.owner.dispose(); +}); + +it("offers Message to a known agent only under this community's native control", () => { + const f = fixture(); + f.agent(false); + const view = render(f.panel(person)); + expect(message()).toBeNull(); + f.agent(true); + expect(message()).toBeTruthy(); + f.agent(false); + expect(message()).toBeNull(); + view.unmount(); + f.owner.dispose(); +}); + +it("rechecks agent control on activation", async () => { + const f = fixture(); + f.agent(true); + f.open.mockResolvedValue("dm-1"); + render(f.panel(person)); + f.agent(false, false); + fireEvent.click(message() as HTMLElement); + expect(f.open).not.toHaveBeenCalled(); + f.agent(true, false); + fireEvent.click(message() as HTMLElement); + expect(f.open).toHaveBeenCalledWith([person], expect.any(AbortSignal)); + await waitFor(() => expect(f.navigate).toHaveBeenCalledOnce()); + f.owner.dispose(); +}); + +it("opens the DM, shows pending state, then navigates in the captured scope", async () => { + const f = fixture(); + const opening = deferred(); + f.open.mockReturnValueOnce(opening.promise); + render(f.panel(person)); + fireEvent.click(message() as HTMLElement); + expect(f.open).toHaveBeenCalledWith([person], expect.any(AbortSignal)); + expect(message()?.getAttribute("aria-busy")).toBe("true"); + fireEvent.click(message() as HTMLElement); + expect(f.open).toHaveBeenCalledOnce(); + await act(async () => opening.resolve("dm-1")); + expect(f.navigate).toHaveBeenCalledWith({ + version: 1, + kind: "conversation", + channelId: "dm-1", + scope: { viewer, communityOrigin: origin }, + }); + expect(message()?.getAttribute("aria-busy")).not.toBe("true"); + f.owner.dispose(); +}); + +it("shows a failure and retries", async () => { + const f = fixture(); + f.open.mockRejectedValueOnce(new Error("Relay refused the DM.")); + render(f.panel(person)); + fireEvent.click(message() as HTMLElement); + expect(await screen.findByText("Relay refused the DM.")).toBeTruthy(); + expect(f.navigate).not.toHaveBeenCalled(); + f.open.mockResolvedValueOnce("dm-1"); + fireEvent.click(message() as HTMLElement); + await waitFor(() => expect(f.navigate).toHaveBeenCalledOnce()); + expect(screen.queryByText("Relay refused the DM.")).toBeNull(); + f.owner.dispose(); +}); + +it.each([ + [ + "target", + (f: ReturnType, view: ReturnType) => + view.rerender(f.panel(other)), + ], + [ + "community", + (f: ReturnType) => + f.reconnect(`https://other.example.test:${viewer}`), + ], + [ + "unmount", + (_f: ReturnType, view: ReturnType) => + view.unmount(), + ], +])("aborts and ignores a late open after %s change", async (_name, change) => { + const f = fixture(); + const opening = deferred(); + f.open.mockReturnValueOnce(opening.promise); + const view = render(f.panel(person)); + fireEvent.click(message() as HTMLElement); + const signal = f.open.mock.calls[0]?.[1]; + change(f, view); + expect(signal?.aborted).toBe(true); + await act(async () => opening.resolve("dm-1")); + expect(f.navigate).not.toHaveBeenCalled(); + f.owner.dispose(); +}); diff --git a/src/bundled/profiles/ProfilePanel.status.test.tsx b/src/bundled/profiles/ProfilePanel.status.test.tsx new file mode 100644 index 000000000..16cfef534 --- /dev/null +++ b/src/bundled/profiles/ProfilePanel.status.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ReadFilter, RelayEvent } from "../../features/relay/events"; +import { createRelaySession } from "../../features/relay/session"; +import type { RelayData } from "../../features/relay/service"; +import { keypair, signed, type Key } from "../../features/relay/testing"; +import { profileTarget } from "../../features/profiles/target"; +import { ProfilePanel } from "./ProfilePanel"; +import styles from "./Profiles.module.css"; + +afterEach(cleanup); +const viewer = keypair(); +const relayAuthor = keypair(); +const status = ( + key: Key, + content: string, + created_at: number, + tags: string[][] = [], +) => + signed(key, { + kind: 30315, + content, + created_at, + tags: [["d", "general"], ...tags], + }); + +function fixture( + statuses: (filter: ReadFilter) => Promise, +) { + const reads: ReadFilter[] = []; + const owner = createRelaySession({ + viewer: viewer.pubkey, + relayAuthor: relayAuthor.pubkey, + scope: "https://relay.example.test", + media: () => undefined, + query: async (filters) => { + const filter = filters.find((item) => item.kinds?.includes(30315)); + if (!filter) return []; + reads.push(filter); + return [...(await statuses(filter))]; + }, + }); + const snapshot = { + status: "ready" as const, + generation: 1, + scope: `https://relay.example.test:${viewer.pubkey}`, + viewer: viewer.pubkey, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const panel = (key: Key) => ( + {}} + /> + ); + return { owner, reads, panel }; +} + +it("shows the newest self-published general status with its emoji", async () => { + const person = keypair(); + const f = fixture(async () => [ + status(person, "Old", 1), + status(person, "In a meeting", 2, [["emoji", "๐Ÿ“…"]]), + ]); + try { + render(f.panel(person)); + expect(await screen.findByText("In a meeting")).toBeTruthy(); + expect(document.querySelector(`.${styles.status}`)?.textContent).toBe( + "๐Ÿ“… In a meeting", + ); + expect(screen.getByText("๐Ÿ“…")).toBeTruthy(); + expect(screen.queryByText("Old")).toBeNull(); + expect(f.reads).toEqual([ + expect.objectContaining({ + kinds: [30315], + authors: [person.pubkey], + "#d": ["general"], + limit: 1, + }), + ]); + } finally { + f.owner.dispose(); + } +}); + +it.each([ + ["a cleared status", async (person: Key) => [status(person, " ", 2)]], + ["another author's status", async () => [status(keypair(), "Spoofed", 2)]], + [ + "a failed read", + async () => { + throw new Error("offline"); + }, + ], +])("shows no status for %s", async (_name, result) => { + const person = keypair(); + const read = vi.fn(() => result(person)); + const f = fixture(read); + try { + render(f.panel(person)); + await waitFor(() => expect(read).toHaveBeenCalled()); + await act(async () => { + await read.mock.results[0]?.value.catch(() => {}); + }); + expect(screen.queryByText("Spoofed")).toBeNull(); + expect(document.querySelector(`.${styles.status}`)).toBeNull(); + } finally { + f.owner.dispose(); + } +}); + +it("ignores a late status after switching profiles", async () => { + const first = keypair(); + const second = keypair(); + let release = () => {}; + const f = fixture(async (filter) => + filter.authors?.includes(first.pubkey) + ? new Promise((resolve) => { + release = () => resolve([status(first, "Stale", 2)]); + }) + : [status(second, "Current", 2)], + ); + try { + const view = render(f.panel(first)); + await waitFor(() => expect(f.reads).toHaveLength(1)); + view.rerender(f.panel(second)); + expect(await screen.findByText("Current")).toBeTruthy(); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.queryByText("Stale")).toBeNull(); + } finally { + f.owner.dispose(); + } +}); diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 2571dc494..1ecbbf6aa 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -89,8 +89,17 @@ function ProfileDetails({ const [copyStatus, setCopyStatus] = useState(""); const [tab, setTab] = useState<"info" | "channels">("info"); const region = useRef(null); + const messageAttempt = useRef(undefined); + const [openingMessage, setOpeningMessage] = useState(false); + const [messageError, setMessageError] = useState(""); + const [userStatus, setUserStatus] = useState<{ + text: string; + emoji: string; + }>(); useEffect(() => { region.current?.focus(); + // Target, viewer and community changes remount this view (see key above). + return () => messageAttempt.current?.abort(); }, []); // Each target/session owns this completion; shared data work remains session-owned. // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery. @@ -109,6 +118,29 @@ function ProfileDetails({ active = false; }; }, [session, pubkey, attempt]); + // One snapshot of the self-published NIP-38 status; not live-updated. + useEffect(() => { + const controller = new AbortController(); + void session + .read( + [{ kinds: [30315], authors: [pubkey], "#d": ["general"], limit: 1 }], + { signal: controller.signal }, + ) + .then( + (events) => { + if (controller.signal.aborted) return; + const latest = events + .filter((event) => event.pubkey === pubkey && event.kind === 30315) + .sort((a, b) => b.created_at - a.created_at)[0]; + const emoji = + latest?.tags.find(([name]) => name === "emoji")?.[1] ?? ""; + const text = latest?.content.trim() ?? ""; + setUserStatus(text || emoji ? { text, emoji } : undefined); + }, + () => {}, + ); + return () => controller.abort(); + }, [session, pubkey]); const agentPubkeys = useKnownAgentPubkeys(session, profiles); let communityOrigin: string | undefined; if (scope && viewer && scope.endsWith(`:${viewer}`)) { @@ -124,6 +156,56 @@ function ProfileDetails({ const picture = profile?.picture ? (session.media(profile.picture) ?? null) : null; + // As in New message, a known agent needs this community's ready native control. + const messageable = () => + !agentPubkeys.has(pubkey) || + session.agentChoices + .snapshot() + .identities.some((agent) => agent.managed && agent.pubkey === pubkey); + const canMessage = + session.directMessages.available && + !!navigation && + !!viewer && + !!communityOrigin && + viewer !== pubkey && + messageable(); + async function openMessage() { + if ( + messageAttempt.current || + !navigation || + !viewer || + !communityOrigin || + !messageable() + ) + return; + const controller = new AbortController(); + messageAttempt.current = controller; + setOpeningMessage(true); + setMessageError(""); + try { + const channelId = await session.directMessages.open( + [pubkey], + controller.signal, + ); + if (controller.signal.aborted) return; + void navigation.open({ + version: 1, + kind: "conversation", + channelId, + scope: { viewer, communityOrigin }, + }); + } catch (reason) { + if (!controller.signal.aborted) + setMessageError( + reason instanceof Error + ? reason.message + : "Could not open the conversation. Try again.", + ); + } finally { + messageAttempt.current = undefined; + if (!controller.signal.aborted) setOpeningMessage(false); + } + } return (
+ {userStatus && ( +

+ {userStatus.emoji && {userStatus.emoji}} + {userStatus.emoji && userStatus.text && " "} + {userStatus.text && {userStatus.text}} +

+ )} + {profile?.nip05 && ( +

+ NIP-05 (unverified){" "} + {profile.nip05} +

+ )} + {canMessage && ( +
+ + {messageError &&

{messageError}

} +
+ )} {profile?.about && (

{profile.about}

)} diff --git a/src/bundled/profiles/Profiles.module.css b/src/bundled/profiles/Profiles.module.css index fce7389ab..003a4bf4e 100644 --- a/src/bundled/profiles/Profiles.module.css +++ b/src/bundled/profiles/Profiles.module.css @@ -36,6 +36,13 @@ white-space: pre-wrap; overflow-wrap: anywhere; } +.status { + overflow-wrap: anywhere; +} +.identifier { + color: var(--text-secondary); + overflow-wrap: anywhere; +} .publicKey { display: flex; flex-direction: column; diff --git a/src/features/direct-messages/NewMessage.test.tsx b/src/features/direct-messages/NewMessage.test.tsx index 52eaefeb5..1a1cde015 100644 --- a/src/features/direct-messages/NewMessage.test.tsx +++ b/src/features/direct-messages/NewMessage.test.tsx @@ -64,6 +64,7 @@ function setup() { open: vi.fn(async () => channel), delivered: vi.fn(async () => {}), delivery: vi.fn(() => "failed"), + subscribeOpened: () => () => {}, }; let operations: readonly OutgoingEvent[] = []; const outboxListeners = new Set<() => void>(); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 9de5868dc..eef85728e 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -38,6 +38,8 @@ export type Profile = Readonly<{ name: string; picture?: string; about?: string; + /** Self-declared NIP-05 identifier; not proof of DNS verification. */ + nip05?: string; /** Self-declared display hint, not proof of ownership, membership or authority. */ isAgent?: true; /** Owner named by the profile auth tag; display metadata, never authorization. */ diff --git a/src/features/relay/direct-messages.test.ts b/src/features/relay/direct-messages.test.ts index 349f5cb9d..92bdc7417 100644 --- a/src/features/relay/direct-messages.test.ts +++ b/src/features/relay/direct-messages.test.ts @@ -68,11 +68,14 @@ function setup(options: { badRoster?: boolean; untrusted?: boolean } = {}) { } it("opens using signed exact membership, then confirms the regular outbox message", async () => { const t = setup(); + const opened = vi.fn(); + t.dm.subscribeOpened(opened); try { await expect( t.dm.open([t.other.pubkey], new AbortController().signal), ).resolves.toBe(id); expect(t.owner.session.channels.get?.(id)?.channelType).toBe("dm"); + expect(opened).toHaveBeenCalledExactlyOnceWith(id); let release = () => {}; t.publish.mockImplementationOnce( () => @@ -100,11 +103,14 @@ it.each([{ badRoster: true }, { untrusted: true }])( "refuses a receipt without trusted exact participants: %j", async (options) => { const t = setup(options); + const opened = vi.fn(); + t.dm.subscribeOpened(opened); try { await expect( t.dm.open([t.other.pubkey], new AbortController().signal), ).rejects.toThrow("participants"); expect(t.publish).not.toHaveBeenCalled(); + expect(opened).not.toHaveBeenCalled(); } finally { t.owner.dispose(); } diff --git a/src/features/relay/direct-messages.ts b/src/features/relay/direct-messages.ts index b33b57ee7..040b5f0db 100644 --- a/src/features/relay/direct-messages.ts +++ b/src/features/relay/direct-messages.ts @@ -15,8 +15,14 @@ export function createDirectMessages( directoryReader: RelayReader, ) { const available = !!transport?.openDirectMessage && !!outbox?.supports(9); + const opened = new Set<(id: string) => void>(); return Object.freeze({ available, + /** Observe relay-confirmed opens; opening also unhides the DM on the relay. */ + subscribeOpened(listener: (id: string) => void) { + opened.add(listener); + return () => void opened.delete(listener); + }, async people(query: string, page: number, signal: AbortSignal) { const active = AbortSignal.any([lifetime, signal]); if (query.length > 100 || !Number.isSafeInteger(page) || page < 1) @@ -92,6 +98,7 @@ export function createDirectMessages( throw new Error( "The conversationโ€™s participants could not be confirmed. Try again.", ); + for (const listener of [...opened]) listener(id); return id; }, delivery(id: string) { diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index a60b24c6f..dd273ee96 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -154,3 +154,38 @@ it("publishes owner-only changes and removal without treating profile claims as reader.dispose(); } }); + +it("reacts to NIP-05-only replacements and removal without asserting verification", () => { + const wire = scriptedTransport(user.pubkey, keypair().pubkey); + const reader = createRelayReader(wire.transport); + const directory = createProfileDirectory(reader.reader); + const selection = selectProfiles(directory.queries, [user.pubkey]); + const changed = vi.fn(); + const unsubscribe = selection.subscribe(changed); + try { + directory.accept([profile(user, { name: "Mic" }, 1)]); + const before = selection.snapshot(); + changed.mockClear(); + directory.accept([ + profile(user, { name: "Mic", nip05: " mic@example.org " }, 2), + ]); + expect(selection.snapshot()).not.toBe(before); + expect(selection.snapshot().get(user.pubkey)?.nip05).toBe( + "mic@example.org", + ); + expect(changed).toHaveBeenCalledTimes(1); + directory.accept([ + profile(user, { name: "Mic", nip05: "mic@example.org" }, 3), + ]); + expect(changed).toHaveBeenCalledTimes(1); + directory.accept([ + profile(user, { name: "Mic", nip05: { unsafe: true } }, 4), + ]); + expect(selection.snapshot().get(user.pubkey)).toEqual({ name: "Mic" }); + expect(changed).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + directory.dispose(); + reader.dispose(); + } +}); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index 9590d273b..486c84586 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -55,6 +55,7 @@ export function createProfileDirectory( old?.name === value.name && old?.picture === value.picture && old?.about === value.about && + old?.nip05 === value.nip05 && old?.isAgent === value.isAgent && old?.ownerPubkey === value.ownerPubkey ) diff --git a/src/features/relay/profiles.ts b/src/features/relay/profiles.ts index 4f398134f..08ba88900 100644 --- a/src/features/relay/profiles.ts +++ b/src/features/relay/profiles.ts @@ -30,6 +30,7 @@ export function foldProfiles( name?: unknown; picture?: unknown; about?: unknown; + nip05?: unknown; is_agent?: unknown; isAgent?: unknown; }; @@ -50,6 +51,9 @@ export function foldProfiles( ...(typeof body.about === "string" && body.about.trim() ? { about: body.about.trim() } : {}), + ...(typeof body.nip05 === "string" && body.nip05.trim() + ? { nip05: body.nip05.trim() } + : {}), ...(body.is_agent === true || body.isAgent === true ? { isAgent: true as const } : {}), diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index d149301e5..ff75ce3d8 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -547,6 +547,15 @@ export const test = base.extend({ }); return []; } + if (filter.kinds?.includes(30315)) { + expect(filter).toEqual({ + kinds: [30315], + authors: [expect.any(String)], + "#d": ["general"], + limit: 1, + }); + return []; + } if (filter.kinds?.includes(0)) return [ sign(0, [], JSON.stringify({ name: "Fixture Reader" }), userKey), diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index 2dd90b90a..56956b6e1 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -27,8 +27,8 @@ const test = base.extend({ }, secret, ); - const people = Array.from({ length: 35 }, (_, i) => { - const secret = generateSecretKey(); + const secrets = Array.from({ length: 35 }, () => generateSecretKey()); + const people = secrets.map((secret, i) => { return sign( secret, 0, @@ -217,6 +217,27 @@ const test = base.extend({ }); }, showPeople: () => releaseDirectory(), + // A shared stream where Avery has spoken, so the profile is reachable. + seedChannel: (id, text) => { + const avery = getPublicKey(secrets[0]); + events.push( + sign( + relay, + 39000, + [ + ["d", id], + ["t", "stream"], + ], + JSON.stringify({ name: "general" }), + ), + sign(relay, 39002, [ + ["d", id], + ["p", viewer], + ["p", avery], + ]), + sign(secrets[0], 9, [["h", id]], text), + ); + }, holdBackground: () => { backgroundReady = new Promise((resolve) => { releaseBackground = resolve; @@ -566,3 +587,56 @@ test("empty compose, keyboard selection, pagination, removal effects, retry, the ).toBeVisible(); expect(app.errors).toEqual([]); }); + +test("profile Message opens a fresh DM and restores a hidden one", async ({ + page, + app, +}) => { + app.seedChannel("22222222-2222-4222-8222-222222222222", "Hello from Avery"); + await page.goto(app.origin); + await page + .getByRole("navigation", { name: "Pages" }) + .getByRole("button", { name: "Messages", exact: true }) + .click(); + const sidebar = page.getByRole("complementary", { name: "Channel sidebar" }); + const general = sidebar.locator( + '[data-channel-id="22222222-2222-4222-8222-222222222222"]', + ); + const sidebarDm = sidebar.getByRole("button", { + name: "Avery Chen", + exact: true, + }); + const openProfileMessage = async () => { + await general.click(); + await page + .locator("[data-message-id]", { hasText: "Hello from Avery" }) + .getByRole("button", { name: "View Avery Chen profile" }) + .click(); + const profile = page.getByRole("complementary", { + name: "Profile", + exact: true, + }); + await profile.getByRole("button", { name: "Message", exact: true }).click(); + }; + await expect(sidebarDm).toHaveCount(0); + await openProfileMessage(); + await expect(sidebarDm).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Message #Avery Chen" }), + ).toBeVisible(); + expect(app.commands).toHaveLength(1); + expect(app.commands[0].kind).toBe(41010); + // A locally hidden DM reappears when the profile opens it again. + await sidebarDm.hover(); + await sidebar + .getByRole("button", { name: "Remove Avery Chen from DMs" }) + .click(); + await expect(sidebarDm).toHaveCount(0); + await openProfileMessage(); + await expect(sidebarDm).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Message #Avery Chen" }), + ).toBeVisible(); + expect(app.commands).toHaveLength(2); + expect(app.errors).toEqual([]); +}); diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 55fdd941a..1ba1efda1 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -98,6 +98,7 @@ test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecyc }); await mention.click(); await expect(key).toHaveText(npubs.mic); + await expect(panel.getByText("๐Ÿ“… In a meeting")).toBeVisible(); await expect(panel.locator("[data-avatar-shape]")).toHaveAttribute( "data-avatar-shape", "circle", diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx index 494b99c56..d5f2a6804 100644 --- a/tests/fixtures/profiles.tsx +++ b/tests/fixtures/profiles.tsx @@ -105,6 +105,19 @@ function session() { ], }), ]; + if (filter.kinds?.includes(30315)) + return filter.authors?.includes(mic.pubkey) + ? [ + signed(mic, { + kind: 30315, + content: "In a meeting", + tags: [ + ["d", "general"], + ["emoji", "๐Ÿ“…"], + ], + }), + ] + : []; if (filter.kinds?.includes(0)) { report.profileReads.push([...(filter.authors ?? [])]); if (