Open popover menus through MenuButton and Tippy, replace Popover with menu components, and make Tooltip a thin Tippy wrapper

This commit is contained in:
Jon Staab 2026-09-25 22:28:23 -07:00
parent 34ee4fab5b
commit fd7b50a7ec
32 changed files with 409 additions and 569 deletions

View file

@ -187,15 +187,22 @@ behind its own loading state. Reusable confirmations get a `*Confirm` wrapper
### Popover menus
`MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop
that hides it:
that hides it. It shows the menu-dots icon unless given children, opens `bottom-end` unless given a
`placement`, and takes `strayMargin` to hide once the pointer strays that many pixels from the
popover, as the hover actions on a chat row do:
```svelte
<MenuButton component={ChatMenu} aria-label="Chat options" />
```
`EventMenu` attaches `onClick` to its `<ul>`, so any item closes the popover, and each item
usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `RoomItem` pushes
`RoomItemMenuMobile` on tap.
`EventMenu` puts `onClick` on its `<ul>` as `onclickcapture`, so any item closes the popover, and
each item usually pushes a modal. It has to be the capture phase: `Button` stops propagation, and
Svelte's delegated `onclick` on the `<ul>` would never see the click. On mobile, rows push a
`*MenuMobile` modal instead: `RoomItem` pushes `RoomItemMenuMobile` on tap.
`Tooltip` is `Tippy` with a string `content` and the `tooltip` theme, whose CSS lives in
`tooltip.css`. `Tippy` appends popovers to the page's `.tippy-target`, or to the open dialog's
`.dialog-tippy-target` so they stack above it.
### Toasts
@ -222,8 +229,8 @@ lives there:
`ModalSubtitle`, `ModalFooter`, `Confirm`
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`,
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`,
`VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {PinboardReader} from "@welshman/domain"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
@ -59,15 +58,9 @@
message: `Delete "${board.title() || "this shelf"}"?`,
confirm: deleteBoard,
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import {assoc} from "@welshman/lib"
import {publish} from "@welshman/app"
import Check from "@assets/icons/check.svg?dataurl"
@ -34,15 +33,9 @@
addRelay: (url: string) => $messagingRelayLists.addUrl(url).then(publish),
removeRelay: (url: string) => $messagingRelayLists.removeUrl(url).then(publish),
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={markAsRead}>
<Icon size={4} icon={Check} />

View file

@ -2,7 +2,7 @@
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store"
import cx from "classnames"
import type {Maybe, MaybeAsync} from "@welshman/lib"
import type {MaybeAsync} from "@welshman/lib"
import {MESSAGE} from "@welshman/util"
import type {EventContent} from "@welshman/util"
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
@ -11,8 +11,7 @@
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
@ -115,10 +114,6 @@
.run()
}
const showMenu = () => tippy?.show()
const hideMenu = () => tippy?.hide()
const submit = async () => {
if ($uploading || disabled || sending) {
return
@ -145,7 +140,6 @@
}
let sending = false
let tippy: Maybe<TippyController> = $state()
let content = $state(
initialValues?.type === "text" ? initialValues.value : (draft?.get()?.content ?? ""),
)
@ -212,15 +206,14 @@
{/if}
</Button>
{#if url}
<Button disabled={$uploading} onclick={showMenu} class={buttonClass}>
<Tippy
bind:controller={tippy}
component={ComposeMenu}
props={{url, h, onClick: hideMenu}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={WidgetAdd} />
</Tippy>
</Button>
<MenuButton
component={ComposeMenu}
componentProps={{url, h}}
placement="top"
disabled={$uploading}
class={buttonClass}>
<Icon icon={WidgetAdd} />
</MenuButton>
{/if}
<EditorEmojiButton {editor} disabled={$uploading || disabled} class={buttonClass} />
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
@ -35,15 +34,9 @@
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createArticle = () => navigate(makeArticleCreatePath(url, {h, shareToChat: true}))
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={createGoal}>
<Icon size={4} icon={StarFallMinimalistic} />

View file

@ -1,12 +1,7 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import EventReactButtons from "@app/components/EventReactButtons.svelte"
@ -19,25 +14,16 @@
}
const {url, noun, event, hideZap, customActions}: Props = $props()
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide()
let tippy: Maybe<TippyController> = $state()
</script>
<!-- The compact segmented control a card footer gets. EventActionBar is the full-width
version a detail page gets. -->
<div class="items-center join">
<EventReactButtons {url} {event} {hideZap} class="button button-neutral button-xs join-item" />
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
<Tippy
bind:controller={tippy}
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={MenuDots} size={4} />
</Tippy>
</Button>
<MenuButton
component={EventMenu}
componentProps={{url, noun, event, customActions}}
placement="top"
iconSize={4}
class="flex join-item button button-neutral button-xs" />
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
@ -44,15 +43,9 @@
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun, event})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {PinReader} from "@welshman/domain"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
@ -53,15 +52,9 @@
message: "Delete this link?",
confirm: deletePin,
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />

