From edda0c5bf66ceed0982d35a1f086711f8b5906be Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 5 Nov 2026 15:06:31 -0500 Subject: [PATCH 1/8] feat(relay): separate viewer membership from peer rosters Co-authored-by: Honor Signed-off-by: Brad Seiler --- src/bundled/agents/AgentShareRecipients.tsx | 3 +- src/bundled/bestie/PrivateChannels.tsx | 3 +- src/bundled/bestie/chat.ts | 3 +- src/bundled/channels/ChannelCompletion.tsx | 3 +- src/bundled/channels/ChannelsPage.tsx | 3 +- src/bundled/channels/StartLinkedChat.tsx | 3 +- src/bundled/channels/canvas-panel.test.tsx | 14 ++++++ src/bundled/channels/canvas-panel.tsx | 3 +- src/bundled/channels/useConversationTabs.tsx | 4 +- src/bundled/channels/useSoloShare.ts | 3 +- src/bundled/inbox/InboxPage.tsx | 3 +- src/features/agents/AgentConnectionSetup.tsx | 8 +++- src/features/channel-members/members.test.ts | 41 ++++++++++++++++- src/features/channel-members/members.ts | 5 +- src/features/huddle/composer-owner.ts | 3 +- src/features/huddle/requests.ts | 9 ++-- src/features/huddle/service.ts | 3 +- src/features/messages/MessageManagement.tsx | 3 +- .../notifications/indicator-unread.ts | 3 +- src/features/notifications/messages.ts | 3 +- src/features/relay/contracts.ts | 5 +- .../relay/discovery-permissions.test.ts | 1 + src/features/relay/discovery.ts | 1 + src/features/relay/inbox-feed.ts | 3 +- src/features/relay/membership.test.ts | 28 ++++++++++- src/features/relay/membership.ts | 19 +++++++- src/features/relay/store.ts | 5 +- .../relay/unread-invalidation.test.ts | 46 ++++++++++++++++--- src/features/relay/unread.ts | 27 +++-------- src/features/solo/labels.ts | 3 +- src/features/solo/share.ts | 5 +- 31 files changed, 206 insertions(+), 60 deletions(-) diff --git a/src/bundled/agents/AgentShareRecipients.tsx b/src/bundled/agents/AgentShareRecipients.tsx index 69886e1a5d..c1ae9b8f13 100644 --- a/src/bundled/agents/AgentShareRecipients.tsx +++ b/src/bundled/agents/AgentShareRecipients.tsx @@ -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"; @@ -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), ) diff --git a/src/bundled/bestie/PrivateChannels.tsx b/src/bundled/bestie/PrivateChannels.tsx index fe7bde8433..dde55288b3 100644 --- a/src/bundled/bestie/PrivateChannels.tsx +++ b/src/bundled/bestie/PrivateChannels.tsx @@ -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"; @@ -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 { diff --git a/src/bundled/bestie/chat.ts b/src/bundled/bestie/chat.ts index f7b2baf146..d3925b542b 100644 --- a/src/bundled/bestie/chat.ts +++ b/src/bundled/bestie/chat.ts @@ -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. */ @@ -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( diff --git a/src/bundled/channels/ChannelCompletion.tsx b/src/bundled/channels/ChannelCompletion.tsx index 27cac0485b..bfdb930aba 100644 --- a/src/bundled/channels/ChannelCompletion.tsx +++ b/src/bundled/channels/ChannelCompletion.tsx @@ -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"; @@ -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) ); } diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 551bca1739..6625d13c1d 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -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"; @@ -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}`; diff --git a/src/bundled/channels/StartLinkedChat.tsx b/src/bundled/channels/StartLinkedChat.tsx index e0382267c3..0e29f98fbd 100644 --- a/src/bundled/channels/StartLinkedChat.tsx +++ b/src/bundled/channels/StartLinkedChat.tsx @@ -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"; @@ -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 ); } diff --git a/src/bundled/channels/canvas-panel.test.tsx b/src/bundled/channels/canvas-panel.test.tsx index c26e405545..6175557c67 100644 --- a/src/bundled/channels/canvas-panel.test.tsx +++ b/src/bundled/channels/canvas-panel.test.tsx @@ -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); diff --git a/src/bundled/channels/canvas-panel.tsx b/src/bundled/channels/canvas-panel.tsx index 7914b575d2..18201df536 100644 --- a/src/bundled/channels/canvas-panel.tsx +++ b/src/bundled/channels/canvas-panel.tsx @@ -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"; @@ -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) ); } diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index 29938adc7c..d7299608f1 100644 --- a/src/bundled/channels/useConversationTabs.tsx +++ b/src/bundled/channels/useConversationTabs.tsx @@ -1,3 +1,4 @@ +import { viewerMember } from "../../features/relay/membership"; import { useCallback, useEffect, @@ -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 diff --git a/src/bundled/channels/useSoloShare.ts b/src/bundled/channels/useSoloShare.ts index 764d0c7a44..e982e9892a 100644 --- a/src/bundled/channels/useSoloShare.ts +++ b/src/bundled/channels/useSoloShare.ts @@ -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"; @@ -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(/^#/, "")), diff --git a/src/bundled/inbox/InboxPage.tsx b/src/bundled/inbox/InboxPage.tsx index 017556a9c2..fa78faa9f6 100644 --- a/src/bundled/inbox/InboxPage.tsx +++ b/src/bundled/inbox/InboxPage.tsx @@ -1,3 +1,4 @@ +import { viewerMember } from "../../features/relay/membership"; import { type ReactNode, useCallback, @@ -855,7 +856,7 @@ function InboxList({ (!current.readOnly || current.viewerRole === "guest") && !current.archived && current.channelType === channel.channelType && - !!current.members?.includes(scope.viewer) && + viewerMember(current, scope.viewer) && [...current.members].sort().join(":") === membership && session.unread.generation() === generation && session.unread diff --git a/src/features/agents/AgentConnectionSetup.tsx b/src/features/agents/AgentConnectionSetup.tsx index 4c47a733ec..3ed6ab7cd8 100644 --- a/src/features/agents/AgentConnectionSetup.tsx +++ b/src/features/agents/AgentConnectionSetup.tsx @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import { useCallback, useContext, @@ -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, @@ -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 && @@ -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]) => ( void)[] = []; @@ -1632,3 +1633,41 @@ it("leaves out someone archived while the roster is read", async () => { ), ).toEqual([[first, second]]); }); + +it("keeps invitation and Solo sharing viewer gates independent of unloaded peers", () => { + const session = { viewer: "viewer", outbox: { supports: () => true } }; + const channel = { + id: "channel", + name: "Updates", + channelType: "stream" as const, + viewerMembership: { pubkey: "viewer", joined: true }, + }; + expect(canAddMembers(session, channel)).toBe(true); + expect(canAddMembers(session, { ...channel, viewerRole: "guest" })).toBe( + false, + ); + expect(canAddMembers(session, { ...channel, archived: true })).toBe(false); + expect( + canAddMembers(session, { + ...channel, + members: ["viewer"], + viewerMembership: { pubkey: "viewer", joined: false }, + }), + ).toBe(false); + const soloSession = setup("solo").session; + assert(soloSession.viewer); + const solo = { + ...channel, + channelType: "solo" as const, + viewerMembership: { pubkey: soloSession.viewer, joined: true }, + }; + expect(canShareSoloChat(soloSession, solo)).toBe(true); + expect(canShareSoloChat(soloSession, { ...solo, cached: true })).toBe(false); + expect( + canShareSoloChat(soloSession, { + ...solo, + viewerRole: "guest", + readOnly: true, + }), + ).toBe(false); +}); diff --git a/src/features/channel-members/members.ts b/src/features/channel-members/members.ts index f6e6baa48b..6329f9a94d 100644 --- a/src/features/channel-members/members.ts +++ b/src/features/channel-members/members.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import { archiveHides } from "../relay/identity-archives"; import type { RelaySession } from "../relay/session"; import type { ChannelSummary } from "../relay/contracts"; @@ -25,7 +26,7 @@ export function canAddMembers( channel.viewerRole !== "guest" && !channel.archived && expandable.has(channel.channelType ?? "") && - (channel.members?.includes(session.viewer) || + (viewerMember(channel, session.viewer) || (channel.readOnly && !channel.private && channel.joinRole !== "guest")) ); } @@ -43,7 +44,7 @@ export function canShareSoloChat( !channel.readOnly && !channel.cached && !channel.archived && - channel.members?.includes(session.viewer) + viewerMember(channel, session.viewer) ); } diff --git a/src/features/huddle/composer-owner.ts b/src/features/huddle/composer-owner.ts index 17405b87a9..8f0159bdd7 100644 --- a/src/features/huddle/composer-owner.ts +++ b/src/features/huddle/composer-owner.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import { communityFromScope, relayGifRequests } from "../relay/gifs"; import { MAX_VOICE_SECONDS } from "../relay/voice-media"; import { mentionDraft } from "../messages/mention-draft"; @@ -98,7 +99,7 @@ export function createHuddleComposerOwner( c.readOnly || c.cached || !session.viewer || - !c.members?.includes(session.viewer)) + !viewerMember(c, session.viewer)) ) throw new Error("This Huddle conversation is read-only."); } diff --git a/src/features/huddle/requests.ts b/src/features/huddle/requests.ts index b9882e38da..4035ff8f90 100644 --- a/src/features/huddle/requests.ts +++ b/src/features/huddle/requests.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import type { RelayData, RelaySnapshot } from "../relay/service"; import type { RelaySession } from "../relay/session"; import type { HuddleDestination } from "./bridge"; @@ -59,7 +60,7 @@ export function createHuddleRequests( channel.archived || channel.readOnly || channel.cached || - !channel.members?.includes(connection?.viewer ?? "") + !viewerMember(channel, connection?.viewer) ) return []; return recentHuddles(snapshot.events, channel.id, session.relayAuthor) @@ -137,8 +138,8 @@ export function createHuddleRequests( room.archived || room.readOnly || room.cached || - !room.members?.includes(connection?.viewer ?? "") || - !room.members.includes(notice.actor) || + !viewerMember(room, connection?.viewer) || + !room.members?.includes(notice.actor) || !event.tags.some( ([key, value]) => key === "h" && value === room.id, ) || @@ -235,7 +236,7 @@ export function createHuddleRequests( !c.archived && !c.readOnly && !c.cached && - c.members?.includes(session.viewer ?? ""), + viewerMember(c, session.viewer), ) .map((c) => c.id) .sort(); diff --git a/src/features/huddle/service.ts b/src/features/huddle/service.ts index e341c25041..23b92c2ce2 100644 --- a/src/features/huddle/service.ts +++ b/src/features/huddle/service.ts @@ -1,5 +1,6 @@ import { addChannelMember } from "../channel-members/members"; import { createPcmSender } from "./sender"; +import { viewerMember } from "../relay/membership"; import type { ChannelSummary } from "../relay/contracts"; import type { AudioSettingsState } from "./audio-settings"; import { createHuddleRequests } from "./requests"; @@ -214,7 +215,7 @@ export function createHuddles( !channel.archived && !channel.readOnly && !channel.cached && - !!channel.members?.includes(destination.viewer); + viewerMember(channel, destination.viewer); // Admission through a parent must never become room-only admission merely // because the audio join subsequently adds the viewer to the temporary room. const parentAdmission = member( diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx index c3e4e4cf51..19fe8239ca 100644 --- a/src/features/messages/MessageManagement.tsx +++ b/src/features/messages/MessageManagement.tsx @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import { BellIcon, BellSlashIcon, @@ -268,7 +269,7 @@ export function MessageManagement({ channel && !channel.cached && session.viewer && - channel.members?.includes(session.viewer) + viewerMember(channel, session.viewer) ? channel.id : undefined, ); diff --git a/src/features/notifications/indicator-unread.ts b/src/features/notifications/indicator-unread.ts index 43bd2ca768..3a24fd9329 100644 --- a/src/features/notifications/indicator-unread.ts +++ b/src/features/notifications/indicator-unread.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import type { Communities } from "../communities/service"; import type { RelaySession } from "../relay/session"; import { communityDestination } from "../communities/destination"; @@ -94,7 +95,7 @@ export function bindUnreadIndicator( (channel) => !channel.huddle && !channel.metadataPending && - channel.members?.includes(viewer) && + viewerMember(channel, viewer) && (!gates || !scope || gates.allows({ diff --git a/src/features/notifications/messages.ts b/src/features/notifications/messages.ts index 433769cf50..a741bfdcbe 100644 --- a/src/features/notifications/messages.ts +++ b/src/features/notifications/messages.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import { REPLY_CORRELATION_MS, type LocalAgentSource, @@ -81,7 +82,7 @@ export function notificationAuthorized( channel.id === target.channelId && !channel.huddle && !channel.metadataPending && - channel.members?.includes(scope.viewer), + viewerMember(channel, scope.viewer), ) ); } diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 8c3b2448e9..e863369364 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -25,7 +25,10 @@ export type ChannelSummary = Readonly<{ visibility?: "public" | "private" | undefined; /** Default for future participants; never an existing participant's role. */ joinRole?: "member" | "guest" | undefined; - /** Viewer role from the relay-signed roster, independent of admission defaults. */ + /** Viewer-scoped membership evidence; peer roster availability is independent. + * An explicit false outranks a retained peer roster. Cache freshness stays in cached. */ + viewerMembership?: Readonly<{ pubkey: string; joined: boolean }>; + /** Viewer role from verified membership, independent of admission defaults. */ viewerRole?: "owner" | "admin" | "member" | "guest" | "bot" | undefined; preview?: string | undefined; /** Newest verified user-visible activity for sidebar ordering, in Unix diff --git a/src/features/relay/discovery-permissions.test.ts b/src/features/relay/discovery-permissions.test.ts index 06e7798566..73a6fa89e5 100644 --- a/src/features/relay/discovery-permissions.test.ts +++ b/src/features/relay/discovery-permissions.test.ts @@ -61,6 +61,7 @@ it("keeps a guest joined and readable across admission-default changes until exp state.accept(roster("guest", 10)); expect(state.channels()).toHaveLength(1); expect(state.get(id)).toMatchObject({ + viewerMembership: { pubkey: viewer.pubkey, joined: true }, viewerRole: "guest", joinRole: "guest", readOnly: true, diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts index bc01b0ab37..17bad8ff26 100644 --- a/src/features/relay/discovery.ts +++ b/src/features/relay/discovery.ts @@ -441,6 +441,7 @@ export class DiscoveryState { ...(!this.authorized(id) || this.cached.has(id) || viewerRole === "guest" ? { readOnly: true as const } : {}), + viewerMembership: { pubkey: this.viewer, joined: this.authorized(id) }, ...(viewerRole ? { viewerRole } : {}), ...(this.cached.has(id) ? { cached: true as const } : {}), name: this.name(id), diff --git a/src/features/relay/inbox-feed.ts b/src/features/relay/inbox-feed.ts index 1048f72f74..5ed8296108 100644 --- a/src/features/relay/inbox-feed.ts +++ b/src/features/relay/inbox-feed.ts @@ -1,4 +1,5 @@ import { createInboxReactions } from "./inbox-reactions"; +import { viewerMember } from "./membership"; import { byteSize } from "./budget"; import type { ChannelQueries } from "./contracts"; import type { RelayEvent, ReadFilter } from "./events"; @@ -84,7 +85,7 @@ export function createInboxFeed({ channel.id === hs[0]?.[1] && !channel.huddle && !channel.cached && - channel.members?.includes(viewer), + viewerMember(channel, viewer), )) ); }; diff --git a/src/features/relay/membership.test.ts b/src/features/relay/membership.test.ts index 484ceee534..68907229cd 100644 --- a/src/features/relay/membership.test.ts +++ b/src/features/relay/membership.test.ts @@ -15,7 +15,7 @@ import { import type { LiveCallbacks } from "./live"; import type { ReadFilter } from "./events"; import type { HeadPersistence, SavedHead } from "./persistence"; -import { rowProfileIds } from "./membership"; +import { rowProfileIds, viewerMember } from "./membership"; const relay = keypair(), viewer = keypair(), @@ -501,3 +501,29 @@ it("a catch-up head rereads the newest notices, and restarts the descent only wh owner.dispose(); } }); + +it("uses viewer membership without a peer roster and rejects stale or foreign viewer evidence", () => { + const membership = { pubkey: viewer.pubkey, joined: true }; + expect(viewerMember({ viewerMembership: membership }, viewer.pubkey)).toBe( + true, + ); + expect(viewerMember({ viewerMembership: membership }, pinky.pubkey)).toBe( + false, + ); + expect(viewerMember({ viewerMembership: membership }, undefined)).toBe(false); + expect(viewerMember(undefined, viewer.pubkey)).toBe(false); + const retained = { members: [viewer.pubkey] }; + expect(viewerMember(retained, viewer.pubkey)).toBe(true); + expect( + viewerMember( + { ...retained, viewerMembership: { ...membership, joined: false } }, + viewer.pubkey, + ), + ).toBe(false); + expect( + viewerMember( + { ...retained, viewerMembership: { pubkey: pinky.pubkey, joined: true } }, + viewer.pubkey, + ), + ).toBe(false); +}); diff --git a/src/features/relay/membership.ts b/src/features/relay/membership.ts index fdb3a000de..3056fb9ef5 100644 --- a/src/features/relay/membership.ts +++ b/src/features/relay/membership.ts @@ -1,4 +1,8 @@ -import type { ChannelMessage, MembershipChange } from "./contracts"; +import type { + ChannelMessage, + ChannelSummary, + MembershipChange, +} from "./contracts"; import type { EventData } from "./events"; import { objectBody } from "./body"; @@ -67,3 +71,16 @@ export function messagePreview( : row.content; } } + +/** Viewer-only membership. Legacy summaries may still carry only a signed roster. + * Explicit viewer evidence wins even when a retained peer roster disagrees. */ +export function viewerMember( + channel: Pick | undefined, + viewer: string | undefined, +): boolean { + if (!channel || !viewer) return false; + const membership = channel.viewerMembership; + return membership + ? membership.pubkey === viewer && membership.joined + : !!channel.members?.includes(viewer); +} diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index dbf21bed1c..415bd5cafa 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "./membership"; import { retryRead } from "./read-retry"; import { channelRowKind, rowProfileIds, messagePreview } from "./membership"; import type { Outbox } from "./outbox"; @@ -444,6 +445,8 @@ export function createChannelStore( old.metadataCached === metadataCached && old.visibility === channel.visibility && old.joinRole === channel.joinRole && + old.viewerMembership?.pubkey === channel.viewerMembership?.pubkey && + old.viewerMembership?.joined === channel.viewerMembership?.joined && old.viewerRole === channel.viewerRole && old.preview === preview && old.hidden === channel.hidden && @@ -3277,7 +3280,7 @@ export function createChannelStore( function describeReference(id: string): ChannelReference { const channel = discovery?.get(id); if (channel) { - const joined = !!discovery && channel.members?.includes(discovery.viewer); + const joined = !!discovery && viewerMember(channel, discovery.viewer); return { state: "found", name: channel.name, diff --git a/src/features/relay/unread-invalidation.test.ts b/src/features/relay/unread-invalidation.test.ts index 56144e29f1..c57b5e8057 100644 --- a/src/features/relay/unread-invalidation.test.ts +++ b/src/features/relay/unread-invalidation.test.ts @@ -17,16 +17,26 @@ function at(values: readonly T[], index: number): T { return value; } -async function setup(count = 3, profiles?: ProfileQueries) { +async function setup( + count = 3, + profiles?: ProfileQueries, + project: (channel: ChannelSummary, viewer: string) => ChannelSummary = ( + channel, + ) => channel, +) { const viewer = keypair(), peer = keypair(); const roster: readonly ChannelSummary[] = Array.from( { length: count }, - (_, i) => ({ - id: `c${i}`, - name: `Channel ${i}`, - members: [viewer.pubkey], - }), + (_, i) => + project( + { + id: `c${i}`, + name: `Channel ${i}`, + members: [viewer.pubkey], + }, + viewer.pubkey, + ), ); const channels: ChannelQueries = { list: () => ({ status: "ready" as const, channels: roster }), @@ -276,3 +286,27 @@ it.each([5, 9005])( expect(h.listeners[2]).not.toHaveBeenCalled(); }, ); + +it("counts unread for a joined Guest with unloaded peers, while stale and foreign authority cannot admit it", async () => { + const h = await setup(1, undefined, (channel, viewer) => { + const { members: _peers, ...rest } = channel; + return { + ...rest, + viewerMembership: { pubkey: viewer, joined: true }, + viewerRole: "guest", + readOnly: true, + }; + }); + expect(h.unread.snapshot(at(h.targets, 0)).observedCount).toBe(1); + for (const state of ["denied", "cached", "foreign"] as const) { + const h = await setup(1, undefined, (channel, viewer) => ({ + ...channel, + viewerMembership: { + pubkey: state === "foreign" ? "another-viewer" : viewer, + joined: state !== "denied", + }, + ...(state === "cached" ? { cached: true as const } : {}), + })); + expect(h.unread.snapshot(at(h.targets, 0)).observedCount).toBeNull(); + } +}); diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index 167d3aaa58..29fb370209 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "./membership"; import type { ProfileQueries } from "./profile-directory"; import { workflowOwner } from "./workflow-attribution"; import { agentAudience } from "./audience"; @@ -401,24 +402,12 @@ export function createUnread({ } let bytes = 0; - // Per member list: a large channel would otherwise be scanned for the - // viewer on every event. - const viewerIn = new WeakMap(); - const member = (members: readonly string[] | undefined) => { - if (!members) return false; - let found = viewerIn.get(members); - if (found === undefined) { - found = members.includes(viewer); - if (Object.isFrozen(members)) viewerIn.set(members, found); - } - return found; - }; const allowed = (id: string) => channels .list() .channels.some( (channel) => - channel.id === id && !channel.cached && member(channel.members), + channel.id === id && !channel.cached && viewerMember(channel, viewer), ); const keyFor = (target: ReadTarget) => `${target.channelId}:${targetKey(target)}`; @@ -1322,7 +1311,7 @@ export function createUnread({ channel.metadataPending || channel.cached || channel.archived || - !channel.members?.includes(viewer) + !viewerMember(channel, viewer) ) continue; const dm = channel.channelType === "dm"; @@ -1666,9 +1655,7 @@ export function createUnread({ const types = (list: ReturnType) => new Map( list.channels - .filter( - (channel) => !channel.cached && channel.members?.includes(viewer), - ) + .filter((channel) => !channel.cached && viewerMember(channel, viewer)) .map((channel) => [ channel.id, `${channel.channelType ?? ""}:${!!channel.huddle}:${!!channel.metadataPending}`, @@ -1684,9 +1671,7 @@ export function createUnread({ list.channels .filter( (channel) => - channel.archived && - !channel.cached && - channel.members?.includes(viewer), + channel.archived && !channel.cached && viewerMember(channel, viewer), ) .map((channel) => channel.id) .sort() @@ -2038,7 +2023,7 @@ export function createUnread({ const ids = channels .list() .channels.filter( - (channel) => !channel.cached && channel.members?.includes(viewer), + (channel) => !channel.cached && viewerMember(channel, viewer), ) .map((channel) => channel.id) // Batches key their marks, so they must not move when a channel is renamed. diff --git a/src/features/solo/labels.ts b/src/features/solo/labels.ts index 255c309717..c9d4c79db6 100644 --- a/src/features/solo/labels.ts +++ b/src/features/solo/labels.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import type { ChannelSummary } from "../relay/contracts.ts"; /** Presentation provenance in the relay's existing NIP-CL labels, never access. */ @@ -65,6 +66,6 @@ export function eligibleSolo( ownSolo(channel, viewer) || (!!viewer && channel.viewerRole === "guest" && - !!channel.members?.includes(viewer))) + viewerMember(channel, viewer))) ); } diff --git a/src/features/solo/share.ts b/src/features/solo/share.ts index ae1ef84d5d..02a1605313 100644 --- a/src/features/solo/share.ts +++ b/src/features/solo/share.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "../relay/membership"; import { sameLabels, type ChannelDetailsDraft, @@ -23,7 +24,7 @@ export function canShareConversation( !channel.cached && !channel.readOnly && !channel.archived && - channel.members?.includes(session.viewer ?? "") && + viewerMember(channel, session.viewer) && canAddMembers(session, channel) ) ); @@ -64,7 +65,7 @@ export async function checkShareDestination( destination.cached || destination.archived || destination.readOnly || - !destination.members?.includes(session.viewer ?? "") || + !viewerMember(destination, session.viewer) || !chatShareDestination(destination) ) throw new Error( From 8dae9acdfb4da6bea4042e7f9eb6c18ff532f80c Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 5 Nov 2026 15:36:41 -0500 Subject: [PATCH 2/8] test(relay): fence unread after restored public previews Co-authored-by: Honor Signed-off-by: Brad Seiler --- src/features/relay/store.ts | 8 +++- .../relay/unread-invalidation.test.ts | 46 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 415bd5cafa..07aaac8d19 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -1,6 +1,10 @@ -import { viewerMember } from "./membership"; import { retryRead } from "./read-retry"; -import { channelRowKind, rowProfileIds, messagePreview } from "./membership"; +import { + channelRowKind, + rowProfileIds, + messagePreview, + viewerMember, +} from "./membership"; import type { Outbox } from "./outbox"; import { MessageProjection } from "./message-projection"; import { createRelayProfiler, type RelayProfiler } from "./profiling"; diff --git a/src/features/relay/unread-invalidation.test.ts b/src/features/relay/unread-invalidation.test.ts index c57b5e8057..4d17201dbf 100644 --- a/src/features/relay/unread-invalidation.test.ts +++ b/src/features/relay/unread-invalidation.test.ts @@ -3,7 +3,14 @@ import type { ChannelQueries, ChannelSummary } from "./contracts"; import type { ProfileQueries } from "./profile-directory"; import { createReadState } from "./read-state"; import { readJournal, type ReadJournal } from "./read-state-storage"; -import { keypair, message, signed } from "./testing"; +import { + keypair, + message, + metadata, + roster as rosterEvent, + signed, +} from "./testing"; +import { DiscoveryState } from "./discovery"; import { createUnread } from "./unread"; const owners: ReturnType[] = []; @@ -310,3 +317,40 @@ it("counts unread for a joined Guest with unloaded peers, while stale and foreig expect(h.unread.snapshot(at(h.targets, 0)).observedCount).toBeNull(); } }); + +it.each(["deny", "omit", "all"] as const)( + "keeps a restored public preview out of unread after %s despite its retained roster", + async (loss) => { + const relay = keypair(); + let projected: ChannelSummary | undefined; + const h = await setup(1, undefined, (channel, viewer) => { + const discovery = new DiscoveryState(viewer, relay.pubkey); + const details = metadata(relay, channel.id, channel.name, 10, [ + ["public"], + ["t", "stream"], + ]); + discovery.accept(details); + discovery.accept(rosterEvent(relay, channel.id, [viewer], 10)); + expect(discovery.get(channel.id)?.viewerMembership?.joined).toBe(true); + if (loss === "deny") discovery.deny(channel.id); + else if (loss === "omit") + discovery.retain(new Set(), discovery.rosterVersions()); + else discovery.denyAll(); + expect(discovery.canAccess(channel.id)).toBe(false); + discovery.resume(channel.id, details); + const preview = discovery.get(channel.id); + assert(preview); + expect(preview.members).toContain(viewer); + projected = preview; + expect(discovery.canAccess(channel.id)).toBe(true); + expect(discovery.canParticipate(channel.id)).toBe(false); + return preview; + }); + expect(h.unread.snapshot(at(h.targets, 0)).observedCount).toBeNull(); + expect(h.unread.inbox().items).toEqual([]); + expect(projected?.viewerMembership).toEqual({ + pubkey: h.viewer.pubkey, + joined: false, + }); + }, +); From 911359a39006e4a15bcfabb54bc86cc23144091d Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 5 Nov 2026 15:52:45 -0500 Subject: [PATCH 3/8] Fix authority summary fixtures and broker imports Co-authored-by: Honor Signed-off-by: Brad Seiler --- src/bundled/inbox/InboxPage.tsx | 2 +- src/features/relay/fold.test.ts | 2 ++ src/features/relay/membership.ts | 6 +++--- src/features/relay/prepared.test.ts | 1 + src/features/solo/labels.ts | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/bundled/inbox/InboxPage.tsx b/src/bundled/inbox/InboxPage.tsx index fa78faa9f6..888efa74bb 100644 --- a/src/bundled/inbox/InboxPage.tsx +++ b/src/bundled/inbox/InboxPage.tsx @@ -857,7 +857,7 @@ function InboxList({ !current.archived && current.channelType === channel.channelType && viewerMember(current, scope.viewer) && - [...current.members].sort().join(":") === membership && + current.members?.slice().sort().join(":") === membership && session.unread.generation() === generation && session.unread .inbox() diff --git a/src/features/relay/fold.test.ts b/src/features/relay/fold.test.ts index 5cb7ffb809..b15fb79eda 100644 --- a/src/features/relay/fold.test.ts +++ b/src/features/relay/fold.test.ts @@ -960,12 +960,14 @@ describe("discovery", () => { joinRole: "member", members: [alice.pubkey, bob.pubkey].sort(), owners: [], + viewerMembership: { pubkey: alice.pubkey, joined: true }, }, { id: "zeta", name: "zeta".slice(0, 8), members: [alice.pubkey], owners: [], + viewerMembership: { pubkey: alice.pubkey, joined: true }, }, ]); expect( diff --git a/src/features/relay/membership.ts b/src/features/relay/membership.ts index 3056fb9ef5..3085c1d4fa 100644 --- a/src/features/relay/membership.ts +++ b/src/features/relay/membership.ts @@ -2,9 +2,9 @@ import type { ChannelMessage, ChannelSummary, MembershipChange, -} from "./contracts"; -import type { EventData } from "./events"; -import { objectBody } from "./body"; +} from "./contracts.ts"; +import type { EventData } from "./events.ts"; +import { objectBody } from "./body.ts"; /** Content rows retained by channel history/live windows, not the unread kind set. */ export const CHANNEL_ROW_KINDS = [9, 40002, 40008, 40099, 48100, 48103]; diff --git a/src/features/relay/prepared.test.ts b/src/features/relay/prepared.test.ts index fd8dd3a00f..67bb798b5e 100644 --- a/src/features/relay/prepared.test.ts +++ b/src/features/relay/prepared.test.ts @@ -474,6 +474,7 @@ it("hides DM channels behind the NIP-29 hidden tag", async () => { hidden: true, members: [viewer.pubkey, alice.pubkey].sort(), owners: [], + viewerMembership: { pubkey: viewer.pubkey, joined: true }, preview: undefined, }, ]); diff --git a/src/features/solo/labels.ts b/src/features/solo/labels.ts index c9d4c79db6..b6139f6b72 100644 --- a/src/features/solo/labels.ts +++ b/src/features/solo/labels.ts @@ -1,4 +1,4 @@ -import { viewerMember } from "../relay/membership"; +import { viewerMember } from "../relay/membership.ts"; import type { ChannelSummary } from "../relay/contracts.ts"; /** Presentation provenance in the relay's existing NIP-CL labels, never access. */ From 8eec319e4f6f7ee9ffa90ff52469a24a4953ba7a Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 5 Nov 2026 19:33:39 -0500 Subject: [PATCH 4/8] Preserve viewer authority across new lifecycle and reaction gates Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../relay/channel-lifecycle-protocol.ts | 3 +- src/features/relay/inbox-reactions.ts | 3 +- .../relay/unread-invalidation.test.ts | 72 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts index 6b4c8fe154..e3c104c6ae 100644 --- a/src/features/relay/channel-lifecycle-protocol.ts +++ b/src/features/relay/channel-lifecycle-protocol.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "./membership.ts"; import type { EventTemplate } from "nostr-tools"; import type { ChannelSummary } from "./contracts.ts"; import { newer, type RelayEvent } from "./events.ts"; @@ -210,7 +211,7 @@ export function lifecycleOffer( (channel.readOnly && channel.viewerRole !== "guest") || !UUID.test(channel.id) || !channel.owners || - !channel.members?.includes(viewer) + !viewerMember(channel, viewer) ) return; const archived = !!channel.archived; diff --git a/src/features/relay/inbox-reactions.ts b/src/features/relay/inbox-reactions.ts index 729ad14b93..cf8b7c6ee5 100644 --- a/src/features/relay/inbox-reactions.ts +++ b/src/features/relay/inbox-reactions.ts @@ -1,3 +1,4 @@ +import { viewerMember } from "./membership"; import type { ChannelQueries, ChannelMessage, @@ -74,7 +75,7 @@ export function createInboxReactions({ !channel.archived && !channel.huddle && !channel.metadataPending && - channel.members?.includes(viewer); + viewerMember(channel, viewer); const allowed = (id: string) => channels .list() diff --git a/src/features/relay/unread-invalidation.test.ts b/src/features/relay/unread-invalidation.test.ts index 4d17201dbf..82aa138ad1 100644 --- a/src/features/relay/unread-invalidation.test.ts +++ b/src/features/relay/unread-invalidation.test.ts @@ -1,3 +1,6 @@ +import { lifecycleOffer } from "./channel-lifecycle-protocol"; +import { createInboxReactions } from "./inbox-reactions"; +import { matchesEvent } from "./projection"; import { afterEach, assert, expect, it, vi } from "vitest"; import type { ChannelQueries, ChannelSummary } from "./contracts"; import type { ProfileQueries } from "./profile-directory"; @@ -9,6 +12,7 @@ import { metadata, roster as rosterEvent, signed, + type Key, } from "./testing"; import { DiscoveryState } from "./discovery"; import { createUnread } from "./unread"; @@ -112,6 +116,47 @@ async function setup( }; } +async function reactionItems(channel: ChannelSummary, viewer: Key) { + const target = message(viewer, channel.id, "My message", 20); + const reaction = signed(keypair(), { + kind: 7, + content: "+", + created_at: 21, + tags: [ + ["e", target.id], + ["p", viewer.pubkey], + ["h", channel.id], + ], + }); + const feed = createInboxReactions({ + viewer: viewer.pubkey, + channels: { + list: () => ({ status: "ready", channels: [channel] }), + subscribeList: () => () => {}, + window: () => { + throw new Error("Reaction feed must not read windows"); + }, + subscribeWindow: () => () => {}, + ensureList() {}, + ensure() {}, + loadOlder() {}, + }, + reader: { + read: async (filters) => + [target, reaction].filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + }, + notify: (listener) => listener(), + }); + try { + await feed.ensure(); + return feed.snapshot().items; + } finally { + feed.dispose(); + } +} + it("does not trust workflow-owner metadata without a configured relay identity", async () => { const h = await setup(1); const row = message(h.peer, "c0", "workflow-shaped output", 12, [ @@ -305,6 +350,17 @@ it("counts unread for a joined Guest with unloaded peers, while stale and foreig }; }); expect(h.unread.snapshot(at(h.targets, 0)).observedCount).toBe(1); + const guest: ChannelSummary = { + id: "11111111-1111-4111-8111-111111111111", + name: "Guest", + channelType: "stream", + owners: [], + viewerRole: "guest", + readOnly: true, + viewerMembership: { pubkey: h.viewer.pubkey, joined: true }, + }; + expect(lifecycleOffer(guest, h.viewer.pubkey, false)?.canLeave).toBe(true); + expect(await reactionItems(guest, h.viewer)).toHaveLength(1); for (const state of ["denied", "cached", "foreign"] as const) { const h = await setup(1, undefined, (channel, viewer) => ({ ...channel, @@ -348,6 +404,22 @@ it.each(["deny", "omit", "all"] as const)( }); expect(h.unread.snapshot(at(h.targets, 0)).observedCount).toBeNull(); expect(h.unread.inbox().items).toEqual([]); + assert(projected); + expect( + lifecycleOffer( + { + ...projected, + id: "11111111-1111-4111-8111-111111111111", + channelType: "stream", + owners: [], + viewerRole: "guest", + readOnly: true, + }, + h.viewer.pubkey, + false, + ), + ).toBeUndefined(); + expect(await reactionItems(projected, h.viewer)).toEqual([]); expect(projected?.viewerMembership).toEqual({ pubkey: h.viewer.pubkey, joined: false, From aa90db8cfe509af4ae235ee9fcca290a7fd76bbb Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 16:29:49 -0500 Subject: [PATCH 5/8] test(agents): fence live viewer authority for setup Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../agents/AgentConnectionSetup.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/features/agents/AgentConnectionSetup.test.tsx b/src/features/agents/AgentConnectionSetup.test.tsx index c5274b8077..de1c436d89 100644 --- a/src/features/agents/AgentConnectionSetup.test.tsx +++ b/src/features/agents/AgentConnectionSetup.test.tsx @@ -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( From 586b29625af3118c43dab51b6d13e2093f20e5c2 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 16:32:18 -0500 Subject: [PATCH 6/8] test(dm): control background page completion during restore Hold the next browse page while asserting retained cached rows, then release it after remount. This preserves the request and isolation assertions without racing the automatic paging timer. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../direct-messages/NewMessage.test.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/features/direct-messages/NewMessage.test.tsx b/src/features/direct-messages/NewMessage.test.tsx index 0fd79582f8..9dd3084de3 100644 --- a/src/features/direct-messages/NewMessage.test.tsx +++ b/src/features/direct-messages/NewMessage.test.tsx @@ -11,6 +11,7 @@ import { describe, expect, it, + onTestFinished, vi, } from "vitest"; import { npubEncode } from "nostr-tools/nip19"; @@ -1482,15 +1483,33 @@ it("loads directory pages on scroll and ignores a late previous search", async ( it("keeps loaded pages when clearing search or returning to New message, scoped to the session", async () => { const t = setup(); - t.directMessages.people.mockImplementation(async (query, page) => ({ - people: query ? people.slice(2, 3) : people.slice(page - 1, page), - hasMore: !query && page < 3, - })); + const thirdPages: (() => void)[] = []; + const releaseThirdPages = () => { + for (const release of thirdPages.splice(0)) release(); + }; + onTestFinished(releaseThirdPages); + t.directMessages.people.mockImplementation(async (query, page) => { + // Automatic paging may start before typing. Keep its result pending until + // the restored two-page cache is asserted, including after remount. + if (!query && page === 3) + await new Promise((resolve) => thirdPages.push(resolve)); + return { + people: query ? people.slice(2, 3) : people.slice(page - 1, page), + hasMore: !query && page < 3, + }; + }); const view = t.mount(); await screen.findByRole("option", { name: "Person 1" }); fireEvent.scroll(screen.getByRole("listbox")); await screen.findByRole("option", { name: "Person 2, Agent" }); const loaded = screen.getAllByRole("option").map((row) => row.textContent); + await waitFor(() => + expect(t.directMessages.people).toHaveBeenLastCalledWith( + "", + 3, + expect.any(AbortSignal), + ), + ); await t.user.type(recipient(), "Person 3"); await screen.findByRole("option", { name: "Person 3" }); await t.user.clear(recipient()); @@ -1508,6 +1527,10 @@ it("keeps loaded pages when clearing search or returning to New message, scoped loaded, ), ); + await waitFor(() => + expect(t.directMessages.people).toHaveBeenCalledTimes(requests + 1), + ); + await act(async () => releaseThirdPages()); await screen.findByRole("option", { name: "Person 3" }); expect(t.directMessages.people).toHaveBeenCalledTimes(requests + 1); expect(t.directMessages.people).toHaveBeenLastCalledWith( From a823be79f32dc753b04a4b3c7283a62f267cbef2 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 16:41:13 -0500 Subject: [PATCH 7/8] fix(relay): preserve viewer authority in new consumers Use the viewer projection for draft access and owner role fallback. Keep explicit role and read-only policies, and cover denial with retained peers plus joined proof without a roster. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../drafts-sent/DraftPreview.test.tsx | 69 +++++++++++++++++++ src/features/drafts-sent/DraftPreview.tsx | 8 +-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/features/drafts-sent/DraftPreview.test.tsx b/src/features/drafts-sent/DraftPreview.test.tsx index db80ab55f1..9cdceb6fb5 100644 --- a/src/features/drafts-sent/DraftPreview.test.tsx +++ b/src/features/drafts-sent/DraftPreview.test.tsx @@ -29,6 +29,7 @@ import type { Navigation } from "../navigation/controller"; import type { Panels } from "../panels/service"; import type { RelayEvent } from "../relay/events"; import { matchesEvent } from "../relay/projection"; +import { DiscoveryState } from "../relay/discovery"; import { bounds, keypair, @@ -823,6 +824,74 @@ it.each([ }, ); +it.each(["deny", "omit", "all"] as const)( + "does not restore draft or sent access from peers retained after %s", + (loss) => { + const h = fixture(); + const viewer = h.session.viewer; + if (!viewer) throw new Error("Missing viewer"); + const relay = keypair(); + const discovery = new DiscoveryState(viewer, relay.pubkey); + const details = metadata(relay, "room", "Design", 10, [["public"]]); + discovery.accept(details); + discovery.accept(roster(relay, "room", [viewer], 10)); + if (loss === "deny") discovery.deny("room"); + else if (loss === "omit") + discovery.retain(new Set(), discovery.rosterVersions()); + else discovery.denyAll(); + discovery.resume("room", details); + const preview = discovery.get("room"); + expect(preview?.members).toContain(viewer); + expect(preview?.viewerMembership?.joined).toBe(false); + if (!preview) throw new Error("Missing public preview"); + const props = { + entry: { id: "draft:room", channelId: "room", text: "Keep this" }, + title: "#Design", + session: h.session, + host: h.host, + autoFocus: false, + onClose: vi.fn(), + onOpen: vi.fn(), + onSaved: vi.fn(), + }; + const view = render(); + expect(screen.getByText(/can’t post/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Copy text" })).toBeEnabled(); + // Read-only public previews retain their existing Sent policy. This row + // exercises membership-based readability without that separate capability. + const { readOnly: _readOnly, ...membershipOnly } = preview; + view.rerender( + , + ); + expect(screen.getByText(/not available for replying/)).toBeVisible(); + }, +); + +it("opens a joined draft without waiting for a peer roster", async () => { + const h = fixture(); + const viewer = h.session.viewer; + if (!viewer) throw new Error("Missing viewer"); + render( + , + ); + expect(await screen.findByRole("textbox")).toBeVisible(); +}); + it("clears the live announcement between two sends to the same conversation", async () => { const h = fixture(); h.render(); diff --git a/src/features/drafts-sent/DraftPreview.tsx b/src/features/drafts-sent/DraftPreview.tsx index cf02e14e00..88fa0f6431 100644 --- a/src/features/drafts-sent/DraftPreview.tsx +++ b/src/features/drafts-sent/DraftPreview.tsx @@ -23,6 +23,7 @@ import { MessageEditScope } from "../messages/MessageEditScope"; import { mentionDraft } from "../messages/mention-draft"; import type { RelaySession } from "../relay/session"; import type { ChannelSummary } from "../relay/contracts"; +import { viewerMember } from "../relay/membership"; import { useChannelWindow } from "../relay/react"; import { readView, viewRevision } from "../../shared/view-state"; import { writeClipboardText } from "../../shared/clipboard"; @@ -132,7 +133,7 @@ export function DraftPreview({ loading || !!channel?.cached || !!channel?.pending || - (!!channel && !channel.members); + (!!channel && !channel.members && !channel.viewerMembership); const writable = channel && !channel.cached && @@ -140,10 +141,9 @@ export function DraftPreview({ !channel.archived && !channel.readOnly && !!session.viewer && - channel.members?.includes(session.viewer); + viewerMember(channel, session.viewer); const readable = - channel && - (channel.readOnly || channel.members?.includes(session.viewer ?? "")); + channel && (channel.readOnly || viewerMember(channel, session.viewer)); useEffect(() => { const narrow = !!back.current?.getClientRects().length; // Sent never autofocuses its composer; a narrow open hides the list row. From 2915904a63281b917e1744cce29ecebf061c992f Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 11 Nov 2026 09:12:18 -0800 Subject: [PATCH 8/8] fix(bestie): use viewer authority for menu panels Recheck viewer membership when a menu action opens a panel. Cover joined summaries without peer rosters and denial after the menu has opened. Co-authored-by: Honor Signed-off-by: Brad Seiler --- src/bundled/bestie/BestieConversation.tsx | 5 +- src/bundled/bestie/PinnedBestie.test.tsx | 110 +++++++++++++++------- 2 files changed, 79 insertions(+), 36 deletions(-) diff --git a/src/bundled/bestie/BestieConversation.tsx b/src/bundled/bestie/BestieConversation.tsx index 3a7fe7c07e..0f4c4ba992 100644 --- a/src/bundled/bestie/BestieConversation.tsx +++ b/src/bundled/bestie/BestieConversation.tsx @@ -1,3 +1,4 @@ +import { viewerMember } from "../../features/relay/membership"; import { useCallback, useEffect, @@ -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) ) diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index 24431a1763..0001b3396d 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -290,8 +290,15 @@ async function fixture({ owner.dispose(); }); const send = vi.fn(() => "d".repeat(64)); + let channelListOverride: + | ReturnType + | undefined; const session = { ...owner.session, + channels: { + ...owner.session.channels, + list: () => channelListOverride ?? owner.session.channels.list(), + }, agentChoices, messages: { ...owner.session.messages, send }, }; @@ -346,6 +353,9 @@ async function fixture({ find: () => undefined, } as unknown as Agents; return { + setChannelList(next: ReturnType) { + channelListOverride = next; + }, setPanels(next: readonly RegisteredPanel[]) { panels = next; for (const listener of panelListeners) listener(); @@ -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 }) => ( -

Canvas for {channelContext?.channelId}

- ), - } 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 }) => ( +

Canvas for {channelContext?.channelId}

+ ), + } 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(); + }, +);