Migrate popovers to tippys

This commit is contained in:
Jon Staab 2026-07-03 09:20:45 -07:00
parent 8d7d021594
commit 79c154bdcb
17 changed files with 736 additions and 672 deletions

View file

@ -1,21 +1,12 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {Address} from "@welshman/util"
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 {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Badge from "@lib/components/Badge.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Content from "@app/components/Content.svelte"
import PinContentEvent from "@app/components/PinContentEvent.svelte"
import PinEdit from "@app/components/PinEdit.svelte"
import {deletePin, type Pin} from "@app/pinboards"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import PinMenu from "@app/components/PinMenu.svelte"
import {type Pin} from "@app/pinboards"
type Props = {
url: string
@ -42,61 +33,17 @@
return data
})
let menuOpen = $state(false)
const edit = () => {
menuOpen = false
pushModal(PinEdit, {url, pin})
}
const confirmDelete = () => {
menuOpen = false
pushModal(Confirm, {
title: "Delete Link",
message: "Delete this link?",
confirm: async () => {
const error = await deletePin(url, pin.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Link deleted!"})
}
},
})
}
</script>
<div class="{props.class} relative flex h-full flex-col gap-2">
{#if showMenu}
<div class="absolute right-2 top-2 z-feature">
<Button
<MenuButton
class="button button-neutral button-sm button-square"
aria-label="More options"
onclick={() => (menuOpen = !menuOpen)}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={() => (menuOpen = false)}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li>
<Button onclick={edit}>
<Icon icon={Pen} />
Edit link
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete link
</Button>
</li>
</ul>
</Popover>
{/if}
iconSize={4}
component={PinMenu}
componentProps={{url, pin}} />
</div>
{#if pin.title}
<strong class="truncate min-w-0 pr-8">{pin.title}</strong>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import {onMount} from "svelte"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import PinEdit from "@app/components/PinEdit.svelte"
import {deletePin, type Pin} from "@app/pinboards"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
pin: Pin
onClick: () => void
}
const {url, pin, onClick}: Props = $props()
const edit = () => pushModal(PinEdit, {url, pin})
const confirmDelete = () =>
pushModal(Confirm, {
title: "Delete Link",
message: "Delete this link?",
confirm: async () => {
const error = await deletePin(url, pin.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Link deleted!"})
}
},
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={edit}>
<Icon icon={Pen} />
Edit link
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete link
</Button>
</li>
</ul>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import {onMount} from "svelte"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
type Props = {
onDelete: () => void
onClick: () => void
}
const {onDelete, onClick}: Props = $props()
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={onDelete}>
<Icon icon={TrashBin2} />
Delete Session
</Button>
</li>
</ul>

View file

@ -2,26 +2,13 @@
import {onMount} from "svelte"
import {Client} from "@pomade/core"
import {session, isPomadeSession} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Badge from "@lib/components/Badge.svelte"
import Popover from "@lib/components/Popover.svelte"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import PomadeSessionMenu from "@app/components/PomadeSessionMenu.svelte"
import {pushToast} from "@app/toast"
import {loadOtherPomadeSessions} from "@app/pomade"
import type {PomadeSessionWithPeers} from "@app/pomade"
const toggleMenu = (client: string) => {
menuClient = menuClient === client ? "" : client
}
const closeMenu = () => {
menuClient = ""
}
let menuClient = $state("")
let sessions = $state<PomadeSessionWithPeers[]>([])
const deleteSession = async (sessionItem: PomadeSessionWithPeers) => {
@ -83,27 +70,9 @@
{/if}
</span>
</div>
<div class="relative">
<Button
class="button button-ghost button-sm button-circle"
onclick={() => toggleMenu(sessionItem.client)}>
<Icon icon={MenuDots} />
</Button>
{#if menuClient === sessionItem.client}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li>
<Button onclick={() => deleteSession(sessionItem)}>
<Icon icon={TrashBin2} />
Delete Session
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
<MenuButton
component={PomadeSessionMenu}
componentProps={{onDelete: () => deleteSession(sessionItem)}} />
</div>
<div class="flex gap-1">
<Badge variant="neutral">

View file

@ -1,22 +1,7 @@
<script lang="ts">
import {getTag, ManagementMethod} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin, banSpaceMembers} from "@app/members"
import {publishDelete} from "@app/deletes"
import {canEnforceNip70} from "@app/relays"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import MenuButton from "@lib/components/MenuButton.svelte"
import ReportMenuList from "@app/components/ReportMenuList.svelte"
type Props = {
url: string
@ -25,131 +10,6 @@
}
const {url, event, onResolved}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
const toggleMenu = () => {
isOpen = !isOpen
}
const closeMenu = () => {
isOpen = false
}
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onResolved?.()
}
const dismissReport = async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id, "Dismissed by admin"],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
onResolved?.()
}
}
const banContent = () => {
const [_, id, reason = ""] = etag!
pushModal(Confirm, {
title: `Remove Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [id, reason],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
repository.removeEvent(id)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
const banMember = () => {
const [_, pubkey, reason = ""] = ptag!
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const error = await banSpaceMembers(url, [pubkey], reason)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
let isOpen = $state(false)
</script>
<div class="relative">
<Button class="button button-ghost button-sm button-circle" onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if isOpen}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
{#if event.pubkey === $pubkey}
<li>
<Button onclick={deleteReport}>
<Icon icon={Close} />
Delete Report
</Button>
</li>
{/if}
{#if $userIsAdmin}
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{#if ptag}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>
</Popover>
{/if}
</div>
<MenuButton component={ReportMenuList} componentProps={{url, event, onResolved}} />

View file

@ -0,0 +1,139 @@
<script lang="ts">
import {onMount} from "svelte"
import {getTag, ManagementMethod} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin, banSpaceMembers} from "@app/members"
import {publishDelete} from "@app/deletes"
import {canEnforceNip70} from "@app/relays"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
onClick: () => void
}
const {url, event, onResolved, onClick}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onResolved?.()
}
const dismissReport = async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id, "Dismissed by admin"],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
onResolved?.()
}
}
const banContent = () => {
const [_, id, reason = ""] = etag!
pushModal(Confirm, {
title: `Remove Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [id, reason],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
repository.removeEvent(id)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
const banMember = () => {
const [_, pubkey, reason = ""] = ptag!
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const error = await banSpaceMembers(url, [pubkey], reason)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if event.pubkey === $pubkey}
<li>
<Button onclick={deleteReport}>
<Icon icon={Close} />
Delete Report
</Button>
</li>
{/if}
{#if $userIsAdmin}
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{#if ptag}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>

View file

@ -1,14 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import type {RoomMeta} from "@welshman/util"
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
import type {Thunk} from "@welshman/app"
import {deleteRoom, waitForThunkError, repository, joinRoom, leaveRoom} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Login3 from "@assets/icons/login-3.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
import Eye from "@assets/icons/eye.svg?dataurl"
@ -17,32 +8,22 @@
import Microphone from "@assets/icons/microphone.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Tooltip from "@lib/components/Tooltip.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import RoomMembers from "@app/components/RoomMembers.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte"
import RoomDetailMenu from "@app/components/RoomDetailMenu.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import {deriveRoom, deriveUserRooms, addRoom, removeRoom} from "@app/groups"
import {
deriveRoomMembers,
deriveUserIsRoomAdmin,
deriveUserRoomMembershipStatus,
MembershipStatus,
} from "@app/members"
import {deriveRoomMembers} from "@app/members"
import {deriveShouldNotify, toggleRoomNotifications} from "@app/settings"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
@ -53,8 +34,6 @@
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const userRooms = deriveUserRooms(url)
const isFavorite = $derived($userRooms.includes(h))
@ -62,34 +41,6 @@
const back = () => history.back()
const toggleMenu = () => {
showMenu = !showMenu
}
const closeMenu = () => {
showMenu = false
}
const startEdit = () => pushModal(RoomEdit, {url, h})
const handleLoading = async (f: (url: string, room: RoomMeta) => Thunk) => {
loading = true
try {
const message = await waitForThunkError(f(url, makeRoomMeta({h})))
if (message && !message.startsWith("duplicate:")) {
pushToast({theme: "error", message})
}
} finally {
loading = false
}
}
const join = () => handleLoading(joinRoom)
const leave = () => handleLoading(leaveRoom)
const showMembers = () => pushModal(RoomMembers, {url, h})
const toggleFavorite = () => {
@ -103,28 +54,6 @@
const toggleShouldNotify = () => {
toggleRoomNotifications(url, h)
}
const startDelete = () =>
pushModal(Confirm, {
title: "Are you sure you want to delete this room?",
message:
"This room will no longer be accessible to space members, and all messages posted to it will be deleted.",
confirm: async () => {
const thunk = deleteRoom(url, $room)
const message = await waitForThunkError(thunk)
if (message) {
repository.removeEvent(thunk.event.id)
pushToast({theme: "error", message})
} else {
await removeRoom(url, h)
goto(makeSpacePath(url))
}
},
})
let loading = $state(false)
let showMenu = $state(false)
</script>
<Modal>
@ -139,62 +68,7 @@
<span class="text-primary">{displayRelayUrl(url)}</span>
</div>
</div>
<div class="relative">
<Button class="button button-ghost button-sm button-circle" onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="bg-surface menu absolute right-0 z-popover w-48 gap-1 rounded-2xl p-2 shadow-md">
{#if $userIsAdmin}
<li>
<Button onclick={startEdit}>
<Icon icon={Pen} />
Edit Room
</Button>
</li>
<li>
<Button class="text-error" onclick={startDelete}>
<Icon icon={TrashBin2} />
Delete Room
</Button>
</li>
{:else if $membershipStatus === MembershipStatus.Initial}
<li>
<Button disabled={loading} onclick={join}>
{#if loading}
<Spinner size="sm" />
{:else}
<Icon icon={Login3} />
{/if}
Join member list
</Button>
</li>
{:else if $membershipStatus === MembershipStatus.Pending}
<li>
<Button>
<Icon icon={ClockCircle} />
Membership pending
</Button>
</li>
{:else}
<li>
<Button disabled={loading} onclick={leave}>
{#if loading}
<Spinner size="sm" />
{:else}
<Icon icon={Login3} />
{/if}
Leave member list
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
<MenuButton component={RoomDetailMenu} componentProps={{url, h}} />
</div>
{#if $room?.about}
<p>{$room.about}</p>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import type {RoomMeta} from "@welshman/util"
import {makeRoomMeta} from "@welshman/util"
import type {Thunk} from "@welshman/app"
import {deleteRoom, waitForThunkError, repository, joinRoom, leaveRoom} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Login3 from "@assets/icons/login-3.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte"
import {deriveRoom, removeRoom} from "@app/groups"
import {
deriveUserIsRoomAdmin,
deriveUserRoomMembershipStatus,
MembershipStatus,
} from "@app/members"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
h: string
onClick: () => void
}
const {url, h, onClick}: Props = $props()
const room = deriveRoom(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const startEdit = () => pushModal(RoomEdit, {url, h})
const handleLoading = async (f: (url: string, room: RoomMeta) => Thunk) => {
loading = true
try {
const message = await waitForThunkError(f(url, makeRoomMeta({h})))
if (message && !message.startsWith("duplicate:")) {
pushToast({theme: "error", message})
}
} finally {
loading = false
}
}
const join = () => handleLoading(joinRoom)
const leave = () => handleLoading(leaveRoom)
const startDelete = () =>
pushModal(Confirm, {
title: "Are you sure you want to delete this room?",
message:
"This room will no longer be accessible to space members, and all messages posted to it will be deleted.",
confirm: async () => {
const thunk = deleteRoom(url, $room)
const message = await waitForThunkError(thunk)
if (message) {
repository.removeEvent(thunk.event.id)
pushToast({theme: "error", message})
} else {
await removeRoom(url, h)
goto(makeSpacePath(url))
}
},
})
let loading = $state(false)
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if $userIsAdmin}
<li>
<Button onclick={startEdit}>
<Icon icon={Pen} />
Edit Room
</Button>
</li>
<li>
<Button class="text-error" onclick={startDelete}>
<Icon icon={TrashBin2} />
Delete Room
</Button>
</li>
{:else if $membershipStatus === MembershipStatus.Initial}
<li>
<Button disabled={loading} onclick={join}>
{#if loading}
<Spinner size="sm" />
{:else}
<Icon icon={Login3} />
{/if}
Join member list
</Button>
</li>
{:else if $membershipStatus === MembershipStatus.Pending}
<li>
<Button>
<Icon icon={ClockCircle} />
Membership pending
</Button>
</li>
{:else}
<li>
<Button disabled={loading} onclick={leave}>
{#if loading}
<Spinner size="sm" />
{:else}
<Icon icon={Login3} />
{/if}
Leave member list
</Button>
</li>
{/if}
</ul>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import {onMount} from "svelte"
import {waitForThunkError, removeRoomMember} from "@welshman/app"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {deriveRoom} from "@app/groups"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
h: string
pubkey: string
onClick: () => void
}
const {url, h, pubkey, onClick}: Props = $props()
const room = deriveRoom(url, h)
const removeMember = () =>
pushModal(Confirm, {
title: "Remove Member",
message: "Are you sure you want to remove this user from the room?",
confirm: async () => {
const error = await waitForThunkError(removeRoomMember(url, $room, pubkey))
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been removed!"})
history.back()
}
},
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button class="text-error" onclick={removeMember}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>

View file

@ -1,14 +1,9 @@
<script lang="ts">
import {waitForThunkError, removeRoomMember} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.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 MenuButton from "@lib/components/MenuButton.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@ -18,10 +13,9 @@
import Profile from "@app/components/Profile.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomMembersAdd from "@app/components/RoomMembersAdd.svelte"
import {deriveRoom} from "@app/groups"
import RoomMemberMenu from "@app/components/RoomMemberMenu.svelte"
import {deriveRoomMembers, deriveUserIsRoomAdmin} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
@ -30,39 +24,12 @@
const {url, h}: Props = $props()
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const addMember = () => pushModal(RoomMembersAdd, {url, h})
const removeMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Remove Member",
message: "Are you sure you want to remove this user from the room?",
confirm: async () => {
const error = await waitForThunkError(removeRoomMember(url, $room, pubkey))
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been removed!"})
back()
}
},
})
let menuPubkey = $state<string | undefined>()
</script>
<Modal>
@ -89,27 +56,7 @@
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button
class="button button-ghost button-sm button-circle"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
<MenuButton component={RoomMemberMenu} componentProps={{url, h, pubkey}} />
</div>
</div>
{/each}

View file

@ -1,24 +1,15 @@
<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 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 MenuButton from "@lib/components/MenuButton.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
import SpaceMemberMenu from "@app/components/SpaceMemberMenu.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import {removeSpaceMembers, banSpaceMembers, type SpaceRole} from "@app/members"
import {type SpaceRole} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
@ -34,56 +25,7 @@
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 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()
}
},
})
}
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<div class="card card-interactive card-sm relative">
@ -108,44 +50,11 @@
</div>
</div>
{#if canAssign || canUnassign || canUnallow || canBan}
<div class="pointer-events-auto relative shrink-0">
<Button
<div class="pointer-events-auto shrink-0">
<MenuButton
class="button button-ghost button-sm button-square"
onclick={() => (menuOpen = !menuOpen)}>
<Icon icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-52 gap-1 rounded-2xl p-2">
{#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}
component={SpaceMemberMenu}
componentProps={{url, pubkey}} />
</div>
{/if}
</div>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import {onMount} from "svelte"
import {ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import Restart from "@assets/icons/restart.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {addSpaceMembers} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushToast} from "@app/toast"
type Props = {
url: string
pubkey: string
onClick: () => void
}
const {url, pubkey, onClick}: Props = $props()
const supportedMethods = deriveSupportedMethods(url)
const canUnban = $derived($supportedMethods.includes(ManagementMethod.UnbanPubkey))
const canRestore = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
const back = () => history.back()
const unbanMember = async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.UnbanPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been removed from the ban list!"})
back()
}
}
const restoreMember = async () => {
const error = await addSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been restored to membership!"})
back()
}
}
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if canUnban}
<li>
<Button onclick={unbanMember}>
<Icon icon={CloseCircle} />
Unban User
</Button>
</li>
{/if}
{#if canRestore}
<li>
<Button onclick={restoreMember}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{/if}
</ul>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import {onMount} from "svelte"
import {ManagementMethod} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
import {removeSpaceMembers, banSpaceMembers} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
pubkey: string
onClick: () => void
}
const {url, pubkey, onClick}: 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"))
const back = () => history.back()
const editRoles = () => pushModal(SpaceMemberRoles, {url, pubkey})
const removeMember = () =>
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 = () =>
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()
}
},
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#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>