View file

@ -1,5 +1,4 @@
<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"
@ -10,15 +9,9 @@
}
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}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={onDelete}>
<Icon icon={TrashBin2} />

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {removeUndefined} from "@welshman/lib"
import Code2 from "@assets/icons/code-2.svg?dataurl"
@ -25,15 +24,9 @@
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
const showShare = () => pushModal(ProfileQrCode, {pubkey})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={showShare}>
<Icon size={4} icon={ShareCircle} />

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {Report} from "@welshman/domain"
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
@ -87,15 +86,9 @@
},
})
}
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={deleteReport}>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Maybe} from "@welshman/lib"
import {MembershipStatus, publish} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
@ -70,14 +69,9 @@
})
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}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
{#if $userIsAdmin}
<li>
<Button onclick={startEdit}>

View file

@ -14,6 +14,7 @@
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import {deriveDisplaysByPubkey} from "@app/social"
@ -22,7 +23,7 @@
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import RoomItemZapButton from "@app/components/RoomItemZapButton.svelte"
import RoomItemEmojiButton from "@app/components/RoomItemEmojiButton.svelte"
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {events, profiles, reader, thunks, user} from "@app/core"
@ -189,7 +190,14 @@
<Icon icon={Pen} size={4} />
</Button>
{/if}
<RoomItemMenuButton {url} {event} />
<MenuButton
component={RoomItemMenu}
componentProps={{url, event}}
placement="top"
strayMargin={50}
iconSize={4}
aria-label="More options"
class="button button-xs button-neutral join-item" />
</div>
{/if}
</TapTarget>

View file

@ -1,42 +0,0 @@
<script lang="ts">
import {between} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
const {url, event} = $props()
const open = () => tippy?.show()
const onClick = () => tippy?.hide()
const onMouseMove = ({clientX, clientY}: MouseEvent) => {
const rect = tippy?.rect()
if (rect) {
const {x, y, width, height} = rect
if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) {
tippy!.hide()
}
}
}
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
<Button aria-label="More options" onclick={open} class="button button-xs button-neutral join-item">
<Tippy
bind:controller={tippy}
component={RoomItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={MenuDots} size={4} />
</Tippy>
</Button>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@ -33,15 +32,9 @@
}
},
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button class="text-error" onclick={removeMember}>
<Icon icon={MinusCircle} />

View file

