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
25 changes: 25 additions & 0 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,28 @@ channels; `sidebar-unread.spec.mjs` holds unread evidence through real EOSE and
checks its badges without retries. Access-loss/disconnect cancellation is unchanged.
Broad scan and native build/package acceptance remain deferred to an agreed
integration batch. No sending/signing behavior changed.

## Info, channels and linked instances

The profile's Channels tab uses the current viewer's `session.channels`
list and exact `ChannelSummary.members` from relay-authored rosters. It excludes
archived, hidden, DM and session conversations. Only positively identified stream
and forum rows render; when a matching roster lacks a recognized type, that
membership is unclassified and omitted from the channel rows. Ready empty copy
qualifies this uncertainty for the viewed identity, including partial lists.
Verified classified rows remain visible
alongside a transient list error; loading/error/partial discovery is not an
empty membership claim; retry uses `refreshList`. Rows navigate using the existing
scoped conversation destination when a valid community scope and navigation
capability exist, otherwise remain plain text.

On native hosts, linked instances use `agentControl`'s exact managed `pubkey` and
normalized `relayUrl` for the active community. The subsection is absent without
native control or a valid community scope. Agent identity hints gate loading and
visibility, never ownership; exact native matches alone supply instance rows. The
section stays hidden for a non-agent without a match. It never derives ownership
from the old Buzz library, self-declared profile markers, or names. The Agents
page route opens management, not a per-instance page.

The Info tab keeps the public key and linked instances; the Channels tab is read-only.
Neither list is a cross-community/global directory.
284 changes: 284 additions & 0 deletions src/bundled/profiles/ProfileChannels.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { Navigation } from "../../features/navigation/controller";
import type {
ChannelList,
ChannelQueries,
} from "../../features/relay/contracts";
import type { RelaySession } from "../../features/relay/session";
import { ProfileChannels } from "./ProfileChannels";

const person = "a".repeat(64);
const viewer = "b".repeat(64);
function fixture() {
let list: ChannelList = { status: "loading", channels: [] };
const listeners = new Set<() => void>();
const refreshList = vi.fn();
const ensureList = vi.fn();
const channels = {
list: () => list,
subscribeList(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
ensureList,
refreshList,
} as unknown as ChannelQueries;
const open = vi.fn(async () => ({ status: "opened" as const }));
const navigation = { open } as unknown as Navigation;
const session = { channels } as RelaySession;
const update = (next: ChannelList) =>
act(() => {
list = next;
for (const listener of listeners) listener();
});
return { session, navigation, open, update, refreshList, ensureList };
}
afterEach(cleanup);

it("shows only exact verified visible memberships, handles partial lists and opens the scoped conversation", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
/>,
);
expect(screen.getByRole("status").textContent).toContain("Loading channels");
expect(f.ensureList).toHaveBeenCalled();
f.update({
status: "ready",
coverage: "partial",
channels: [
{
id: "exact",
name: "Visible",
channelType: "stream",
members: [person, viewer],
},
{ id: "forum", name: "Forum", channelType: "forum", members: [person] },
{ id: "other", name: "Unrelated", members: [viewer] },
{ id: "unknown", name: "Unknown" },
{
id: "hidden",
name: "Hidden",
channelType: "stream",
hidden: true,
members: [person],
},
{ id: "untyped-member", name: "Unknown type", members: [person] },
{ id: "untyped-unrelated", name: "Unrelated type", members: [viewer] },
{ id: "dm", name: "Direct", channelType: "dm", members: [person] },
{
id: "session",
name: "Child",
channelType: "session",
members: [person],
},
{ id: "archived", name: "Archived", archived: true, members: [person] },
],
});
expect(screen.getByRole("button", { name: "#Visible" })).toBeTruthy();
expect(screen.getByRole("button", { name: "#Forum" })).toBeTruthy();
expect(
screen.getByText(/Some memberships for this identity are unclassified/),
).toBeTruthy();
expect(
screen.queryByText(/Unrelated|Unknown type|Hidden|Archived|Direct|Child/),
).toBeNull();
expect(screen.getByText(/More channels may exist/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "#Visible" }));
expect(f.open).toHaveBeenCalledWith({
version: 1,
kind: "conversation",
channelId: "exact",
scope: { viewer, communityOrigin: "https://relay.example.test" },
});
f.update({ status: "ready", channels: [] });
expect(
screen.getByText(/No visible channels with verified membership/),
).toBeTruthy();
});