View file

@ -1,14 +1,9 @@
<script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl"
import CloseCircle from "@assets/icons/close-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 MenuButton from "@lib/components/MenuButton.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
@ -16,9 +11,9 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
import SpaceMemberBannedMenu from "@app/components/SpaceMemberBannedMenu.svelte"
import {deriveSpaceBannedPubkeyItems} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushToast} from "@app/toast"
interface Props {
url: string
@ -32,41 +27,6 @@
const canRestore = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const unbanMember = async (pubkey: string) => {
const {error} = await manageRelay(url, {
method: ManagementMethod.UnbanPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been removed from the ban list!"})
back()
}
}
const restoreMember = async (pubkey: string) => {
const error = await addSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been restored to membership!"})
back()
}
}
let menuPubkey = $state<string | undefined>()
</script>
<Modal>
@ -86,37 +46,7 @@
<Profile {pubkey} {url} />
</div>
{#if canUnban || canRestore}
<div class="relative">
<Button
class="button button-ghost button-sm button-circle"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
{#if canUnban}
<li>
<Button onclick={() => unbanMember(pubkey)}>
<Icon icon={CloseCircle} />
Unban User
</Button>
</li>
{/if}
{#if canRestore}
<li>
<Button onclick={() => restoreMember(pubkey)}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
<MenuButton component={SpaceMemberBannedMenu} componentProps={{url, pubkey}} />
{/if}
</div>
</div>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import {onMount} from "svelte"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import RoleEdit from "@app/components/RoleEdit.svelte"
import RoleAddMembers from "@app/components/RoleAddMembers.svelte"
import {deleteRole, type SpaceRole} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
role: SpaceRole
onClick: () => void
}
const {url, role, onClick}: Props = $props()
const back = () => history.back()
const editRole = () => pushModal(RoleEdit, {url, role})
const addMembers = () => pushModal(RoleAddMembers, {url, role})
const confirmDelete = () =>
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()
}
},
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={addMembers}>
<Icon icon={AddCircle} />
Add members
</Button>
</li>
<li>
<Button onclick={editRole}>
<Icon icon={Pen} />
Edit role
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete role
</Button>
</li>
</ul>

View file

@ -1,14 +1,9 @@
<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 MenuButton from "@lib/components/MenuButton.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
@ -17,12 +12,10 @@
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 SpaceRoleMenu from "@app/components/SpaceRoleMenu.svelte"
import {deriveSpaceRoles} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
@ -32,41 +25,9 @@
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>
@ -84,38 +45,11 @@
{#each $roles as role (role.id)}
<div class="card card-sm flex justify-between gap-2">
<RoleItem {role} />
<div class="relative shrink-0">
<Button
<div class="shrink-0">
<MenuButton
class="button button-ghost button-sm button-square"
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 bg-surface absolute right-0 z-popover mt-2 w-52 gap-1 rounded-2xl p-2">
<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}
component={SpaceRoleMenu}
componentProps={{url, role}} />
</div>
</div>
{/each}

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type {Component} from "svelte"
import type {Instance} from "tippy.js"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
type Props = {
component: Component<any>
componentProps?: Record<string, any>
class?: string
iconSize?: number
"aria-label"?: string
}
const {
component,
componentProps = {},
class: className = "button button-ghost button-sm button-circle",
iconSize,
"aria-label": ariaLabel,
}: Props = $props()
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide()
let popover: Instance | undefined = $state()
</script>
<Button class={className} aria-label={ariaLabel} onclick={showPopover}>
<Tippy
bind:popover
{component}
props={{...componentProps, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>
<Icon icon={MenuDots} size={iconSize} />
</Tippy>
</Button>