@ -0,0 +1,54 @@
<script lang="ts">
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceAdmins from "@app/components/SpaceAdmins.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal"
type Props = {
url: string
onClick: () => void
}
const {url, onClick}: Props = $props()
const permissions = deriveSpacePermissions(url)
const manageRoles = () => pushModal(SpaceRoles, {url})
const spaceAdmins = () => pushModal(SpaceAdmins, {url})
const bannedMembers = () => pushModal(SpaceMembersBanned, {url})
</script>
<ul class="menu w-48 gap-1 rounded-2xl bg-surface p-2" onclickcapture={onClick}>
{#if $permissions.manageRoles}
<li>
<Button onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
</li>
{/if}
{#if $permissions.listAdmins}
<li>
<Button onclick={spaceAdmins}>
<Icon icon={ShieldUser} />
Admins
</Button>
</li>
{/if}
{#if $permissions.listBans}
<li>
<Button onclick={bannedMembers}>
<Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
{/if}
</ul>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import Restart from "@assets/icons/restart.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -39,15 +38,9 @@
$spaceManagement.loadBannedPubkeys(url)
}
}
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
{#if $permissions.unban}
<li>
<Button onclick={unbanMember}>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import Pen from "@assets/icons/pen.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
@ -61,15 +60,9 @@
}
},
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
{#if $permissions.assignRoles || $permissions.unassignRoles}
<li>
<Button onclick={editRoles}>

View file

@ -6,7 +6,6 @@
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
import RelayName from "@app/components/RelayName.svelte"
@ -32,13 +31,26 @@
}
}
const toggleMenu = () => {
showMenu = !showMenu
const closeMenu = () => {
showMenu = false
}
// Deferred so the click that follows the mouseup still reaches the item before the menu goes.
const onMouseUp = () => setTimeout(closeMenu)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setTimeout(closeMenu)
}
}
let showMenu = $state(false)
</script>
<svelte:window
onmouseup={showMenu ? onMouseUp : undefined}
onkeydown={showMenu ? onKeyDown : undefined} />
<Button
class={cx(
mobile
@ -64,9 +76,9 @@
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
</Button>
{#if showMenu && !mobile}
<Popover hideOnClick onClose={toggleMenu}>
<div class="relative w-full">
<ul transition:fly class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
<SpaceMenuActions {url} />
</ul>
</Popover>
</div>
{/if}

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import type {RelayRoleReader} from "@welshman/domain"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
@ -47,15 +46,9 @@
message: `Delete the "${role.label()}" role? Members will keep their space membership.`,
confirm: deleteRole,
})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
{#if $permissions.assignRoles}
<li>
<Button onclick={addMembers}>

View file

@ -1,36 +1,25 @@
<script lang="ts">
import cx from "classnames"
import {spec} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Power from "@assets/icons/power.svg?dataurl"
import Play from "@assets/icons/play.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import VerifiedCheck from "@assets/icons/verified-check.svg?dataurl"
import ShieldWarning from "@assets/icons/shield-warning.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Refresh from "@assets/icons/refresh.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Database from "@assets/icons/database.svg?dataurl"
import {fly} from "@lib/transition"
import {ucFirst} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Badge from "@lib/components/Badge.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Popover from "@lib/components/Popover.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import ToggleInput from "@lib/components/ToggleInput.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayDetailMenu from "@app/components/hosting/RelayDetailMenu.svelte"
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
import PlanModal from "@app/components/hosting/PlanModal.svelte"
import RelayForm from "@app/components/hosting/RelayForm.svelte"
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import {pushModal, clearModals} from "@app/modal"
import {pushModal} from "@app/modal"
import {clip, pushToast} from "@app/toast"
import {
RELAY_FLAG_DEFAULTS,
@ -45,8 +34,6 @@
isPaidPlan,
updateRelay,
getRelay,
deactivateRelay,
reactivateRelay,
deriveRelayMembers,
type HostedRelay,
type RelayFlag,
@ -63,7 +50,6 @@
const members = deriveRelayMembers(relay.id)
const host = $derived(relayHost(relay))
const relayLabel = $derived(relay.info_name || relay.subdomain)
const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
const recordTarget = $derived(canonicalRelayHost(relay))
// DNS forbids a CNAME at a zone apex, so those use the provider's ALIAS or ANAME record.
@ -85,48 +71,10 @@
}
}
const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
const openEdit = () => {
const previous = relay
const initialValues: RelayFormValues = {
info_name: relay.info_name,
subdomain: relay.subdomain,
info_icon: relay.info_icon,
info_description: relay.info_description,
plan_id: relay.plan_id,
}
pushModal(RelayForm, {
mode: "edit",
initialValues,
zooidDomain: relay.zooid_domain,
onSubmit: async (values: RelayFormValues) => {
const updated = await updateRelay(previous.id, values)
clearModals()
await followHostedRelay(previous, updated)
},
})
}
const openDataTransfer = () => {
pushModal(DataTransferModal, {relay})
}
const openCustomDomain = () => {
pushModal(CustomDomainModal, {relay})
}
const openMenu = () => {
showMenu = true
}
const closeMenu = () => {
showMenu = false
}
const copyTarget = () => clip(recordTarget)
// Verification runs in a backend poller; reload to pick up the result.
@ -150,48 +98,6 @@
}
}
const requestDeactivate = () => {
pushModal(Confirm, {
title: "Deactivate relay?",
subtitle: `${relayLabel} will be taken offline immediately.`,
message:
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
confirm: async () => {
try {
await deactivateRelay(relay.id)
$hosting.setRelay(await getRelay(relay.id))
clearModals()
} catch (e) {
pushToast({
theme: "error",
message: hostingErrorMessage(e, "Failed to deactivate relay."),
})
}
},
})
}
const requestReactivate = () => {
pushModal(Confirm, {
title: "Reactivate relay?",
subtitle: `${relayLabel} will come back online.`,
message: "The relay will start accepting connections again.",
confirm: async () => {
try {
await reactivateRelay(relay.id)
$hosting.setRelay(await getRelay(relay.id))
clearModals()
} catch (e) {
pushToast({
theme: "error",
message: hostingErrorMessage(e, "Failed to reactivate relay."),
})
}
},
})
}
let showMenu = $state(false)
let loading = $state(false)
</script>
@ -216,7 +122,7 @@
<RelayIcon url={getHostedRelayUrl(relay)} icon={relay.info_icon} size={12} />
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold">{relayLabel}</h2>
<h2 class="text-xl font-bold">{relay.info_name || relay.subdomain}</h2>
<Badge variant={relay.status === "active" ? "primary" : "neutral"}>
{ucFirst(relay.status.replace(/_/g, " "))}
</Badge>
@ -233,53 +139,11 @@
</div>
</div>
<div class="relative shrink-0">
<Button
class="button button-neutral button-circle"
aria-label="Relay actions"
onclick={openMenu}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-2xl border border-line bg-surface p-2 shadow-xl">
<li>
<Button onclick={openEdit}>
<Icon icon={Pen} />
Edit details
</Button>
</li>
<li>
<Button onclick={openPlan}>
<Icon icon={BillList} />
Change plan
</Button>
</li>
<li>
<Button onclick={openDataTransfer}>
<Icon icon={Database} />
Import / export data
</Button>
</li>
<li>
{#if relay.status === "active"}
<Button class="text-error" onclick={requestDeactivate}>
<Icon icon={Power} />
Deactivate
</Button>
{:else}
<Button class="text-primary" onclick={requestReactivate}>
<Icon icon={Play} />
Reactivate
</Button>
{/if}
</li>
</ul>
</Popover>
{/if}
</div>
<MenuButton
class="button button-neutral button-circle"
aria-label="Relay actions"
component={RelayDetailMenu}
componentProps={{relay}} />
</div>
{#if relay.sync_error}

View file

@ -0,0 +1,142 @@
<script lang="ts">
import Pen from "@assets/icons/pen.svg?dataurl"
import Power from "@assets/icons/power.svg?dataurl"
import Play from "@assets/icons/play.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Database from "@assets/icons/database.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
import PlanModal from "@app/components/hosting/PlanModal.svelte"
import RelayForm from "@app/components/hosting/RelayForm.svelte"
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import {pushModal, clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
import {
followHostedRelay,
hosting,
hostingErrorMessage,
updateRelay,
getRelay,
deactivateRelay,
reactivateRelay,
type HostedRelay,
} from "@app/hosting"
type Props = {
relay: HostedRelay
onClick: () => void
}
const {relay, onClick}: Props = $props()
const relayLabel = $derived(relay.info_name || relay.subdomain)
const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
const openEdit = () => {
const previous = relay
const initialValues: RelayFormValues = {
info_name: relay.info_name,
subdomain: relay.subdomain,
info_icon: relay.info_icon,
info_description: relay.info_description,
plan_id: relay.plan_id,
}
pushModal(RelayForm, {
mode: "edit",
initialValues,
zooidDomain: relay.zooid_domain,
onSubmit: async (values: RelayFormValues) => {
const updated = await updateRelay(previous.id, values)
clearModals()
await followHostedRelay(previous, updated)
},
})
}
const openDataTransfer = () => {
pushModal(DataTransferModal, {relay})
}
const requestDeactivate = () => {
pushModal(Confirm, {
title: "Deactivate relay?",
subtitle: `${relayLabel} will be taken offline immediately.`,
message:
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
confirm: async () => {
try {
await deactivateRelay(relay.id)
$hosting.setRelay(await getRelay(relay.id))
clearModals()
} catch (e) {
pushToast({
theme: "error",
message: hostingErrorMessage(e, "Failed to deactivate relay."),
})
}
},
})
}
const requestReactivate = () => {
pushModal(Confirm, {
title: "Reactivate relay?",
subtitle: `${relayLabel} will come back online.`,
message: "The relay will start accepting connections again.",
confirm: async () => {
try {
await reactivateRelay(relay.id)
$hosting.setRelay(await getRelay(relay.id))
clearModals()
} catch (e) {
pushToast({
theme: "error",
message: hostingErrorMessage(e, "Failed to reactivate relay."),
})
}
},
})
}
</script>
<ul
class="menu w-52 gap-1 rounded-2xl border border-line bg-surface p-2 shadow-xl"
onclickcapture={onClick}>
<li>
<Button onclick={openEdit}>
<Icon icon={Pen} />
Edit details
</Button>
</li>
<li>
<Button onclick={openPlan}>
<Icon icon={BillList} />
Change plan
</Button>
</li>
<li>
<Button onclick={openDataTransfer}>
<Icon icon={Database} />
Import / export data
</Button>
</li>
<li>
{#if relay.status === "active"}
<Button class="text-error" onclick={requestDeactivate}>
<Icon icon={Power} />
Deactivate
</Button>
{:else}
<Button class="text-primary" onclick={requestReactivate}>
<Icon icon={Play} />
Reactivate
</Button>
{/if}
</li>
</ul>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {between, throttle} from "@welshman/lib"
import type {Props as TippyProps} from "tippy.js"
import type {Maybe} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
@ -8,7 +8,7 @@
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
type Props = {
tippyParams?: Record<string, unknown>
tippyParams?: Partial<TippyProps>
onEmoji: (emoji: string) => void
allowKaomoji?: boolean
class?: string
@ -32,28 +32,15 @@
tippy?.hide()
}
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
const rect = tippy?.rect()
if (rect) {
const {x, y, width, height} = rect
if (!between([x, x + width], clientX) || !between([y - 100, y + height + 100], clientY)) {
tippy!.hide()
}
}
})
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={tippy?.visible && !allowKaomoji ? onMouseMove : undefined} />
<Button onclick={open} class={className} {...restProps}>
<Tippy
bind:controller={tippy}
class="flex"
component={EmojiPicker}
strayMargin={allowKaomoji ? undefined : 100}
props={{onClick, allowKaomoji}}
params={{trigger: "manual", interactive: true, ...tippyParams}}>
{@render children?.()}

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {between, throttle} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {isMobile} from "@lib/html"
import Button from "@lib/components/Button.svelte"
@ -32,22 +31,13 @@
close()
}
const onMouseMove = throttle(300, ({clientX}: MouseEvent) => {
const rect = tippy?.rect()
if (rect && !between([rect.x - 50, rect.x + rect.width + 50], clientX)) {
tippy!.hide()
}
})
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={onMouseMove} />
<Tippy
bind:controller={tippy}
component={IconPickerPopover}
strayMargin={50}
props={{onSelect: onClick}}
params={{trigger: "manual", interactive: true, placement: "top-end"}}>
<Button onclick={open} class={props.class}>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type {Component} from "svelte"
import type {Component, Snippet} from "svelte"
import type {Placement} from "tippy.js"
import type {Maybe} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -10,16 +11,24 @@
type Props = {
component: Component<any>
componentProps?: Record<string, any>
placement?: Placement
strayMargin?: number
class?: string
iconSize?: number
disabled?: boolean
children?: Snippet
"aria-label"?: string
}
const {
component,
componentProps = {},
placement = "bottom-end",
strayMargin,
class: className = "button button-ghost button-sm button-circle",
iconSize,
disabled,
children,
"aria-label": ariaLabel,
}: Props = $props()
@ -30,12 +39,17 @@
let tippy: Maybe<TippyController> = $state()
</script>
<Button class={className} aria-label={ariaLabel} onclick={showPopover}>
<Button class={className} aria-label={ariaLabel} {disabled} onclick={showPopover}>
<Tippy
bind:controller={tippy}
{component}
{strayMargin}
props={{...componentProps, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>
<Icon icon={MenuDots} size={iconSize} />
params={{trigger: "manual", interactive: true, placement}}>
{#if children}
{@render children()}
{:else}
<Icon icon={MenuDots} size={iconSize} />
{/if}
</Tippy>
</Button>

View file

@ -1,34 +0,0 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {fly} from "@lib/transition"
interface Props {
onClose: any
hideOnClick?: boolean
children?: Snippet
}
const {onClose, hideOnClick = false, children}: Props = $props()
const onMouseUp = (e: any) => {
if (hideOnClick || !element?.contains(e.target)) {
setTimeout(onClose)
}
}
const onKeyDown = (e: any) => {
if (e.key === "Escape") {
setTimeout(onClose)
}
}
let element: HTMLElement | undefined = $state()
</script>
<svelte:window onmouseup={onMouseUp} onkeydown={onKeyDown} />
<div class="relative w-full" bind:this={element}>
<div transition:fly|local class="absolute z-popover w-full">
{@render children?.()}
</div>
</div>

View file

@ -6,27 +6,56 @@
hide: () => void
toggle: () => void
visible: boolean
rect: () => Maybe<DOMRect>
content: Maybe<Content>
}
// Cached because a couple of popovers per chat row runs this thousands of times.
let tippyTarget: Maybe<Element>
const getTippyTarget = (trigger: Element) => {
const dialogTarget = trigger.closest(".dialog-overlay")?.querySelector(".dialog-tippy-target")
if (dialogTarget) {
return dialogTarget
}
if (!tippyTarget?.isConnected) {
tippyTarget = document.querySelector(".tippy-target")!
}
return tippyTarget
}
</script>
<script lang="ts">
import "tippy.js/animations/shift-away.css"
import tippy from "tippy.js"
import type {Instance} from "tippy.js"
import type {Instance, Props as TippyProps} from "tippy.js"
import {onMount, mount, unmount} from "svelte"
import {getTippyTarget, isMobile} from "@lib/html"
import type {Component, Snippet} from "svelte"
import {between, throttle} from "@welshman/lib"
import {isMobile} from "@lib/html"
type Props = {
component?: Component<any>
children?: Snippet
props?: Record<string, any>
params?: Partial<TippyProps>
strayMargin?: number
controller?: TippyController
class?: string
}
let {
component,
children = undefined,
children,
props = {},
params = {},
strayMargin,
controller = $bindable(),
...restProps
} = $props()
class: className,
}: Props = $props()
const target = document.createElement("div")
@ -56,15 +85,24 @@
: element.closest<HTMLElement>("button, [href], input, [tabindex]") ||
element.querySelector<HTMLElement>("button, [href], input, [tabindex]") ||
undefined
content ??= mount(component, {target, props: mountedProps})
if (component) {
content ??= mount(component, {target, props: mountedProps})
}
visible = true
document.addEventListener("keydown", onKeyDown)
if (strayMargin) {
document.addEventListener("mousemove", onMouseMove)
}
return params.onShow?.(instance)
},
onHidden: (instance: Instance) => {
visible = false
document.removeEventListener("keydown", onKeyDown)
document.removeEventListener("mousemove", onMouseMove)
return params.onHidden?.(instance)
},
@ -85,6 +123,20 @@
}
}
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
const rect = popover?.popper.getBoundingClientRect()
if (rect && strayMargin) {
const {x, y, width, height} = rect
const inX = between([x - strayMargin, x + width + strayMargin], clientX)
const inY = between([y - strayMargin, y + height + strayMargin], clientY)
if (!inX || !inY) {
popover?.hide()
}
}
})
controller = {
show: () => create().show(),
hide: () => popover?.hide(),
@ -95,7 +147,6 @@
create().show()
}
},
rect: () => popover?.popper.getBoundingClientRect(),
get visible() {
return visible
},
@ -115,6 +166,7 @@
return () => {
document.removeEventListener("keydown", onKeyDown)
document.removeEventListener("mousemove", onMouseMove)
popover?.destroy()
if (content) {
@ -124,6 +176,6 @@
})
</script>
<div bind:this={element} class={restProps.class}>
<div bind:this={element} class={className}>
{@render children?.()}
</div>

