Share the invite link UI through InviteLink, move adding members directly into its own modal, and diff role and method assignments with one helper

This commit is contained in:
Jon Staab 2026-09-25 22:00:01 -07:00
parent 0eebfc7dc1
commit 97d1b1dc49
12 changed files with 319 additions and 379 deletions

View file

@ -156,7 +156,7 @@ Invite links are `${PLATFORM_URL}/join?r=<relay>&c=<claim>`, 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` |

View file

@ -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

View file

@ -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!")

View file

@ -0,0 +1,115 @@
<script lang="ts">
import {onMount} from "svelte"
import {Share} from "@capacitor/share"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import QRCode from "@app/components/QRCode.svelte"
import {Access, makeInviteLink} from "@app/access"
import {PLATFORM_NAME} from "@app/env"
import {clip} from "@app/toast"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const access = new Access(url)
const {claim, loading, roomCode} = access
const inviteStatus = access.createInviteStatus(Boolean(h))
const invite = $derived(makeInviteLink({url, claim: $claim, h, code: $roomCode}))
const copyInvite = () => clip(invite)
const shareInvite = async () => {
try {
await Share.share({url: invite})
} catch (e) {
console.error(e)
}
}
let canShare = $state(false)
onMount(async () => {
try {
const {value} = await Share.canShare()
canShare = value
} catch {
canShare = false
}
await access.prepareInvite(h)
})
</script>
<div>
{#if $inviteStatus === "loading"}
<p class="flex justify-center items-center">
<Spinner loading={$loading}>Requesting an invite link...</Spinner>
</p>
{:else if $inviteStatus === "network"}
<p class="flex justify-center items-center">
Unable to reach the space. Please check your connection and try again.
</p>
{:else if $inviteStatus === "auth"}
<p class="flex justify-center items-center">
{#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}
</p>
{:else if $inviteStatus === "failed"}
<p class="flex justify-center items-center">Unable to create a room invite code.</p>
{:else}
<div class="flex flex-col items-center gap-6">
<div class="w-48">
<QRCode code={invite} />
</div>
<Field>
{#snippet input()}
<div class="flex w-full gap-2">
{#if canShare}
<Button
class="input flex w-12 shrink-0 items-center justify-center p-0"
onclick={shareInvite}>
<Icon icon={Upload} />
</Button>
{/if}
<label class="input flex min-w-0 flex-1 items-center gap-2">
<Icon icon={LinkRound} class="shrink-0" />
<input value={invite} class="min-w-0 flex-1 truncate" type="text" readonly />
<Button class="shrink-0" onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
</div>
{/snippet}
{#snippet info()}
<p>
{#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}
</p>
{/snippet}
</Field>
</div>
{/if}
</div>

View file

@ -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([])
</script>
<Modal>

View file

@ -1,25 +1,14 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl} from "@welshman/util"
import {Share} from "@capacitor/share"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import InviteLink from "@app/components/InviteLink.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {Access, makeInviteLink} from "@app/access"
import {PLATFORM_NAME} from "@app/env"
import {clip} from "@app/toast"
type Props = {
url: string
@ -28,38 +17,7 @@
const {url, h}: Props = $props()
const access = new Access(url)
const {claim, loading, roomCode} = access
const inviteStatus = access.createInviteStatus(true)
const back = () => history.back()
const copyInvite = () => clip(invite)
const shareInvite = async () => {
if (!canShare) {
return
}
try {
await Share.share({url: invite})
} catch (e) {
console.error(e)
}
}
let canShare = $state(false)
let invite = $derived(makeInviteLink({url, claim: $claim, h, code: $roomCode}))
onMount(async () => {
try {
const {value} = await Share.canShare()
canShare = value
} catch {
canShare = false
}
await access.prepareInvite(h)
})
</script>
<Modal>
@ -72,63 +30,7 @@
in <span class="text-primary">{displayRelayUrl(url)}</span>
</ModalSubtitle>
</ModalHeader>
<div>
{#if $inviteStatus === "loading"}
<p class="flex justify-center items-center">
<Spinner loading={$loading}>Requesting an invite link...</Spinner>
</p>
{:else if $inviteStatus === "network"}
<p class="flex justify-center items-center">
Unable to reach the space. Please check your connection and try again.
</p>
{:else if $inviteStatus === "auth"}
<p class="flex justify-center items-center">
Oops! It looks like you're not allowed to create invites for this room.
</p>
{:else if $inviteStatus === "failed"}
<p class="flex justify-center items-center">Unable to create a room invite code.</p>
{:else}
<div class="flex flex-col items-center gap-6">
<div class="w-48">
<QRCode code={invite} />
</div>
<Field>
{#snippet input()}
<div class="flex w-full gap-2">
{#if canShare}
<Button
class="input flex w-12 shrink-0 items-center justify-center p-0"
onclick={shareInvite}>
<Icon icon={Upload} />
</Button>
{/if}
<label class="input flex min-w-0 flex-1 items-center gap-2">
<Icon icon={LinkRound} class="shrink-0" />
<input bind:value={invite} class="min-w-0 flex-1 truncate" type="text" readonly />
<Button class="shrink-0" onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
</div>
{/snippet}
{#snippet info()}
<p>
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}
</p>
{/snippet}
</Field>
</div>
{/if}
</div>
<InviteLink {url} {h} />
</ModalBody>
<ModalFooter>
<Button class="button button-primary grow" onclick={back}>Done</Button>

View file

@ -1,28 +1,18 @@
<script lang="ts">
import {onMount} from "svelte"
import {Share} from "@capacitor/share"
import {displayRelayUrl} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte"
import UserPlus from "@assets/icons/user-plus.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Divider from "@lib/components/Divider.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import InviteLink from "@app/components/InviteLink.svelte"
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
import {deriveSpacePermissions} from "@app/management"
import QRCode from "@app/components/QRCode.svelte"
import {Access, makeInviteLink} from "@app/access"
import {relayManagement, relayMemberLists} from "@app/core"
import {clip, pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
url: string
@ -30,67 +20,11 @@
const {url}: Props = $props()
const access = new Access(url)
const {claim, loading} = access
const inviteStatus = access.createInviteStatus(false)
const back = () => history.back()
const copyInvite = () => clip(invite)
const shareInvite = async () => {
if (!canShare) {
return
}
try {
await Share.share({url: invite})
} catch (e) {
console.error(e)
}
}
const addMembers = async () => {
adding = true
try {
const members = $relayMemberLists.get(url)
const management = $relayManagement.forUrl(url)
const responses = await Promise.all(
pubkeys.filter(pk => !members?.isMember(pk)).map(pk => management.allowPubkey(pk)),
)
const error = responses.map(response => response.error).find(Boolean)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
}
} finally {
adding = false
}
}
const permissions = deriveSpacePermissions(url)
let canShare = $state(false)
let invite = $derived(makeInviteLink({url, claim: $claim}))
const back = () => history.back()
let adding = $state(false)
let pubkeys: string[] = $state([])
onMount(async () => {
try {
const {value} = await Share.canShare()
canShare = value
} catch {
canShare = false
}
await access.prepareInvite()
})
const addMembers = () => pushModal(SpaceMembersAdd, {url}, {replaceState: true})
</script>
<Modal>
@ -102,84 +36,15 @@
<span class="text-primary">{displayRelayUrl(url)}</span>
</ModalSubtitle>
</ModalHeader>
<div>
{#if $inviteStatus === "loading"}
<p class="flex justify-center items-center">
<Spinner loading={$loading}>Requesting an invite link...</Spinner>
</p>
{:else if $inviteStatus === "network"}
<p class="flex justify-center items-center">
Unable to reach the space. Please check your connection and try again.
</p>
{:else if $inviteStatus === "auth"}
<p class="flex justify-center items-center">
Oops! It looks like you're not a member of this space.
</p>
{:else}
<div class="flex flex-col items-center gap-6">
<div class="w-48">
<QRCode code={invite} />
</div>
<Field>
{#snippet input()}
<div class="flex w-full gap-2">
{#if canShare}
<Button
class="input flex w-12 shrink-0 items-center justify-center p-0"
onclick={shareInvite}>
<Icon icon={Upload} />
</Button>
{/if}
<label class="input flex min-w-0 flex-1 items-center gap-2">
<Icon icon={LinkRound} class="shrink-0" />
<input bind:value={invite} class="min-w-0 flex-1 truncate" type="text" readonly />
<Button class="shrink-0" onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
</div>
{/snippet}
{#snippet info()}
<p>
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}
</p>
{/snippet}
</Field>
</div>
{/if}
</div>
{#if $permissions.addMembers}
<Divider>or</Divider>
<Field>
{#snippet label()}
<p>Add members directly</p>
{/snippet}
{#snippet input()}
<ProfileMultiSelect bind:value={pubkeys} />
{/snippet}
</Field>
{/if}
<InviteLink {url} />
</ModalBody>
<ModalFooter>
{#if $permissions.addMembers}
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button
class="button button-primary"
onclick={addMembers}
disabled={adding || pubkeys.length === 0}>
<Spinner loading={adding}>Save</Spinner>
<Button class="button button-link" onclick={addMembers}>
<Icon icon={UserPlus} />
Add members
</Button>
<Button class="button button-primary" onclick={back}>Done</Button>
{:else}
<Button class="button button-primary grow" onclick={back}>Done</Button>
{/if}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {get} from "svelte/store"
import {spec} from "@welshman/lib"
import {difference, spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@ -13,7 +13,12 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {profiles, relayManagement} from "@app/core"
import {MANAGEMENT_METHOD_GROUPS, deriveSpacePermissions, spaceManagement} from "@app/management"
import {
MANAGEMENT_METHOD_GROUPS,
deriveSpacePermissions,
spaceManagement,
syncAssignments,
} from "@app/management"
import {pushToast} from "@app/toast"
type Props = {
@ -29,7 +34,7 @@
// A relay can grant methods this client doesn't know, and leaving them out would revoke them.
const groups = $derived.by(() => {
const known = MANAGEMENT_METHOD_GROUPS.flatMap(group => group.methods.map(({method}) => method))
const extra = [...assigned].filter(method => !known.includes(method))
const extra = difference(assigned, known)
if (extra.length > 0) {
return [
@ -43,57 +48,33 @@
const back = () => history.back()
const toggle = (method: string) => {
const next = new Set(selected)
if (next.has(method)) {
next.delete(method)
} else {
next.add(method)
}
selected = next
}
const submit = async () => {
saving = true
try {
const management = $relayManagement.forUrl(url)
const error = await syncAssignments(
assigned,
selected,
method => management.assignMethod(pubkey, method),
method => management.unassignMethod(pubkey, method),
)
for (const method of selected) {
if (!assigned.has(method)) {
const {error} = await management.assignMethod(pubkey, method)
if (error) {
pushToast({theme: "error", message: error})
} else {
await $spaceManagement.loadMethodAssignees(url)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
pushToast({message: "Permissions updated!"})
back()
}
for (const method of assigned) {
if (!selected.has(method)) {
const {error} = await management.unassignMethod(pubkey, method)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
await $spaceManagement.loadMethodAssignees(url)
pushToast({message: "Permissions updated!"})
back()
} finally {
saving = false
}
}
let assigned = $state(new Set<string>())
let selected = $state(new Set<string>())
let assigned: string[] = $state([])
let selected: string[] = $state([])
let loading = $state(true)
let saving = $state(false)
@ -104,10 +85,8 @@
pushToast({theme: "error", message: error})
}
assigned = new Set(
get($spaceManagement.methodAssignees(url)).find(spec({pubkey}))?.methods ?? [],
)
selected = new Set(assigned)
assigned = get($spaceManagement.methodAssignees(url)).find(spec({pubkey}))?.methods ?? []
selected = [...assigned]
loading = false
})
</script>
@ -133,11 +112,11 @@
<input
type="checkbox"
class="checkbox"
checked={selected.has(method)}
disabled={selected.has(method)
value={method}
bind:group={selected}
disabled={selected.includes(method)
? !$permissions.unassignMethods
: !$permissions.assignMethods}
onchange={() => toggle(method)} />
: !$permissions.assignMethods} />
</label>
{/each}
</div>

View file

@ -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)
</script>
@ -109,8 +85,8 @@
<input
type="checkbox"
class="checkbox"
checked={selected.has(role.identifier() ?? "")}
onchange={() => toggle(role.identifier() ?? "")} />
value={role.identifier() ?? ""}
bind:group={selected} />
</label>
{/each}
</div>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {allowPubkeys} from "@app/management"
import {pushToast} from "@app/toast"
type Props = {
url: string
}
const {url}: Props = $props()
const back = () => history.back()
const submit = async () => {
loading = true
try {
const error = await allowPubkeys(url, pubkeys)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
}
} finally {
loading = false
}
}
let loading = $state(false)
let pubkeys: string[] = $state([])
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Add Members</ModalTitle>
<ModalSubtitle>
to <span class="text-primary">{displayRelayUrl(url)}</span>
</ModalSubtitle>
</ModalHeader>
<Field>
{#snippet label()}
<p>Search for People</p>
{/snippet}
{#snippet input()}
<ProfileMultiSelect bind:value={pubkeys} />
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button
class="button button-primary"
onclick={submit}
disabled={loading || pubkeys.length === 0}>
<Spinner {loading}>Save</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -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)

View file

@ -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(