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
19 changes: 15 additions & 4 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions src/bundled/channels/useHiddenDms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function fixture() {
NonNullable<RelaySession["outbox"]>["observeSend"]
>[0];
const outgoing = new Set<ObserveSend>();
const opened = new Set<(id: string) => void>();
let onRead = () => {};
let nextRead: Promise<void> | undefined;
let historyIds = ["before"];
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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([]);
});
4 changes: 4 additions & 0 deletions src/bundled/channels/useHiddenDms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
204 changes: 204 additions & 0 deletions src/bundled/profiles/ProfilePanel.message.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>>();
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) => (
<ProfilePanel
relay={relay}
navigation={navigation}
target={profileTarget(pubkey) ?? ""}
close={() => {}}
/>
);
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<string>((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<typeof fixture>, view: ReturnType<typeof render>) =>
view.rerender(f.panel(other)),
],
[
"community",
(f: ReturnType<typeof fixture>) =>
f.reconnect(`https://other.example.test:${viewer}`),
],
[
"unmount",
(_f: ReturnType<typeof fixture>, view: ReturnType<typeof render>) =>
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();
});
Loading
Loading