View file

@ -1,65 +1,16 @@
<style>
:global(.tippy-box[data-theme~="tooltip"]) {
background-color: var(--content);
color: var(--surface);
border-radius: var(--radius-xl);
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
box-shadow: var(--shadow);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="top"] > .tippy-arrow::before) {
border-top-color: var(--content);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="bottom"] > .tippy-arrow::before) {
border-bottom-color: var(--content);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="left"] > .tippy-arrow::before) {
border-left-color: var(--content);
}
:global(.tippy-box[data-theme~="tooltip"][data-placement^="right"] > .tippy-arrow::before) {
border-right-color: var(--content);
}
</style>
<script lang="ts">
import "tippy.js/animations/shift-away.css"
import type {Snippet} from "svelte"
import Tippy from "@lib/components/Tippy.svelte"
import tippy from "tippy.js"
import {onMount} from "svelte"
import {getTippyTarget, isMobile} from "@lib/html"
type Props = {
content: string
children: Snippet
class?: string
}
let {
content,
arrow = true,
interactive = false,
children = undefined,
instance = $bindable(),
...restProps
} = $props()
let element: Element
onMount(() => {
instance = tippy(element, {
content,
arrow,
interactive,
animation: "shift-away",
theme: "tooltip",
appendTo: getTippyTarget(element),
trigger: isMobile ? "click" : "mouseenter focus",
})
return () => {
instance?.destroy()
}
})
const {content, children, class: className}: Props = $props()
</script>
<div bind:this={element} class={restProps.class}>
{@render children?.()}
</div>
<Tippy class={className} params={{content, theme: "tooltip"}}>
{@render children()}
</Tippy>

