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/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(); + }, +); 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..888efa74bb 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,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() 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( 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/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( 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. 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/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/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/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/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/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/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..3085c1d4fa 100644 --- a/src/features/relay/membership.ts +++ b/src/features/relay/membership.ts @@ -1,6 +1,10 @@ -import type { ChannelMessage, MembershipChange } from "./contracts"; -import type { EventData } from "./events"; -import { objectBody } from "./body"; +import type { + ChannelMessage, + ChannelSummary, + MembershipChange, +} 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]; @@ -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/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/relay/store.ts b/src/features/relay/store.ts index dbf21bed1c..07aaac8d19 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -1,5 +1,10 @@ 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"; @@ -444,6 +449,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 +3284,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..82aa138ad1 100644 --- a/src/features/relay/unread-invalidation.test.ts +++ b/src/features/relay/unread-invalidation.test.ts @@ -1,9 +1,20 @@ +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"; 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, + type Key, +} from "./testing"; +import { DiscoveryState } from "./discovery"; import { createUnread } from "./unread"; const owners: ReturnType[] = []; @@ -17,16 +28,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 }), @@ -95,6 +116,47 @@ async function setup(count = 3, profiles?: ProfileQueries) { }; } +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, [ @@ -276,3 +338,91 @@ 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); + 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, + 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(); + } +}); + +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([]); + 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, + }); + }, +); 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..b6139f6b72 100644 --- a/src/features/solo/labels.ts +++ b/src/features/solo/labels.ts @@ -1,3 +1,4 @@ +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. */ @@ -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(