Add roles

This commit is contained in:
Jon Staab
2026-06-22 13:36:57 -07:00
parent fd4e7a9f2d
commit 7ec5a28d1f
42 changed files with 1169 additions and 384 deletions
+13 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import {
@@ -37,10 +38,11 @@
interface Props {
event: any
trimParent?: boolean
singleLine?: boolean
url?: string
}
const {event, trimParent = false, url}: Props = $props()
const {event, trimParent = false, singleLine = false, url}: Props = $props()
const fullContent = parse(event)
@@ -104,10 +106,18 @@
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis wrap-break-word">
<div
class={cx(
"overflow-hidden text-ellipsis",
singleLine ? "whitespace-nowrap" : "wrap-break-word",
)}>
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
{#if singleLine}
{" "}
{:else}
<ContentNewline value={parsed.value} />
{/if}
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
+1 -1
View File
@@ -33,7 +33,7 @@
</script>
<div
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block border-r border-solid border-base-content/15 dark:border-base-content/10">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
+2 -2
View File
@@ -33,7 +33,7 @@
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script>
<div class="flex max-w-full items-start gap-3">
<div class="flex max-w-full items-start gap-2">
{#if inert}
<span class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
@@ -46,7 +46,7 @@
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
{#if inert}
<span class="text-bold overflow-hidden text-ellipsis">
<span class="text-bold overflow-hidden text-ellipsis whitespace-nowrap">
{$profileDisplay}
</span>
{:else}
+8 -12
View File
@@ -1,13 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {
manageRelay,
deriveProfile,
displayProfileByPubkey,
loadMessagingRelayList,
} from "@welshman/app"
import {deriveProfile, displayProfileByPubkey, loadMessagingRelayList} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
@@ -29,7 +23,12 @@
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink} from "@app/env"
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
import {
deriveUserIsSpaceAdmin,
deriveSpaceBannedPubkeyItems,
addSpaceMembers,
banSpaceMembers,
} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {goToChat} from "@app/routes"
@@ -68,10 +67,7 @@
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url!, {
method: ManagementMethod.BanPubkey,
params: [pubkey],
})
const error = await banSpaceMembers(url!, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
+3 -2
View File
@@ -5,14 +5,15 @@
export type Props = {
pubkey: string
singleLine?: boolean
url?: string
}
const {pubkey, url}: Props = $props()
const {pubkey, url, singleLine}: Props = $props()
const profile = deriveProfile(pubkey, removeUndefined([url]))
</script>
{#if $profile}
<ContentMinimal event={{content: $profile.about || "", tags: []}} />
<ContentMinimal event={{content: $profile.about || "", tags: []}} {singleLine} />
{/if}
+2 -5
View File
@@ -12,7 +12,7 @@
import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {deriveUserIsSpaceAdmin, banSpaceMembers} from "@app/members"
import {publishDelete} from "@app/deletes"
import {canEnforceNip70} from "@app/relays"
import {pushToast} from "@app/toast"
@@ -91,10 +91,7 @@
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanPubkey,
params: [pubkey, reason],
})
const error = await banSpaceMembers(url, [pubkey], reason)
if (error) {
pushToast({theme: "error", message: error})
@@ -1,5 +1,4 @@
<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"
@@ -12,43 +11,55 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/members"
import {addSpaceMembers, assignRole, type SpaceRole} from "@app/members"
import {pushToast} from "@app/toast"
interface Props {
url: string
role: SpaceRole
}
const {url}: Props = $props()
const {url, role}: Props = $props()
const back = () => history.back()
const addMember = async () => {
let loading = $state(false)
let pubkeys: string[] = $state([])
const submit = async () => {
loading = true
try {
const error = await addSpaceMembers(url, pubkeys)
// Ensure they're space members first, then assign the role
const memberError = await addSpaceMembers(url, pubkeys)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
if (memberError) {
pushToast({theme: "error", message: memberError})
return
}
for (const pubkey of pubkeys) {
const error = await assignRole(url, pubkey, role.id)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
pushToast({message: "Members assigned!"})
back()
} finally {
loading = false
}
}
let loading = $state(false)
let pubkeys: string[] = $state([])
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Add Members</ModalTitle>
<ModalSubtitle>to {displayRelayUrl(url)}</ModalSubtitle>
<ModalTitle>Add to {role.label || "Role"}</ModalTitle>
<ModalSubtitle>Assign members to this role</ModalSubtitle>
</ModalHeader>
<Field>
{#snippet label()}
@@ -64,7 +75,7 @@
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={addMember} disabled={loading}>
<Button class="btn btn-primary" onclick={submit} disabled={loading || pubkeys.length === 0}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import {roleColor, roleColorSoft, type SpaceRole} from "@app/members"
interface Props {
role: SpaceRole
}
const {role}: Props = $props()
</script>
<div
class="badge min-w-0"
style="background-color: {roleColorSoft(role.color)}; border-color: {roleColor(
role.color,
)}; color: {roleColor(role.color)};">
<strong>{role.label || "Untitled Role"}</strong>
</div>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import {randomId} from "@welshman/lib"
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 RelayName from "@app/components/RelayName.svelte"
import RoleForm, {type Values} from "@app/components/RoleForm.svelte"
import {createRole} from "@app/members"
import {pushToast} from "@app/toast"
type Props = {
url: string
}
const {url}: Props = $props()
const back = () => history.back()
let loading = $state(false)
const onSubmit = async ({label, description, color}: Values) => {
loading = true
try {
const error = await createRole(url, randomId(), label, description, color, 0)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Role created!"})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Create Role</ModalTitle>
<ModalSubtitle>in <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<RoleForm {loading} {onSubmit} />
</ModalBody>
</Modal>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
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 RelayName from "@app/components/RelayName.svelte"
import RoleForm, {type Values} from "@app/components/RoleForm.svelte"
import {editRole, type SpaceRole} from "@app/members"
import {pushToast} from "@app/toast"
type Props = {
url: string
role: SpaceRole
}
const {url, role}: Props = $props()
const back = () => history.back()
let loading = $state(false)
const onSubmit = async ({label, description, color}: Values) => {
loading = true
try {
const error = await editRole(url, role.id, label, description, color, role.order)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Role updated!"})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Edit Role</ModalTitle>
<ModalSubtitle>in <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<RoleForm {loading} {onSubmit} initialValues={role} />
</ModalBody>
</Modal>
+86
View File
@@ -0,0 +1,86 @@
<script module lang="ts">
import type {SpaceRole} from "@app/members"
export type Values = Pick<SpaceRole, "label" | "description" | "color">
</script>
<script lang="ts">
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 AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {roleColor} from "@app/members"
type Props = {
initialValues?: Partial<Values>
loading?: boolean
onSubmit: (values: Values) => void
}
const {initialValues = {}, loading = false, onSubmit}: Props = $props()
const values: Values = $state({
label: "",
description: "",
color: Math.floor(Math.random() * 256),
...initialValues,
})
const back = () => history.back()
const submit = () => onSubmit(values)
</script>
<div class="flex flex-col gap-4">
<Field>
{#snippet label()}
<p>Name</p>
{/snippet}
{#snippet input()}
<input
bind:value={values.label}
class="input input-bordered w-full"
placeholder="Moderator" />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Description</p>
{/snippet}
{#snippet input()}
<textarea bind:value={values.description} class="textarea textarea-bordered w-full" rows="2"
></textarea>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Color</p>
{/snippet}
{#snippet input()}
<div class="flex items-center gap-3">
<div
class="h-8 w-8 shrink-0 rounded-full border-2 border-base-300"
style="background-color: {roleColor(values.color)}">
</div>
<input
type="range"
min="0"
max="255"
bind:value={values.color}
class="range range-sm grow"
style="color: {roleColor(values.color)}; --range-shdw: {roleColor(values.color)}" />
</div>
{/snippet}
</Field>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading || !values.label}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import RoleBadge from "@app/components/RoleBadge.svelte"
import type {SpaceRole} from "@app/members"
interface Props {
role: SpaceRole
}
const {role}: Props = $props()
</script>
<div class="flex min-w-0 flex-col gap-2">
<RoleBadge {role} />
{#if role.description}
<p class="text-sm opacity-70">{role.description}</p>
{/if}
</div>
+1 -1
View File
@@ -33,7 +33,7 @@
<div class="flex min-w-0 flex-col">
<div class="flex min-w-0 items-start gap-2">
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
<div class="hidden shrink-0 md:flex md:items-center">
<div class="hidden shrink-0 md:flex md:items-center place-self-center">
{@render leading?.()}
</div>
<div class="min-w-0">
-104
View File
@@ -1,104 +0,0 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Button from "@lib/components/Button.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLatest from "@app/components/ProfileLatest.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {pushModal} from "@app/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const owner = $derived($relay?.pubkey)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const back = () => history.back()
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
</script>
<Modal>
<ModalBody>
<div class="flex justify-between">
<div class="relative flex gap-4">
<div class="relative">
<div class="avatar relative">
<div
class="center flex! h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} size={10} />
</div>
</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
<RelayName {url} />
</h1>
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
</div>
</div>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit
</Button>
{/if}
</div>
<RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy}
<div class="flex gap-3">
{#if $relay.terms_of_service}
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.privacy_policy}
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
<SpaceRelayStatus {url} />
<div class="flex flex-col gap-2">
{#if owner}
<div class="card2 bg-alt">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Icon icon={UserRounded} />
Latest Updates
</h3>
<ProfileLatest {url} pubkey={owner}>
{#snippet fallback()}
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
{/snippet}
</ProfileLatest>
</div>
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>
@@ -1,9 +1,27 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {pushModal} from "@app/modal"
interface Props {
url: string
@@ -12,16 +30,48 @@
const {url}: Props = $props()
const relay = deriveRelay(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
</script>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Icon icon={Server} />
Relay Details
</h3>
<SocketStatusIndicator {url} />
<div class="flex justify-between">
<div class="relative flex gap-4">
<div class="relative">
<RelayIcon {url} size={14} class="rounded-full" />
</div>
<div class="flex min-w-0 flex-col">
<h1 class="ellipsize whitespace-nowrap">
<RelayName {url} class="text-2xl font-bold" />
</h1>
<p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
</div>
</div>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit
</Button>
{/if}
</div>
<RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy}
<div class="flex gap-3">
{#if $relay.terms_of_service}
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.privacy_policy}
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
{#if $relay}
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
<div class="flex flex-wrap gap-1">
+1 -1
View File
@@ -126,7 +126,7 @@
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Edit a Space</ModalTitle>
<ModalTitle>Edit this Space</ModalTitle>
<ModalSubtitle><span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
</ModalHeader>
<FieldInline>
+58 -5
View File
@@ -2,29 +2,36 @@
import {onMount} from "svelte"
import {sleep} from "@welshman/lib"
import {request} from "@welshman/net"
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
import {displayRelayUrl, getTagValue, ManagementMethod, RELAY_INVITE} 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 AltArrowLeft from "@assets/icons/alt-arrow-left.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 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 QRCode from "@app/components/QRCode.svelte"
import {clip} from "@app/toast"
import {clip, pushToast} from "@app/toast"
import {PLATFORM_URL} from "@app/env"
import {deriveRelayAuthError} from "@app/relays"
import {deriveRelayAuthError, deriveSupportedMethods} from "@app/relays"
import {addSpaceMembers} from "@app/members"
const {url} = $props()
const supportedMethods = deriveSupportedMethods(url)
const canAddMembers = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
const authError = deriveRelayAuthError(url)
let networkError = $state(false)
const isExplicitAuthError = $derived(
$authError &&
@@ -54,6 +61,28 @@
let loading = $state(true)
let invite = $state("")
let adding = $state(false)
let pubkeys: string[] = $state([])
const addMembers = async () => {
if (pubkeys.length === 0) return
adding = true
try {
const error = await addSpaceMembers(url, pubkeys)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
}
} finally {
adding = false
}
}
$effect(() => {
const relay = displayRelayUrl(url)
const params = new URLSearchParams({r: relay, c: claim}).toString()
@@ -124,7 +153,7 @@
<div class="flex w-full gap-2">
{#if canShare}
<Button
class="input input-bordered flex shrink-0 w-12 items-center justify-center p-0"
class="input input-bordered flex w-12 shrink-0 items-center justify-center p-0"
onclick={shareInvite}>
<Icon icon={Upload} />
</Button>
@@ -152,8 +181,32 @@
</div>
{/if}
</div>
{#if canAddMembers}
<Divider>or</Divider>
<Field>
{#snippet label()}
<p>Add members directly</p>
{/snippet}
{#snippet input()}
<ProfileMultiSelect bind:value={pubkeys} />
{/snippet}
</Field>
{/if}
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
{#if canAddMembers}
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button
class="btn btn-primary"
onclick={addMembers}
disabled={adding || pubkeys.length === 0}>
<Spinner loading={adding}>Save</Spinner>
</Button>
{:else}
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
{/if}
</ModalFooter>
</Modal>
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import {ManagementMethod} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import {removeSpaceMembers, banSpaceMembers, type SpaceRole} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {goToChat} from "@app/routes"
interface Props {
url: string
pubkey: string
roles?: SpaceRole[]
}
const {url, pubkey, roles = []}: Props = $props()
const supportedMethods = deriveSupportedMethods(url)
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
const canAssign = $derived($supportedMethods.some(m => (m as string) === "assignrole"))
const canUnassign = $derived($supportedMethods.some(m => (m as string) === "unassignrole"))
let menuOpen = $state(false)
const back = () => history.back()
const closeMenu = () => (menuOpen = false)
const openProfile = () => {
menuOpen = false
pushModal(ProfileDetail, {pubkey, url})
}
const sendMessage = () => {
menuOpen = false
goToChat([pubkey])
}
const editRoles = () => {
menuOpen = false
pushModal(SpaceMemberRoles, {url, pubkey})
}
const removeMember = () => {
menuOpen = false
pushModal(Confirm, {
title: "Remove Member",
message: `Remove @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const error = await removeSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been removed!"})
back()
}
},
})
}
const banMember = () => {
menuOpen = false
pushModal(Confirm, {
title: "Ban Member",
message: `Ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const error = await banSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been banned!"})
back()
}
},
})
}
</script>
<div class="card2 card2-sm border border-solid border-base-content/20 relative">
<button
type="button"
class="absolute inset-0 cursor-pointer rounded-box"
aria-label="View {displayProfileByPubkey(pubkey)}'s profile"
onclick={openProfile}>
</button>
<div class="pointer-events-none relative flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<Profile {pubkey} {url} inert />
{#if roles.length > 0}
<div class="flex flex-wrap gap-1">
{#each roles as role (role.id)}
<RoleBadge {role} />
{/each}
</div>
{/if}
<div class="line-clamp-1 text-sm opacity-70">
<ProfileInfo {pubkey} {url} singleLine />
</div>
</div>
<div class="pointer-events-auto relative shrink-0">
<Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}>
<Icon icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={sendMessage}>
<Icon icon={Letter} />
Send message
</Button>
</li>
{#if canAssign || canUnassign}
<li>
<Button onclick={editRoles}>
<Icon icon={Pen} />
Edit roles
</Button>
</li>
{/if}
{#if canUnallow}
<li>
<Button onclick={removeMember}>
<Icon icon={UserMinus} />
Remove member
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban member
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
</div>
</div>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import {get} from "svelte/store"
import {displayProfileByPubkey} from "@welshman/app"
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 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 RoleItem from "@app/components/RoleItem.svelte"
import {deriveSpaceRoles, deriveSpaceMemberRoles, assignRole, unassignRole} from "@app/members"
import {pushToast} from "@app/toast"
interface Props {
url: string
pubkey: string
}
const {url, pubkey}: Props = $props()
const roles = deriveSpaceRoles(url)
const memberRoles = deriveSpaceMemberRoles(url)
const initial = new Set(get(memberRoles).get(pubkey) ?? [])
let selected = $state(new Set(initial))
let loading = $state(false)
const back = () => history.back()
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 {
for (const id of selected) {
if (!initial.has(id)) {
const error = await assignRole(url, pubkey, id)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
for (const id of initial) {
if (!selected.has(id)) {
const error = await unassignRole(url, pubkey, id)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
pushToast({message: "Roles updated!"})
back()
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Edit Member</ModalTitle>
<ModalSubtitle>
Manage roles for <span class="text-primary">@{displayProfileByPubkey(pubkey)}</span>
</ModalSubtitle>
</ModalHeader>
{#if $roles.length === 0}
<div class="card2 bg-base-200 p-4 text-sm opacity-70">This space has no roles yet.</div>
{:else}
<div class="flex flex-col gap-2">
{#each $roles as role (role.id)}
<label class="card2 card2-sm bg-alt flex cursor-pointer items-center gap-3">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={selected.has(role.id)}
onchange={() => toggle(role.id)} />
<RoleItem {role} />
</label>
{/each}
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</Modal>
-181
View File
@@ -1,181 +0,0 @@
<script lang="ts">
import {ManagementMethod} from "@welshman/util"
import {manageRelay, displayProfileByPubkey} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.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 RelayName from "@app/components/RelayName.svelte"
import Profile from "@app/components/Profile.svelte"
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {
deriveSpaceMembers,
deriveSpaceBannedPubkeyItems,
deriveUserIsSpaceAdmin,
} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const members = deriveSpaceMembers(url)
const bans = deriveSpaceBannedPubkeyItems(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const supportedMethods = deriveSupportedMethods(url)
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const showBannedPubkeyItems = () => pushModal(SpaceMembersBanned, {url})
const addMember = () => pushModal(SpaceMembersAdd, {url})
const unallowMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Remove User",
message: `Are you sure you want to remove @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.UnallowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been removed!"})
back()
}
},
})
const banMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
back()
}
},
})
let menuPubkey = $state<string | undefined>()
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Members</ModalTitle>
<ModalSubtitle>of <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
{#if $userIsAdmin}
{#if $bans.length > 0}
<Button class="btn btn-neutral" onclick={showBannedPubkeyItems}>
Banned users ({$bans.length})
</Button>
{/if}
{/if}
<div class="flex flex-col gap-2">
{#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this space</span>
</div>
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{/if}
</ModalFooter>
</Modal>
@@ -76,6 +76,9 @@
<ModalSubtitle>on {displayRelayUrl(url)}</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#if $bans.length === 0}
<div class="card2 bg-base-200 p-4 text-sm opacity-70">No banned users.</div>
{/if}
{#each $bans as { pubkey, reason } (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
+7 -27
View File
@@ -6,7 +6,7 @@
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Home from "@assets/icons/home.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
@@ -29,12 +29,10 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SpaceDetail from "@app/components/SpaceDetail.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
@@ -42,7 +40,7 @@
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {ENABLE_ZAPS} from "@app/env"
import {CONTENT_KINDS} from "@app/content"
import {deriveSpaceMembers, deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
import {
deriveUserRooms,
deriveOtherRooms,
@@ -70,7 +68,6 @@
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const actionItems = deriveSpaceActionItems(url)
@@ -97,10 +94,6 @@
showMenu = !showMenu
}
const showDetail = () => pushModal(SpaceDetail, {url})
const showMembers = () => pushModal(SpaceMembers, {url})
const showActionItems = () => pushModal(SpaceActionItems, {url})
const canCreateRoom = deriveUserCanCreateRoom(url)
@@ -164,22 +157,6 @@
Create Invite
</Button>
</li>
<li>
<Button onclick={showDetail}>
<Icon icon={RemoteControllerMinimalistic} />
Space Information
</Button>
</li>
<li>
<Button onclick={showMembers}>
<Icon icon={UserRounded} />
{#if $members === undefined}
View Members
{:else}
View Members ({$members.length})
{/if}
</Button>
</li>
{#if $userIsAdmin}
<li>
<Button onclick={showActionItems}>
@@ -230,6 +207,9 @@
{/if}
</div>
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29($relay)}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
@@ -311,8 +291,8 @@
<div
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<Link href={makeSpacePath("about")} class="btn btn-neutral btn-sm h-10">
<SocketStatusIndicator {url} />
</Button>
</Link>
</div>
</div>
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import {fly} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.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 RelayName from "@app/components/RelayName.svelte"
import RoleCreate from "@app/components/RoleCreate.svelte"
import RoleEdit from "@app/components/RoleEdit.svelte"
import RoleAddMembers from "@app/components/RoleAddMembers.svelte"
import RoleItem from "@app/components/RoleItem.svelte"
import {deriveSpaceRoles, deleteRole, type SpaceRole} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const roles = deriveSpaceRoles(url)
let menuRoleId = $state<string | undefined>()
const back = () => history.back()
const closeMenu = () => (menuRoleId = undefined)
const createRole = () => pushModal(RoleCreate, {url})
const editRole = (role: SpaceRole) => {
menuRoleId = undefined
pushModal(RoleEdit, {url, role})
}
const addMembers = (role: SpaceRole) => {
menuRoleId = undefined
pushModal(RoleAddMembers, {url, role})
}
const confirmDelete = (role: SpaceRole) => {
menuRoleId = undefined
pushModal(Confirm, {
title: "Delete Role",
message: `Delete the "${role.label}" role? Members will keep their space membership.`,
confirm: async () => {
const error = await deleteRole(url, role.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Role deleted!"})
back()
}
},
})
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Manage Roles</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
{#if $roles.length === 0}
<div class="card2 bg-base-200 p-4 text-sm opacity-70">
No roles yet. Create one to start organizing members.
</div>
{:else}
<div class="flex flex-col gap-2">
{#each $roles as role (role.id)}
<div class="card2 card2-sm bg-alt flex justify-between gap-2">
<RoleItem {role} />
<div class="relative shrink-0">
<Button
class="btn btn-square btn-ghost btn-sm"
onclick={() => (menuRoleId = menuRoleId === role.id ? undefined : role.id)}>
<Icon icon={MenuDots} />
</Button>
{#if menuRoleId === role.id}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={() => addMembers(role)}>
<Icon icon={AddCircle} />
Add members
</Button>
</li>
<li>
<Button onclick={() => editRole(role)}>
<Icon icon={Pen} />
Edit role
</Button>
</li>
<li>
<Button class="text-error" onclick={() => confirmDelete(role)}>
<Icon icon={TrashBin} />
Delete role
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={createRole}>
<Icon icon={AddCircle} />
Create Role
</Button>
</ModalFooter>
</Modal>