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:
parent
0eebfc7dc1
commit
97d1b1dc49
12 changed files with 319 additions and 379 deletions
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
|
||||
|
|
|
|||
115
src/app/components/InviteLink.svelte
Normal file
115
src/app/components/InviteLink.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
76
src/app/components/SpaceMembersAdd.svelte
Normal file
76
src/app/components/SpaceMembersAdd.svelte
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue