Skip to content
3 changes: 2 additions & 1 deletion src/bundled/agents/AgentShareRecipients.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
// Who Share sends an agent to: people by direct message, or a channel the
// viewer belongs to, found with one search as Share access does.
import { useSyncExternalStore, type Ref } from "react";
Expand Down Expand Up @@ -70,7 +71,7 @@ export function AgentShareRecipients({
!item.cached &&
!item.archived &&
["stream", "forum"].includes(item.channelType ?? "") &&
item.members?.includes(session.viewer ?? "") &&
viewerMember(item, session.viewer) &&
!picked.has(item.id) &&
item.name.toLowerCase().includes(needle),
)
Expand Down
5 changes: 2 additions & 3 deletions src/bundled/bestie/BestieConversation.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import {
useCallback,
useEffect,
Expand Down Expand Up @@ -555,9 +556,7 @@ export function BestieConversation({
current.cached ||
(current.readOnly &&
current.viewerRole !== "guest") ||
!current.members?.includes(
session.viewer ?? "",
) ||
!viewerMember(current, session.viewer) ||
!host.panels.snapshot().includes(panel) ||
!panel.channelMenu?.eligible(current, session)
)
Expand Down
110 changes: 77 additions & 33 deletions src/bundled/bestie/PinnedBestie.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,15 @@ async function fixture({
owner.dispose();
});
const send = vi.fn<typeof owner.session.messages.send>(() => "d".repeat(64));
let channelListOverride:
| ReturnType<typeof owner.session.channels.list>
| undefined;
const session = {
...owner.session,
channels: {
...owner.session.channels,
list: () => channelListOverride ?? owner.session.channels.list(),
},
agentChoices,
messages: { ...owner.session.messages, send },
};
Expand Down Expand Up @@ -346,6 +353,9 @@ async function fixture({
find: () => undefined,
} as unknown as Agents;
return {
setChannelList(next: ReturnType<typeof owner.session.channels.list>) {
channelListOverride = next;
},
setPanels(next: readonly RegisteredPanel[]) {
panels = next;
for (const listener of panelListeners) listener();
Expand Down Expand Up @@ -1059,36 +1069,70 @@ it("offers the shared DM actions and channel details in Bestie", async () => {
).toBeInTheDocument();
});

it("opens eligible plugin menu items in the Bestie workspace", async () => {
const f = await fixture({ existing: true });
f.choose(pollen);
const user = userEvent.setup();
f.setPanels([
{
id: "canvas",
key: "test/canvas",
pluginId: "test",
revision: "1",
title: "Canvas",
matches: () => false,
channelMenu: {
label: "View canvas",
eligible: (channel) => channel.id === pollenDM,
},
component: ({ channelContext }) => (
<p>Canvas for {channelContext?.channelId}</p>
),
} satisfies RegisteredPanel,
]);
f.mount();
await user.click(
await screen.findByRole("button", { name: "Channel actions" }),
);
await user.click(
await screen.findByRole("menuitem", { name: "View canvas" }),
);
expect(await screen.findByText(`Canvas for ${pollenDM}`)).toBeVisible();
expect(
screen.getByRole("textbox", { name: "Message Pollen" }),
).toBeInTheDocument();
});
it.each([
"legacy roster",
"joined without peers",
"denied with retained peers",
])(
"opens eligible Bestie menu panels using viewer authority: %s",
async (membership) => {
const f = await fixture({ existing: true });
f.choose(pollen);
const user = userEvent.setup();
f.setPanels([
{
id: "canvas",
key: "test/canvas",
pluginId: "test",
revision: "1",
title: "Canvas",
matches: () => false,
channelMenu: {
label: "View canvas",
eligible: (channel) => channel.id === pollenDM,
},
component: ({ channelContext }) => (
<p>Canvas for {channelContext?.channelId}</p>
),
} satisfies RegisteredPanel,
]);
f.mount();
await user.click(
await screen.findByRole("button", { name: "Channel actions" }),
);
const action = await screen.findByRole("menuitem", { name: "View canvas" });
// Change only the authoritative read at click time. The rendered menu is
// already open, so denial must be rechecked by the Bestie handler itself.
const list = f.session.channels.list();
const current = list.channels.find((channel) => channel.id === pollenDM);
if (!current) throw new Error("Fixture needs the selected DM");
if (membership !== "legacy roster") {
const { members: _peers, ...withoutPeers } = current;
const updated =
membership === "joined without peers"
? {
...withoutPeers,
viewerMembership: { pubkey: f.viewer, joined: true },
}
: {
...current,
viewerMembership: { pubkey: f.viewer, joined: false },
};
f.setChannelList({
...list,
channels: list.channels.map((channel) =>
channel.id === pollenDM ? updated : channel,
),
});
}
await user.click(action);
if (membership === "denied with retained peers") {
expect(screen.queryByText(`Canvas for ${pollenDM}`)).toBeNull();
} else {
expect(await screen.findByText(`Canvas for ${pollenDM}`)).toBeVisible();
}
expect(
screen.getByRole("textbox", { name: "Message Pollen" }),
).toBeInTheDocument();
},
);
3 changes: 2 additions & 1 deletion src/bundled/bestie/PrivateChannels.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { administerMember } from "../../features/channel-members/administration";
import { canAddMembers } from "../../features/channel-members/members";
Expand Down Expand Up @@ -32,7 +33,7 @@ export function privateChannels(
!channel.huddle &&
(channel.channelType === "stream" || channel.channelType === "forum") &&
channel.private &&
channel.members?.includes(viewer),
viewerMember(channel, viewer),
)
.sort(compareRecent);
return {
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/bestie/chat.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import type { ChannelSummary } from "../../features/relay/contracts";

/** The verified one-to-one DM with this Bestie. Solo chats stay separate. */
Expand All @@ -13,7 +14,7 @@ export function bestieChat(
channel.channelType === "dm" &&
!channel.cached &&
!channel.archived &&
channel.members?.includes(viewer) &&
viewerMember(channel, viewer) &&
onlyOther(channel.members, viewer) === bestie,
)
.sort(
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/ChannelCompletion.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import { useContext, useLayoutEffect, useSyncExternalStore } from "react";
import { channelIcon } from "../../features/channels/channel-icon";
import { usePublicChannelSearch } from "../../features/channels/usePublicChannelSearch";
Expand Down Expand Up @@ -38,7 +39,7 @@ function joined(channel: ChannelSummary, viewer: string | undefined) {
!!viewer &&
referable(channel) &&
(!channel.readOnly || channel.viewerRole === "guest" || !!channel.cached) &&
!!channel.members?.includes(viewer)
viewerMember(channel, viewer)
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import { useChannelHistoryFocus } from "./useChannelHistoryFocus";
import type { Conversation } from "../../features/conversation/service";
import { StartLinkedChat, canStartLinkedChat } from "./StartLinkedChat";
Expand Down Expand Up @@ -1802,7 +1803,7 @@ function ChannelWorkspace({
!item.cached &&
!item.readOnly &&
!item.archived &&
item.members?.includes(queries.viewer ?? ""),
viewerMember(item, queries.viewer),
);
if (linkedChat && linkedChat.id !== current?.id) {
const id = `conversation:${linkedChat.id}`;
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/StartLinkedChat.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import { ChatCircleIcon } from "../../shared/design-system/icons";
import { useState } from "react";
import type { ChannelMessage } from "../../features/relay/contracts";
Expand Down Expand Up @@ -25,7 +26,7 @@ export function canStartLinkedChat(session: RelaySession, channelId: string) {
!channel.archived &&
!channel.readOnly &&
["stream", "forum"].includes(channel.channelType ?? "") &&
channel.members?.includes(session.viewer) &&
viewerMember(channel, session.viewer) &&
session.channelWrites.available
);
}
Expand Down
14 changes: 14 additions & 0 deletions src/bundled/channels/canvas-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ it("lets a joined Guest read the canvas and reacts to promotion and demotion", a
const panel = panels.snapshot()[0];
assert.exists(panel);
expect(panel?.channelMenu?.eligible(channel, session)).toBe(true);
const { members: _peers, ...withoutPeers } = channel;
expect(
panel?.channelMenu?.eligible(
{ ...withoutPeers, viewerMembership: { pubkey: viewer, joined: true } },
session,
),
).toBe(true);
expect(
panel?.channelMenu?.eligible(
{ ...channel, viewerMembership: { pubkey: viewer, joined: false } },
session,
),
).toBe(false);

expect(
panel?.channelMenu?.eligible({ ...channel, cached: true }, session),
).toBe(false);
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/canvas-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import { useSyncExternalStore } from "react";
import type { Context } from "@deepseek-ai/cordis";
import { useListedChannel } from "../../features/relay/listed-channel";
Expand All @@ -14,7 +15,7 @@ function eligible(channel: ChannelSummary, session: RelaySession) {
return (
!channel.cached &&
(!channel.readOnly || channel.viewerRole === "guest") &&
!!channel.members?.includes(session.viewer ?? "")
viewerMember(channel, session.viewer)
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/bundled/channels/useConversationTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import {
useCallback,
useEffect,
Expand Down Expand Up @@ -729,8 +730,7 @@ export function useConversationTabs({
!target.readOnly &&
!target.archived &&
!target.cached &&
(target.channelType !== "solo" ||
!!target.members?.includes(queries.viewer ?? ""))
(target.channelType !== "solo" || viewerMember(target, queries.viewer))
);
}
// The currently resolved channel can be a verified public/archived preview
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/useSoloShare.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import { useProfileGeneration } from "../../features/relay/react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { RelaySession } from "../../features/relay/session";
Expand Down Expand Up @@ -156,7 +157,7 @@ export function useSoloShare({
!item.cached &&
!item.archived &&
["stream", "forum"].includes(item.channelType ?? "") &&
item.members?.includes(session.viewer ?? "") &&
viewerMember(item, session.viewer) &&
item.name
.toLowerCase()
.includes(query.trim().toLowerCase().replace(/^#/, "")),
Expand Down
5 changes: 3 additions & 2 deletions src/bundled/inbox/InboxPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../../features/relay/membership";
import {
type ReactNode,
useCallback,
Expand Down Expand Up @@ -855,8 +856,8 @@ function InboxList({
(!current.readOnly || current.viewerRole === "guest") &&
!current.archived &&
current.channelType === channel.channelType &&
!!current.members?.includes(scope.viewer) &&
[...current.members].sort().join(":") === membership &&
viewerMember(current, scope.viewer) &&
current.members?.slice().sort().join(":") === membership &&
session.unread.generation() === generation &&
session.unread
.inbox()
Expand Down
44 changes: 44 additions & 0 deletions src/features/agents/AgentConnectionSetup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,50 @@ it.each([
},
);

it.each(["stream", "forum"] as const)(
"tracks explicit viewer membership for %s setup without a parent render",
async (channelType) => {
const f = await ownedAgentChannel(channelType, {
choicesReady: true,
listed: true,
});
render(f.setup());
f.deliverMention();
await screen.findByRole("region", { name: "Agent connection setup" });
act(() => {
f.state.list = {
status: "ready",
channels: [
{
...f.channel,
viewerMembership: { pubkey: f.viewer, joined: false },
},
],
};
f.notify("list");
});
expect(
screen.queryByRole("region", { name: "Agent connection setup" }),
).toBeNull();
act(() => {
f.state.list = {
status: "ready",
channels: [
{
...f.channel,
members: [candidate.pubkey],
viewerMembership: { pubkey: f.viewer, joined: true },
},
],
};
f.notify("list");
});
expect(
await screen.findByRole("region", { name: "Agent connection setup" }),
).toBeVisible();
},
);

it("keeps dismissal disabled while an explicit import is pending", async () => {
let finish!: (result: { notes: string[] }) => void;
const port = vi.fn(
Expand Down
8 changes: 6 additions & 2 deletions src/features/agents/AgentConnectionSetup.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewerMember } from "../relay/membership";
import {
useCallback,
useContext,
Expand Down Expand Up @@ -86,6 +87,9 @@ export function AgentConnectionSetup({
channel?.id,
(listed) => listed?.members,
) ?? channel?.members;
const joined = useListedChannel(session.channels, channel?.id, (listed) =>
viewerMember(listed ?? channel, session.viewer),
);
const profiles = useSyncExternalStore(
session.profiles.subscribe,
session.profiles.snapshot,
Expand Down Expand Up @@ -158,7 +162,7 @@ export function AgentConnectionSetup({
channel &&
(channel.channelType === "stream" || channel.channelType === "forum") &&
session.viewer &&
roster?.includes(session.viewer) &&
joined &&
!channel.archived &&
!channel.readOnly &&
!channel.cached &&
Expand All @@ -174,7 +178,7 @@ export function AgentConnectionSetup({
),
]
// Only a current member can reply here.
.filter(([key]) => isAgent(key) && roster.includes(key))
.filter(([key]) => isAgent(key) && roster?.includes(key))
.map(([mentioned, message]) => (
<Candidate
key={`${session.scope}:${channel.id}:${mentioned}`}
Expand Down
Loading
Loading