View file

@ -419,12 +419,6 @@ body.keyboard-open .room {
padding: 0.7rem 1rem !important;
}
/* tippy popover */
.tippy-box {
@apply rounded-3xl;
box-shadow: var(--shadow-lg);
}
/* emoji picker — the .emoji-picker frame around it supplies the border */
emoji-picker {
--background: var(--surface);

View file

@ -42,7 +42,7 @@
display: none !important;
}
/* the host tippy renders its popovers into; see getTippyTarget in lib/html.ts */
/* the host tippy renders its popovers into; see getTippyTarget in Tippy.svelte */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
@ -50,4 +50,34 @@
.tippy-target > * {
@apply pointer-events-auto;
}
.tippy-box {
@apply rounded-3xl;
box-shadow: var(--shadow-lg);
}
.tippy-box[data-theme~="tooltip"] {
background-color: var(--content);
color: var(--surface);
border-radius: var(--radius-xl);
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
box-shadow: var(--shadow);
}
.tippy-box[data-theme~="tooltip"][data-placement^="top"] > .tippy-arrow::before {
border-top-color: var(--content);
}
.tippy-box[data-theme~="tooltip"][data-placement^="bottom"] > .tippy-arrow::before {
border-bottom-color: var(--content);
}
.tippy-box[data-theme~="tooltip"][data-placement^="left"] > .tippy-arrow::before {
border-left-color: var(--content);
}
.tippy-box[data-theme~="tooltip"][data-placement^="right"] > .tippy-arrow::before {
border-right-color: var(--content);
}
}