it("does not invent a route, and retries failed discovery without claiming a complete empty result", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin={undefined}
navigation={f.navigation}
/>,
);
f.update({
status: "error",
channels: [
{ id: "known", name: "Known", channelType: "stream", members: [person] },
],
});
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Known")).toBeTruthy();
expect(screen.queryByRole("button", { name: /Known/ })).toBeNull();
expect(screen.getByText(/Channel navigation is unavailable/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Retry channels" }));
expect(f.refreshList).toHaveBeenCalledOnce();
expect(f.open).not.toHaveBeenCalled();
});

it("renders a verified row without a destination when navigation is unavailable", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={undefined}
/>,
);
f.update({
status: "ready",
channels: [
{ id: "known", name: "Known", channelType: "stream", members: [person] },
],
});
expect(screen.getByText("Known")).toBeTruthy();
expect(screen.queryByRole("button", { name: /Known/ })).toBeNull();
expect(screen.getByText(/Channel navigation is unavailable/)).toBeTruthy();
});

it("keeps classified roster rows but omits unclassified conversations after metadata failure", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
/>,
);
f.update({
status: "error",
channels: [
{ id: "known", name: "Known", channelType: "forum", members: [person] },
{ id: "untyped", name: "untyped", members: [person] },
{
id: "hidden",
name: "Hidden",
channelType: "stream",
hidden: true,
members: [person],
},
{ id: "dm", name: "Direct", channelType: "dm", members: [person] },
{
id: "session",
name: "Child",
channelType: "session",
members: [person],
},
],
});
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByRole("button", { name: "#Known" })).toBeTruthy();
expect(
screen.getByText(/Some memberships for this identity are unclassified/),
).toBeTruthy();
expect(screen.queryByText(/untyped|Hidden|Direct|Child/)).toBeNull();
});

it("qualifies ready empty results when only matching unclassified memberships exist", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
/>,
);
f.update({
status: "ready",
channels: [{ id: "unknown", name: "Unknown", members: [person] }],
});
expect(screen.queryByRole("button", { name: /Unknown/ })).toBeNull();
expect(
screen.getByText(/No matching channels with a known visible type/),
).toBeTruthy();
expect(
screen.queryByText(/No visible channels with verified membership/),
).toBeNull();
f.update({
status: "ready",
coverage: "partial",
channels: [{ id: "unknown", name: "Unknown", members: [person] }],
});
expect(
screen.getByText(/More channels may exist outside the loaded list/),
).toBeTruthy();
expect(
screen.queryByText(/No visible channels with verified membership/),
).toBeNull();
});

it("does not classify hidden, archived or DM memberships in a ready empty list", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
/>,
);
f.update({
status: "ready",
channels: [
{ id: "dm", name: "Direct", channelType: "dm", members: [person] },
{ id: "hidden", name: "Hidden", hidden: true, members: [person] },
{ id: "archived", name: "Archived", archived: true, members: [person] },
],
});
expect(screen.queryByText(/unclassified and omitted/)).toBeNull();
expect(
screen.getByText(/No visible channels with verified membership/),
).toBeTruthy();
expect(
screen.queryByText(/No matching channels with a known visible type/),
).toBeNull();
expect(f.open).not.toHaveBeenCalled();
});

it("does not show a metadata caveat for another identity's unclassified roster", () => {
const f = fixture();
render(
<ProfileChannels
session={f.session}
pubkey={person}
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
/>,
);
f.update({
status: "ready",
channels: [{ id: "unknown", name: "Unknown", members: [viewer] }],
});
expect(screen.queryByText(/unclassified and omitted/)).toBeNull();
expect(
screen.getByText(/No visible channels with verified membership/),
).toBeTruthy();
});
Loading
Loading