diff --git a/desktop/src/features/workflows/ui/AuthorGridPicker.tsx b/desktop/src/features/workflows/ui/AuthorGridPicker.tsx new file mode 100644 index 000000000..016644e63 --- /dev/null +++ b/desktop/src/features/workflows/ui/AuthorGridPicker.tsx @@ -0,0 +1,349 @@ +import { Check, Search } from "lucide-react"; +import * as React from "react"; + +import { useRelayMembersQuery } from "@/features/community-members/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { UserProfileSummary, UserSearchResult } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { PortalledScrollArea } from "@/shared/ui/PortalledScrollArea"; + +const AUTHOR_PAGE_SIZE = 50; + +function authorLabel( + pubkey: string, + profile?: Pick | null, +) { + return ( + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + truncatePubkey(pubkey) + ); +} + +function toSearchResult( + pubkey: string, + profile?: UserProfileSummary | null, +): UserSearchResult { + return { + pubkey, + displayName: profile?.displayName ?? profile?.name ?? null, + avatarUrl: profile?.avatarUrl ?? null, + nip05Handle: profile?.nip05Handle ?? null, + ownerPubkey: profile?.ownerPubkey ?? null, + isAgent: profile?.isAgent ?? false, + }; +} + +function matchesPubkeyPrefix(pubkey: string, query: string) { + return ( + query.length >= 8 && + /^[0-9a-f]+$/i.test(query) && + pubkey.startsWith(query.toLowerCase()) + ); +} + +export function AuthorGridPicker({ + ariaLabel = "Author pubkey", + disabled, + id, + knownPubkeys = [], + onChange, + value, +}: { + ariaLabel?: string; + disabled?: boolean; + id?: string; + knownPubkeys?: string[]; + onChange: (value: string) => void; + value: string; +}) { + const [query, setQuery] = React.useState(""); + const [visibleMemberCount, setVisibleMemberCount] = + React.useState(AUTHOR_PAGE_SIZE); + const deferredQuery = React.useDeferredValue(query.trim()); + const normalizedValue = parsePubkeyInput(value); + + const membersQuery = useRelayMembersQuery(true); + const memberPubkeys = React.useMemo( + () => [ + ...new Set( + [ + ...knownPubkeys, + ...(membersQuery.data ?? []).map((member) => member.pubkey), + ] + .map((pubkey) => pubkey.trim().toLowerCase()) + .filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey)), + ), + ], + [knownPubkeys, membersQuery.data], + ); + const visibleMemberPubkeys = React.useMemo( + () => memberPubkeys.slice(0, visibleMemberCount), + [memberPubkeys, visibleMemberCount], + ); + const visibleProfilesQuery = useUsersBatchQuery(visibleMemberPubkeys); + const visibleMemberResults = React.useMemo( + () => + visibleMemberPubkeys.map((pubkey) => + toSearchResult(pubkey, visibleProfilesQuery.data?.profiles[pubkey]), + ), + [visibleMemberPubkeys, visibleProfilesQuery.data?.profiles], + ); + + const directorySearchQuery = useInfiniteUserSearchQuery(deferredQuery, { + allowEmpty: true, + enabled: true, + limit: AUTHOR_PAGE_SIZE, + }); + const directoryResults = useFlattenedUserSearchResults( + directorySearchQuery.data, + ); + const searchResults = React.useMemo(() => { + if (deferredQuery.length === 0) { + const resultsByPubkey = new Map(); + for (const result of visibleMemberResults) { + resultsByPubkey.set(result.pubkey.toLowerCase(), result); + } + for (const result of directoryResults) { + const pubkey = result.pubkey.toLowerCase(); + resultsByPubkey.set(pubkey, { ...result, pubkey }); + } + return [...resultsByPubkey.values()]; + } + + const resultsByPubkey = new Map(); + for (const result of directoryResults) { + const pubkey = result.pubkey.toLowerCase(); + resultsByPubkey.set(pubkey, { ...result, pubkey }); + } + + if (memberPubkeys.length > 0) { + for (const pubkey of memberPubkeys) { + if ( + matchesPubkeyPrefix(pubkey, deferredQuery) && + !resultsByPubkey.has(pubkey) + ) { + resultsByPubkey.set(pubkey, toSearchResult(pubkey)); + } + } + } + + return [...resultsByPubkey.values()]; + }, [deferredQuery, directoryResults, memberPubkeys, visibleMemberResults]); + const directPubkey = parsePubkeyInput(deferredQuery); + const showDirectPubkey = + directPubkey !== null && + !searchResults.some( + (user) => user.pubkey.toLowerCase() === directPubkey.toLowerCase(), + ); + + function selectAuthor(pubkey: string) { + onChange(pubkey.toLowerCase()); + } + + function loadNextPage() { + if (deferredQuery.length === 0) { + if (visibleMemberCount < memberPubkeys.length) { + setVisibleMemberCount((count) => + Math.min(count + AUTHOR_PAGE_SIZE, memberPubkeys.length), + ); + } + if ( + directorySearchQuery.hasNextPage && + !directorySearchQuery.isFetchingNextPage + ) { + void directorySearchQuery.fetchNextPage(); + } + return; + } + if ( + directorySearchQuery.hasNextPage && + !directorySearchQuery.isFetchingNextPage + ) { + void directorySearchQuery.fetchNextPage(); + } + } + + function handleListScroll(event: React.UIEvent) { + const list = event.currentTarget; + if (list.scrollHeight - list.scrollTop - list.clientHeight < 64) { + loadNextPage(); + } + } + + const hasMoreResults = + deferredQuery.length === 0 + ? visibleMemberCount < memberPubkeys.length || + directorySearchQuery.hasNextPage + : directorySearchQuery.hasNextPage; + const isSettling = deferredQuery !== query.trim(); + const isLoading = + isSettling || + (searchResults.length === 0 && + (membersQuery.isLoading || directorySearchQuery.isLoading)); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search people or paste a public key..." + spellCheck={false} + value={query} + /> +
+ +
+ {ariaLabel} + {isLoading ? ( +

+ Loading authors… +

+ ) : showDirectPubkey && directPubkey ? ( + <> + selectAuthor(directPubkey)} + pubkey={directPubkey} + /> + {searchResults.map((user) => ( + selectAuthor(user.pubkey)} + user={user} + /> + ))} + + ) : searchResults.length > 0 ? ( + searchResults.map((user) => ( + selectAuthor(user.pubkey)} + user={user} + /> + )) + ) : ( +

+ No authors found. +

+ )} + {!isLoading && hasMoreResults ? ( + + ) : null} +
+
+
+ ); +} + +function AuthorSearchOption({ + disabled, + isSelected, + onSelect, + user, +}: { + disabled?: boolean; + isSelected: boolean; + onSelect: () => void; + user: UserSearchResult; +}) { + const label = authorLabel(user.pubkey, user); + return ( + + ); +} + +function AuthorOption({ + avatarUrl, + disabled, + isSelected, + label, + onSelect, + pubkey, +}: { + avatarUrl?: string | null; + disabled?: boolean; + isSelected: boolean; + label: string; + onSelect: () => void; + pubkey: string; +}) { + return ( + + ); +} diff --git a/desktop/src/features/workflows/ui/ChannelCombobox.tsx b/desktop/src/features/workflows/ui/ChannelCombobox.tsx index 2562bbfba..50172eb4e 100644 --- a/desktop/src/features/workflows/ui/ChannelCombobox.tsx +++ b/desktop/src/features/workflows/ui/ChannelCombobox.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { PortalledScrollArea } from "@/shared/ui/PortalledScrollArea"; function ChannelPrivacyIcon({ channel }: { channel: Channel }) { const Icon = channel.visibility === "private" ? Lock : Hash; @@ -160,7 +161,7 @@ export function ChannelCombobox({ value={query} /> -
@@ -221,7 +222,7 @@ export function ChannelCombobox({ ); }) )} -
+ ); diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx index ffd1c8619..8d20c26e0 100644 --- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -1,7 +1,11 @@ import * as React from "react"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; +import { AuthorGridPicker } from "./AuthorGridPicker"; +import { ChannelCombobox } from "./ChannelCombobox"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { @@ -77,6 +81,7 @@ function valuePlaceholder(field: string): string { } export function WorkflowConditionBuilder({ + channels, disabled, idPrefix, matchAllHint = "Leave empty to match every event.", @@ -84,6 +89,7 @@ export function WorkflowConditionBuilder({ triggerType, value, }: { + channels: Channel[]; disabled?: boolean; idPrefix: string; matchAllHint?: string; @@ -92,6 +98,39 @@ export function WorkflowConditionBuilder({ value: string; }) { const fields = conditionFieldsForTrigger(triggerType); + const identityQuery = useIdentityQuery(); + const knownAuthorPubkeys = React.useMemo(() => { + const selfPubkey = identityQuery.data?.pubkey.trim().toLowerCase(); + const rankedPubkeys = new Set(); + const addPubkeys = (pubkeys: string[]) => { + for (const pubkey of pubkeys) { + const normalized = pubkey.trim().toLowerCase(); + if (normalized && normalized !== selfPubkey) { + rankedPubkeys.add(normalized); + } + } + }; + + // DM counterparts are the strongest browse-time signal that the user is + // likely to recognize the author. Group DMs keep participant order and + // duplicates are removed before the shared-channel tier is appended. + for (const channel of channels) { + if (channel.channelType !== "dm") continue; + addPubkeys( + channel.participantPubkeys.length > 0 + ? channel.participantPubkeys + : channel.memberPubkeys, + ); + } + + for (const channel of channels) { + if (channel.channelType === "dm") continue; + addPubkeys(channel.memberPubkeys); + addPubkeys(channel.participantPubkeys); + } + + return [...rankedPubkeys]; + }, [channels, identityQuery.data?.pubkey]); const [editor, setEditor] = React.useState(() => initialEditorState(value, triggerType), ); @@ -264,7 +303,30 @@ export function WorkflowConditionBuilder({ ? "Value" : valueLabel(editor.field)} - {editor.field === "trigger_emoji" ? ( + {editor.field === "trigger_author" ? ( + + emitEditor({ ...editor, value: pubkey }) + } + value={editor.value} + /> + ) : editor.field === "trigger_channel_id" ? ( + + emitEditor({ ...editor, value: channelId }) + } + value={editor.value} + variant="field" + /> + ) : editor.field === "trigger_emoji" ? ( void; @@ -62,6 +64,7 @@ function TriggerConfigFields({ return (
updateFormState({ ...formState, trigger }) diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index 18bc38f66..3e900be4d 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -101,42 +101,34 @@ function StepConfigFields({ value={step.text ?? ""} />
-
- - Channel override (optional) - - - Boolean(workflowChannelId && channel.id !== workflowChannelId) - } - onChange={(channel) => onUpdate({ ...step, channel })} - value={step.channel ?? ""} - variant="field" - /> -

- {workflowChannelId - ? "Defaults to this workflow's channel. Cross-channel posting is not permitted." - : "Defaults to the trigger channel. Webhook and manual triggers require a channel."} -

- {triggerType === "webhook" && - !workflowChannelId && - !(step.channel ?? "").trim() ? ( -

- This step will fail for webhook-triggered runs until a channel - override is set. + {!workflowChannelId ? ( +

+ + Channel override (optional) + + onUpdate({ ...step, channel })} + value={step.channel ?? ""} + variant="field" + /> +

+ Defaults to the channel that triggered the workflow. Webhook and + manual triggers require a channel.

- ) : null} -
+ {triggerType === "webhook" && !(step.channel ?? "").trim() ? ( +

+ This step will fail for webhook-triggered runs until a channel + override is set. +

+ ) : null} +
+ ) : null} ); case "send_dm": @@ -391,6 +383,7 @@ export function WorkflowStepCard({
; + +/** + * Keeps a portalled overflow container wheel-scrollable when it is rendered + * outside a modal's scroll-lock boundary. + */ +export function PortalledScrollArea({ + onWheel, + ...props +}: PortalledScrollAreaProps) { + function handleWheel(event: React.WheelEvent) { + onWheel?.(event); + if (event.defaultPrevented) return; + + const area = event.currentTarget; + const maxScrollTop = area.scrollHeight - area.clientHeight; + if (maxScrollTop <= 0) return; + + const multiplier = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? area.clientHeight + : 1; + const nextScrollTop = Math.max( + 0, + Math.min(maxScrollTop, area.scrollTop + event.deltaY * multiplier), + ); + if (nextScrollTop === area.scrollTop) return; + + area.scrollTop = nextScrollTop; + event.preventDefault(); + event.stopPropagation(); + } + + return
; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 95670000c..15f052b0d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -158,6 +158,11 @@ type MockSearchProfileSeed = { isAgent?: boolean; }; +type MockRelayMemberSeed = { + pubkey: string; + role?: "owner" | "admin" | "member"; +}; + type MockHuddleMemberSeed = { pubkey: string; role: "owner" | "admin" | "member" | "guest" | "bot"; @@ -417,6 +422,8 @@ type E2eConfig = { /** Delay EOSE for membership snapshots after delivering the event. */ relayMembershipEoseDelayMs?: number; relayRole?: "owner" | "admin" | "member" | null; + /** Additional members appended to the default NIP-43 roster. */ + additionalRelayMembers?: MockRelayMemberSeed[]; // Descriptors returned by the mocked `pick_and_upload_media` / // `upload_media_bytes` commands. Lets a spec drive the attachment flow // (e.g. a generic PDF) without a real upload pipeline. See @@ -1765,6 +1772,19 @@ function resetMockRelayMembers(config: E2eConfig | undefined) { created_at: isoMinutesAgo(60), }, ]; + + for (const member of config?.mock?.additionalRelayMembers ?? []) { + const memberPubkey = member.pubkey.toLowerCase(); + if (mockRelayMembers.some((existing) => existing.pubkey === memberPubkey)) { + continue; + } + mockRelayMembers.push({ + pubkey: memberPubkey, + role: member.role ?? "member", + added_by: activeRoleMember?.pubkey ?? null, + created_at: isoMinutesAgo(30), + }); + } } function buildMockConfigSurface(pubkey: string): { diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 2af362836..c92ae7d31 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { installMockBridge } from "../helpers/bridge"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; const TRIGGER_OPTION_LABELS: Record = { diff_posted: "Diff Posted", @@ -10,8 +10,30 @@ const TRIGGER_OPTION_LABELS: Record = { webhook: "Webhook", }; -test.beforeEach(async ({ page }) => { - await installMockBridge(page); +const WORKFLOW_AUTHOR_DIRECTORY = Array.from({ length: 60 }, (_, index) => { + const memberNumber = index + 1; + return { + pubkey: (10_000 + memberNumber).toString(16).padStart(64, "0"), + displayName: `Workflow member ${memberNumber.toString().padStart(2, "0")}`, + }; +}); +const PROFILELESS_WORKFLOW_MEMBER = "abcdef12".padEnd(64, "3"); + +test.beforeEach(async ({ page }, testInfo) => { + const needsLargeAuthorDirectory = + testInfo.title === + "chooses a trigger author condition from live user search"; + await installMockBridge( + page, + needsLargeAuthorDirectory + ? { + additionalRelayMembers: WORKFLOW_AUTHOR_DIRECTORY.map( + ({ pubkey }) => ({ pubkey }), + ).concat({ pubkey: PROFILELESS_WORKFLOW_MEMBER }), + searchProfiles: WORKFLOW_AUTHOR_DIRECTORY, + } + : undefined, + ); }); async function navigateToWorkflows(page: import("@playwright/test").Page) { @@ -293,6 +315,133 @@ test("builds a valid trigger condition from plain-language choices", async ({ await expect(inspector.getByLabel("Custom expression")).not.toBeVisible(); }); +test("chooses a trigger channel condition from the live channel list", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + const inspector = dialog.getByTestId("workflow-node-inspector"); + + await inspector.getByRole("button", { name: "Channel ID" }).click(); + const channelCondition = inspector.getByRole("combobox", { + name: "Channel ID", + }); + await expect(channelCondition).toContainText("Choose a channel"); + await channelCondition.click(); + + const channelList = page.getByTestId("channel-combobox-list"); + await channelList.hover(); + await page.mouse.wheel(0, 500); + await expect + .poll(() => channelList.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + const search = page.getByPlaceholder("Search channels..."); + await search.fill("random"); + await page.getByRole("button", { name: "random · stream" }).click(); + await expect(channelCondition).toContainText("random"); + + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /str_contains\(trigger_channel_id, "[0-9a-f-]{36}"\)/, + ); +}); + +test("chooses a trigger author condition from live user search", async ({ + page, +}) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + const inspector = dialog.getByTestId("workflow-node-inspector"); + + await inspector.getByRole("button", { name: "Author pubkey" }).click(); + const authorPicker = inspector.getByTestId("author-grid-picker"); + await expect(authorPicker).toBeVisible(); + await expect + .poll(() => + authorPicker + .locator("fieldset") + .evaluate( + (element) => + getComputedStyle(element) + .gridTemplateColumns.split(" ") + .filter(Boolean).length, + ), + ) + .toBe(3); + const authorList = authorPicker.getByTestId("author-grid-list"); + const authorResults = page.getByTestId(/^workflow-author-result-/); + await expect.poll(() => authorResults.count()).toBeGreaterThanOrEqual(50); + await expect + .poll(() => + authorResults.evaluateAll((elements) => + elements + .slice(0, 3) + .map((element) => + element + .getAttribute("data-testid") + ?.replace("workflow-author-result-", ""), + ), + ), + ) + .toEqual([ + TEST_IDENTITIES.alice.pubkey, + TEST_IDENTITIES.bob.pubkey, + TEST_IDENTITIES.charlie.pubkey, + ]); + await authorList.hover(); + await page.mouse.wheel(0, 10_000); + await expect.poll(() => authorResults.count()).toBeGreaterThan(60); + + await page + .getByPlaceholder("Search people or paste a public key...") + .fill("abcdef12"); + await expect( + page.getByTestId(`workflow-author-result-${PROFILELESS_WORKFLOW_MEMBER}`), + ).toBeVisible(); + + await page + .getByPlaceholder("Search people or paste a public key...") + .fill("charlie"); + await expect(authorList.getByText("charlie", { exact: true })).toBeVisible(); + + await page + .getByPlaceholder("Search people or paste a public key...") + .fill("outsider"); + await expect( + page.getByTestId( + `workflow-author-result-${TEST_IDENTITIES.outsider.pubkey}`, + ), + ).toBeVisible(); + + await page + .getByPlaceholder("Search people or paste a public key...") + .fill("Workflow"); + await expect(authorResults).toHaveCount(50); + await page.getByRole("button", { name: "Load more authors" }).click(); + await expect(authorResults).toHaveCount(60); + + await page + .getByPlaceholder("Search people or paste a public key...") + .fill("Workflow member 59"); + const selectedAuthor = page.getByTestId( + `workflow-author-result-${WORKFLOW_AUTHOR_DIRECTORY[58].pubkey}`, + ); + await selectedAuthor.click(); + await expect(selectedAuthor).toHaveAttribute("aria-pressed", "true"); + + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /str_contains\(trigger_author,\s+"[0-9a-f]{64}"\)/, + ); +}); + test("chooses and clears a reaction trigger with the app emoji picker", async ({ page, }) => { @@ -447,35 +596,25 @@ test("scrolls the channel list with the mouse wheel", async ({ page }) => { .toBeGreaterThan(0); }); -test("selects a message destination from the channel lookup", async ({ - page, -}) => { +test("omits destination controls for a channel workflow", async ({ page }) => { await navigateToWorkflows(page); await page.getByRole("button", { name: "Create Workflow" }).click(); const dialog = page.getByRole("dialog"); await selectFirstChannel(dialog); + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + await dialog.getByLabel("Trigger event").click(); + await page.getByRole("menuitem", { name: "Webhook" }).click(); await dialog.getByRole("button", { name: "Add step" }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - const channelOverride = dialog.getByRole("combobox", { - name: "Channel override (optional)", - }); - await expect(channelOverride).toContainText("Use workflow channel"); - await channelOverride.click(); - - const search = page.getByPlaceholder("Search channels..."); - await search.fill("random"); await expect( - page.getByRole("button", { name: "random · stream" }), - ).toBeDisabled(); - - await search.fill("agents"); - await page.getByRole("button", { name: "agents · stream" }).click(); - await expect(channelOverride).toContainText("agents"); + dialog.getByRole("combobox", { name: "Channel override (optional)" }), + ).toHaveCount(0); + await expect(dialog.getByText("Posting channel")).toHaveCount(0); await dialog.getByRole("tab", { name: "YAML" }).click(); - await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + await expect(dialog.getByLabel("Workflow YAML")).not.toHaveValue( /channel: [0-9a-f-]{36}/, ); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 31af66ab0..d64d15c83 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -54,6 +54,11 @@ type MockSearchProfileSeed = { isAgent?: boolean; }; +type MockRelayMemberSeed = { + pubkey: string; + role?: "owner" | "admin" | "member"; +}; + type MockRelayAgentSeed = { pubkey: string; name: string; @@ -374,6 +379,8 @@ type MockBridgeOptions = { * evaluates false). */ relayRole?: "owner" | "admin" | "member" | null; + /** Additional members appended to the default NIP-43 roster. */ + additionalRelayMembers?: MockRelayMemberSeed[]; /** * Descriptors returned by the mocked `pick_and_upload_media` / * `upload_media_bytes` commands. When omitted, the bridge returns a single