View file

@ -1,6 +1,5 @@
import {readable} from "svelte/store"
import {sleep, randomId} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {Capacitor} from "@capacitor/core"
export {preventDefault, stopPropagation} from "svelte/legacy"
@ -121,23 +120,6 @@ export const createScroller = ({
export const isMobile = "ontouchstart" in document.documentElement
// Cached because a couple of popovers per chat row runs this thousands of times.
let tippyTarget: Maybe<Element>
export const getTippyTarget = (trigger?: Element) => {
const dialogTarget = trigger?.closest(".dialog-overlay")?.querySelector(".dialog-tippy-target")
if (dialogTarget) {
return dialogTarget
}
if (!tippyTarget?.isConnected) {
tippyTarget = document.querySelector(".tippy-target")!
}
return tippyTarget
}
export const downloadText = async (filename: string, text: string) => {
// The <a download> blob trick is a no-op in native WebViews, so write the file and share it.
if (["android", "ios"].includes(Capacitor.getPlatform())) {

View file

@ -3,21 +3,15 @@
import {removeUndefined, sortBy} from "@welshman/lib"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceAdmins from "@app/components/SpaceAdmins.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import SpaceDirectoryMenu from "@app/components/SpaceDirectoryMenu.svelte"
import {deriveSpacePermissions} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles"
import {relayMemberLists, relayRoles} from "@app/core"
@ -47,27 +41,7 @@
}))
})
let menuOpen = $state(false)
const inviteMembers = () => {
menuOpen = false
pushModal(SpaceInvite, {url})
}
const manageRoles = () => {
menuOpen = false
pushModal(SpaceRoles, {url})
}
const spaceAdmins = () => {
menuOpen = false
pushModal(SpaceAdmins, {url})
}
const bannedMembers = () => {
menuOpen = false
pushModal(SpaceMembersBanned, {url})
}
const inviteMembers = () => pushModal(SpaceInvite, {url})
// A role section matches on its own name, so it survives a term its members don't match.
let term = $state("")
@ -112,46 +86,12 @@
Invite people
</Button>
{#if $permissions.directoryMenu}
<div class="relative">
<Button
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">
{#if $permissions.manageRoles}
<li>
<Button onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
</li>
{/if}
{#if $permissions.listAdmins}
<li>
<Button onclick={spaceAdmins}>
<Icon icon={ShieldUser} />
Admins
</Button>
</li>
{/if}
{#if $permissions.listBans}
<li>
<Button onclick={bannedMembers}>
<Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
<MenuButton
class="button button-neutral button-sm button-square"
aria-label="More options"
iconSize={4}
component={SpaceDirectoryMenu}
componentProps={{url}} />
{/if}
{/snippet}
</SpaceBar>