From 97d1b1dc49287a56f8c7598c88ec9f4afa7b5344 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 25 Sep 2026 22:00:01 -0700 Subject: [PATCH] Share the invite link UI through InviteLink, move adding members directly into its own modal, and diff role and method assignments with one helper --- .agents/skills/flotilla-model/SKILL.md | 6 +- e2e/USER_STORIES.md | 4 +- e2e/specs/admin.spec.ts | 8 +- src/app/components/InviteLink.svelte | 115 ++++++++++++++ src/app/components/RoleAddMembers.svelte | 38 ++--- src/app/components/RoomInvite.svelte | 102 +----------- src/app/components/SpaceInvite.svelte | 157 ++----------------- src/app/components/SpaceMemberMethods.svelte | 77 ++++----- src/app/components/SpaceMemberRoles.svelte | 56 ++----- src/app/components/SpaceMembersAdd.svelte | 76 +++++++++ src/app/management.ts | 42 ++++- src/app/rooms.ts | 17 +- 12 files changed, 319 insertions(+), 379 deletions(-) create mode 100644 src/app/components/InviteLink.svelte create mode 100644 src/app/components/SpaceMembersAdd.svelte diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index 25b91b8c..9cc58f72 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -156,7 +156,7 @@ Invite links are `${PLATFORM_URL}/join?r=&c=`, plus `h` and `code` (`makeInviteLink`; `parseInviteLink` also accepts a bare relay URL). `src/routes/join` renders `SpaceInviteAccept`, which calls `Access.acceptInvite` to join the space and then the room. `Access.prepareInvite` gets a claim over NIP-86 (`supportedmethods`, then `listclaims`, then -`createclaim`); this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, +`createclaim`), and `InviteLink` renders the result for both `SpaceInvite` and `RoomInvite`; this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, `SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`. ## NIP-86 relay management @@ -168,9 +168,9 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | Methods | Called from | |---|---| | `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) | -| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `SpaceInvite`, `ReportMenuList`, `addRoomMembers` | +| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenuList`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) | | `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) | -| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` | +| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` | | `listClaims`, `createClaim` | `Access.prepareInvite` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index d546033f..f366c56f 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1469,8 +1469,8 @@ Acceptance: - "Create Invite" shows a QR code and a copyable link, and copying puts it on the clipboard. -- Where the relay supports it, the invite dialog's member search adds someone - straight into the member list. +- Where the relay supports it, "Add members" in the invite dialog opens a member + search that adds someone straight into the member list. - A room-scoped invite link carries both the space and that room's join code. ### US-095 — Remove, ban, and restore members diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index 8da22f76..783d446e 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -281,9 +281,13 @@ test("US-094 invite people to a space", async ({seed, as}) => { await expect(invite.locator("input[readonly]")).toHaveValue(/\/join\?r=space\.test&c=.+/) - await invite.getByPlaceholder("Search for profiles...").fill("Nadia") + await invite.getByRole("button", {name: "Add members"}).click() + + const addMembers = dialog(page, "Add Members") + + await addMembers.getByPlaceholder("Search for profiles...").fill("Nadia") await page.locator(".tiptap-suggestions").getByRole("button", {name: newcomer.pubkey}).click() - await invite.getByRole("button", {name: "Save", exact: true}).click() + await addMembers.getByRole("button", {name: "Save", exact: true}).click() await expect(page.getByRole("alert")).toContainText("Members have successfully been added!") diff --git a/src/app/components/InviteLink.svelte b/src/app/components/InviteLink.svelte new file mode 100644 index 00000000..2a6988c4 --- /dev/null +++ b/src/app/components/InviteLink.svelte @@ -0,0 +1,115 @@ + + +
+ {#if $inviteStatus === "loading"} +

+ Requesting an invite link... +

+ {:else if $inviteStatus === "network"} +

+ Unable to reach the space. Please check your connection and try again. +

+ {:else if $inviteStatus === "auth"} +

+ {#if h} + Oops! It looks like you're not allowed to create invites for this room. + {:else} + Oops! It looks like you're not a member of this space. + {/if} +

+ {:else if $inviteStatus === "failed"} +

Unable to create a room invite code.

+ {:else} +
+
+ +
+ + {#snippet input()} +
+ {#if canShare} + + {/if} + + +
+ {/snippet} + {#snippet info()} +

+ {#if h} + This invite link includes access to the space and room. Anyone with the link can join + by opening it in {PLATFORM_NAME}. + {:else} + This invite link can be used by clicking "Add Space" and pasting it there. + {/if} + {#if $inviteStatus === "noclaim"} + We weren't able to get an invite code from this space, so additional steps might be + required. + {:else if !$claim} + This space did not issue a claim for this link, so additional steps might be required. + {/if} +

+ {/snippet} +
+
+ {/if} +
diff --git a/src/app/components/RoleAddMembers.svelte b/src/app/components/RoleAddMembers.svelte index ab9e98a8..dd0e8579 100644 --- a/src/app/components/RoleAddMembers.svelte +++ b/src/app/components/RoleAddMembers.svelte @@ -12,7 +12,8 @@ import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" - import {relayManagement, relayMemberLists} from "@app/core" + import {relayManagement} from "@app/core" + import {allowPubkeys} from "@app/management" import {pushToast} from "@app/toast" type Props = { @@ -24,41 +25,34 @@ const back = () => history.back() - let loading = $state(false) - let pubkeys: string[] = $state([]) - const submit = async () => { loading = true try { - const members = $relayMemberLists.get(url) const management = $relayManagement.forUrl(url) + // Members have to be allowed at the relay before a role there means anything + let error = await allowPubkeys(url, pubkeys) + for (const pubkey of pubkeys) { - // Ensure they're space members first, then assign the role - if (!members?.isMember(pubkey)) { - const {error} = await management.allowPubkey(pubkey) - - if (error) { - pushToast({theme: "error", message: error}) - return - } - } - - const {error} = await management.assignRole(pubkey, role.identifier()!) - - if (error) { - pushToast({theme: "error", message: error}) - return + if (!error) { + error = (await management.assignRole(pubkey, role.identifier()!)).error } } - pushToast({message: "Members assigned!"}) - back() + if (error) { + pushToast({theme: "error", message: error}) + } else { + pushToast({message: "Members assigned!"}) + back() + } } finally { loading = false } } + + let loading = $state(false) + let pubkeys: string[] = $state([]) diff --git a/src/app/components/RoomInvite.svelte b/src/app/components/RoomInvite.svelte index dab4e0bf..1d220ee6 100644 --- a/src/app/components/RoomInvite.svelte +++ b/src/app/components/RoomInvite.svelte @@ -1,25 +1,14 @@ @@ -72,63 +30,7 @@ in {displayRelayUrl(url)} -
- {#if $inviteStatus === "loading"} -

- Requesting an invite link... -

- {:else if $inviteStatus === "network"} -

- Unable to reach the space. Please check your connection and try again. -

- {:else if $inviteStatus === "auth"} -

- Oops! It looks like you're not allowed to create invites for this room. -

- {:else if $inviteStatus === "failed"} -

Unable to create a room invite code.

- {:else} -
-
- -
- - {#snippet input()} -
- {#if canShare} - - {/if} - - -
- {/snippet} - {#snippet info()} -

- This invite link includes access to the space and room. Anyone with the link can - join by opening it in {PLATFORM_NAME}. - {#if $inviteStatus === "noclaim"} - We weren't able to get an invite code from this space, so additional steps might - be required. - {:else if !$claim} - This space did not issue a claim for this link, so additional steps might be - required. - {/if} -

- {/snippet} -
-
- {/if} -
+ diff --git a/src/app/components/SpaceInvite.svelte b/src/app/components/SpaceInvite.svelte index 4ede13c8..4471d6b7 100644 --- a/src/app/components/SpaceInvite.svelte +++ b/src/app/components/SpaceInvite.svelte @@ -1,28 +1,18 @@ @@ -102,84 +36,15 @@ {displayRelayUrl(url)} -
- {#if $inviteStatus === "loading"} -

- Requesting an invite link... -

- {:else if $inviteStatus === "network"} -

- Unable to reach the space. Please check your connection and try again. -

- {:else if $inviteStatus === "auth"} -

- Oops! It looks like you're not a member of this space. -

- {:else} -
-
- -
- - {#snippet input()} -
- {#if canShare} - - {/if} - - -
- {/snippet} - {#snippet info()} -

- This invite link can be used by clicking "Add Space" and pasting it there. - {#if $inviteStatus === "noclaim"} - We weren't able to get an invite code from this space, so additional steps might - be required. - {:else if !$claim} - This space did not issue a claim for this link, so additional steps might be - required. - {/if} -

- {/snippet} -
-
- {/if} -
- {#if $permissions.addMembers} - or - - {#snippet label()} -

Add members directly

- {/snippet} - {#snippet input()} - - {/snippet} -
- {/if} + {#if $permissions.addMembers} - - + {:else} {/if} diff --git a/src/app/components/SpaceMemberMethods.svelte b/src/app/components/SpaceMemberMethods.svelte index de0688d4..c15e733c 100644 --- a/src/app/components/SpaceMemberMethods.svelte +++ b/src/app/components/SpaceMemberMethods.svelte @@ -1,7 +1,7 @@ @@ -133,11 +112,11 @@ toggle(method)} /> + : !$permissions.assignMethods} /> {/each} diff --git a/src/app/components/SpaceMemberRoles.svelte b/src/app/components/SpaceMemberRoles.svelte index aa864295..9947e42a 100644 --- a/src/app/components/SpaceMemberRoles.svelte +++ b/src/app/components/SpaceMemberRoles.svelte @@ -16,7 +16,7 @@ import RoleItem from "@app/components/RoleItem.svelte" import SpaceRoles from "@app/components/SpaceRoles.svelte" import {app, profiles, relayManagement} from "@app/core" - import {deriveSpacePermissions} from "@app/management" + import {deriveSpacePermissions, syncAssignments} from "@app/management" import {deriveSpaceMemberRoles} from "@app/roles" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -33,61 +33,37 @@ const profileDisplay = $profiles.display(pubkey, [url]).$ const memberRoles = deriveSpaceMemberRoles(url) - const initial = new Set(get(memberRoles).get(pubkey) ?? []) + const initial = get(memberRoles).get(pubkey) ?? [] const permissions = deriveSpacePermissions(url) const back = () => history.back() const manageRoles = () => pushModal(SpaceRoles, {url}) - const toggle = (id: string) => { - const next = new Set(selected) - - if (next.has(id)) { - next.delete(id) - } else { - next.add(id) - } - - selected = next - } - const submit = async () => { loading = true try { const management = $relayManagement.forUrl(url) + const error = await syncAssignments( + initial, + selected, + id => management.assignRole(pubkey, id), + id => management.unassignRole(pubkey, id), + ) - for (const id of selected) { - if (!initial.has(id)) { - const {error} = await management.assignRole(pubkey, id) - - if (error) { - pushToast({theme: "error", message: error}) - return - } - } + if (error) { + pushToast({theme: "error", message: error}) + } else { + pushToast({message: "Roles updated!"}) + back() } - - for (const id of initial) { - if (!selected.has(id)) { - const {error} = await management.unassignRole(pubkey, id) - - if (error) { - pushToast({theme: "error", message: error}) - return - } - } - } - - pushToast({message: "Roles updated!"}) - back() } finally { loading = false } } - let selected = $state(new Set(initial)) + let selected = $state([...initial]) let loading = $state(false) @@ -109,8 +85,8 @@ toggle(role.identifier() ?? "")} /> + value={role.identifier() ?? ""} + bind:group={selected} /> {/each} diff --git a/src/app/components/SpaceMembersAdd.svelte b/src/app/components/SpaceMembersAdd.svelte new file mode 100644 index 00000000..a06a9c69 --- /dev/null +++ b/src/app/components/SpaceMembersAdd.svelte @@ -0,0 +1,76 @@ + + + + + + Add Members + + to {displayRelayUrl(url)} + + + + {#snippet label()} +

Search for People

+ {/snippet} + {#snippet input()} + + {/snippet} +
+
+ + + + +
diff --git a/src/app/management.ts b/src/app/management.ts index 6422c3bf..67523a8b 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -1,9 +1,9 @@ import {derived, readable, writable} from "svelte/store" -import {ago, MINUTE, now, simpleCache} from "@welshman/lib" +import {ago, difference, MINUTE, now, simpleCache} from "@welshman/lib" import {ROOM_CREATE_PERMISSION, hexTags, tagValues} from "@welshman/util" import {RelayManagement, Relays} from "@welshman/app" import type {IApp} from "@welshman/app" -import {events, fromApp, usePlugin, user} from "@app/core" +import {events, fromApp, relayManagement, relayMemberLists, usePlugin, user} from "@app/core" // `supportedmethods` answers for the pubkey that signed the request, so this catalog is the client's. export const MANAGEMENT_METHOD_GROUPS = [ @@ -199,6 +199,44 @@ const getSpacePermissions = (methods: string[]) => { } } +// Mutations + +export const allowPubkeys = async (url: string, pubkeys: string[]) => { + const members = relayMemberLists.get().get(url) + const management = relayManagement.get().forUrl(url) + const responses = await Promise.all( + pubkeys.filter(pk => !members?.isMember(pk)).map(pk => management.allowPubkey(pk)), + ) + + return responses.map(response => response.error).find(Boolean) +} + +type Assign = (id: string) => Promise<{error?: string}> + +// Stops at the first refusal, leaving whatever was already applied in place. +export const syncAssignments = async ( + current: string[], + next: string[], + assign: Assign, + unassign: Assign, +) => { + for (const id of difference(next, current)) { + const {error} = await assign(id) + + if (error) { + return error + } + } + + for (const id of difference(current, next)) { + const {error} = await unassign(id) + + if (error) { + return error + } + } +} + // Each control is gated on the NIP-86 method behind it, as reported for the signed-in user. export const deriveSpacePermissions = (url?: string) => derived(deriveSpaceSupportedMethods(url), getSpacePermissions) diff --git a/src/app/rooms.ts b/src/app/rooms.ts index ac302a6c..dcb25d3c 100644 --- a/src/app/rooms.ts +++ b/src/app/rooms.ts @@ -21,8 +21,6 @@ import type {Room, RoomMeta} from "@welshman/app" import { deriveUserItem, command, - relayManagement, - relayMemberLists, roomLists, rooms, router, @@ -31,7 +29,7 @@ import { user, writer, } from "@app/core" -import {deriveSpacePermissions, deriveUserIsSpaceStaff} from "@app/management" +import {allowPubkeys, deriveSpacePermissions, deriveUserIsSpaceStaff} from "@app/management" import {makeRoomPath} from "@app/routes" import {loadSendDelay} from "@app/settings" @@ -57,17 +55,10 @@ export const deriveRoomMembers = (url: string, h: string) => // A room member also has to be allowed at the relay level to read the room at all. export const addRoomMembers = async (url: string, room: RoomMeta, pubkeys: string[]) => { - const members = relayMemberLists.get().get(url) - const management = relayManagement.get().forUrl(url) + const allowError = await allowPubkeys(url, pubkeys) - const responses = await Promise.all( - pubkeys.filter(pk => !members?.isMember(pk)).map(pk => management.allowPubkey(pk)), - ) - - for (const {error} of responses) { - if (error) { - return error - } + if (allowError) { + return allowError } const errors = await Promise.all(