Open the space menu in a drawer instead of navigating away from the room (#425)

This commit is contained in:
Coracle-Bot 2026-09-08 17:23:22 +00:00 committed by hodlbod
parent 6818aa240f
commit 9810695782
49 changed files with 346 additions and 167 deletions

View file

@ -192,6 +192,8 @@ return a `Command`, so `.then(publish)` is usually all you need.
- Import from `app/modal.ts` or `app/toast.ts`
- Pass component objects with parameters
- Use `$state.snapshot` if calling component might unmount
- Navigate with `navigate` from `app/modal.ts` rather than `goto` — an open modal owns the current
history entry, so a navigation that drops it replaces that entry instead of stacking on it
## Development Workflow

View file

@ -1565,14 +1565,14 @@ Acceptance:
### US-110 — See another space's unread activity from a phone
As bob on a phone, I want the bottom bar's Spaces button to tell me another
As bob on a phone, I want the bottom bar's space-menu button to tell me another
space wants attention, so that I don't have to leave the room I'm reading to
find out.
Acceptance:
- While bob has a room open, a message in a space he isn't in raises an unread
dot on the Spaces button, matching the dot that space's row carries in
dot on the space-menu button, matching the dot that space's row carries in
`/spaces`.
- A message in another room of the space he's already in does not; that space's
own room list carries it.

View file

@ -41,10 +41,9 @@ const settingRow = (page: Page, label: string) =>
const spaceNavItem = (page: Page, name: string) =>
page.locator(`.primary-nav [data-tip^="${name}"]`)
// The desktop rail and the phone's bottom bar each carry a link to the space list, and neither one
// has an accessible name — both icons are masked svgs. Below tailwind's md breakpoint the rail is
// display:none, so on a phone the visible one is the bar's.
const spacesNavItem = (page: Page) => page.locator('a[href="/spaces"]:visible')
// The phone's bottom bar opens the space menu in a drawer; the desktop rail has no equivalent,
// since the menu is always on screen there.
const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"})
// The space menu's header, the one button in the secondary nav carrying the relay's address.
const spaceMenu = (page: Page, url: string) =>
@ -53,8 +52,7 @@ const spaceMenu = (page: Page, url: string) =>
const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name})
// The room's page bar carries a back arrow (display:none at this viewport), a search button and the
// detail button, in that order.
// The room's page bar carries a search button and the detail button, in that order.
const openRoomDetail = (page: Page) =>
page.locator('[data-component="PageBar"]').getByRole("button").last().click()
@ -448,16 +446,16 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const spacesButton = spacesNavItem(bob)
const menuButton = spaceMenuNavItem(bob)
await expect(spacesButton).toBeVisible()
await expect(unreadDot(spacesButton)).toHaveCount(0)
await expect(menuButton).toBeVisible()
await expect(unreadDot(menuButton)).toHaveCount(0)
const inOther = await as(users.alice, roomPath(other.url, "general"))
await send(inOther, "the server is on fire")
await expect(unreadDot(spacesButton)).toBeVisible()
await expect(unreadDot(menuButton)).toBeVisible()
// Meanwhile the space bob is sitting in gets a message too, in a room he isn't reading
const inSpace = await as(users.alice, roomPath(space.url, "random"))
@ -473,7 +471,7 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
await bob.goto(spacePath(space.url))
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible()
await expect(unreadDot(spacesButton)).toHaveCount(0)
await expect(unreadDot(menuButton)).toHaveCount(0)
})
// SpaceMenuNavItems hides a content type until the space has an event of that kind, so this link

View file

@ -43,8 +43,7 @@ const openMessageMenu = (page: Page, text: string) => messageActions(page, text)
// left corner of the viewport is outside it.
const dismissMenu = (page: Page) => page.mouse.move(0, 0)
// The room's page bar carries a back arrow, a search button and the detail button, in that order.
// The back arrow is display:none at this viewport, so a role query sees the other two.
// The room's page bar carries a search button and the detail button, in that order.
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click()

View file

@ -112,3 +112,37 @@ test("does not stack a history entry for the space you are already in", async ({
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
})
test("takes over the space menu's history entry when you navigate out of it", async ({
seed,
as,
}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("lounge", {name: "Space Lounge"})
space.room("garden", {name: "Space Garden"})
space.join(user.alice, "lounge")
space.join(user.alice, "garden")
})
const space = scenario.space("space")
const page = await as(users.alice, roomPath(space.url, "lounge"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const drawer = page.locator(".drawer")
await page.getByRole("button", {name: "Open space menu"}).click()
await drawer.getByRole("link", {name: "Space Garden"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
await expect(drawer).toHaveCount(0)
// The menu held the entry the room it opened now holds, so one step back is the room it opened
// over. Stacked instead, this lands on the menu again.
await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
await expect(drawer).toHaveCount(0)
})

View file

@ -24,3 +24,82 @@ test("renders a seeded space, its rooms and its messages", async ({seed, as}) =>
await expect(page.getByText("morning all")).toBeVisible()
await expect(page.getByText("morning!")).toBeVisible()
})
test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const drawer = page.locator(".drawer")
await expect(drawer).toHaveCount(0)
await page.getByRole("button", {name: "Open space menu"}).click()
await expect(drawer.getByRole("link", {name: "General"})).toBeVisible()
// The space rail sits beside the menu inside the panel, so the menu gets what the rail leaves.
// Sized to the panel instead, it runs off the right of the screen.
await expect(drawer.locator(".space-menu")).toBeInViewport({ratio: 1})
// The panel is full width, so the only way back out is the bottom bar, which the drawer stops
// short of rather than covering. Playwright's hit-target check is what proves it: a drawer over
// the bar would take the click itself.
const closeButton = page.getByRole("button", {name: "Close space menu"})
await closeButton.click()
await expect(drawer).toHaveCount(0)
await page.getByRole("button", {name: "Open space menu"}).click()
await expect(drawer.getByRole("link", {name: "General"})).toBeVisible()
// The modal stack lives in the url hash, so the drawer is a history entry and back closes it.
await page.goBack()
await expect(drawer).toHaveCount(0)
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page
// that is in no space at all.
await page.locator('a[href="/people"]:visible').click()
await page.getByRole("button", {name: "Open space menu"}).click()
await expect(drawer.getByRole("link", {name: "General"})).toBeVisible()
})
test("keeps the drawer's space rail free of a horizontal scrollbar", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const {url} = scenario.space("space")
// No touch, so the app is not in mobile mode and hover tooltips are live. The rail scrolls
// vertically, which makes it scroll horizontally too, and a tooltip is 140px wider than it is.
const page = await as(users.alice, roomPath(url, "general"), {
context: {viewport: {width: 390, height: 844}},
})
await page.getByRole("button", {name: "Open space menu"}).click()
const rail = page.locator(".drawer .primary-nav")
await expect(rail).toBeVisible()
const overflow = await rail.evaluate(el => el.scrollWidth - el.clientWidth)
expect(overflow).toBe(0)
})

View file

@ -1,6 +1,6 @@
<script lang="ts">
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util"
@ -37,7 +37,7 @@
const goToRoom = () => {
if (!$callTargetRoom) return
void goto(makeRoomPath($callTargetRoom.url, $callTargetRoom.h))
void navigate(makeRoomPath($callTargetRoom.url, $callTargetRoom.h))
}
const toggleMute = async () => {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onDestroy, onMount} from "svelte"
import {goto} from "$app/navigation"
import {
ago,
int,
@ -45,7 +44,7 @@
import {userSettingsValues} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats"
import {makeFeedContext} from "@app/feeds"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {DraftKey, type Draft} from "@app/drafts"
import {prependParent} from "@app/rooms"
import {pendingShare, type Share} from "@app/share"
@ -73,7 +72,7 @@
? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others})
const back = () => goto("/chat")
const back = () => navigate("/chat")
const replyTo = (event: TrustedEvent) => {
parent = event

View file

@ -1,6 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
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"
@ -9,7 +8,7 @@
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {makeArticleCreatePath} from "@app/routes"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
@ -35,7 +34,7 @@
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createArticle = () => goto(makeArticleCreatePath(url, {h, shareToChat: true}))
const createArticle = () => navigate(makeArticleCreatePath(url, {h, shareToChat: true}))
let ul: Element

View file

@ -55,7 +55,7 @@
<script lang="ts">
import {marked} from "marked"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {removeUndefined, tryCatch} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {fromNostrURI} from "@welshman/util"
@ -119,7 +119,7 @@
if (href && isEntityPath(href)) {
clickEvent.preventDefault()
goto(href)
navigate(href)
}
}
</script>

View file

@ -21,12 +21,11 @@
<Modal>
<ModalBody>
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
<Link replaceState href="/settings/profile">
<Link href="/settings/profile">
<Profile inert pubkey={$user.pubkey} />
</Link>
<div class="grid grid-cols-3 gap-3 w-full">
<Link
replaceState
href="/settings/alerts"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
<Icon icon={Bell} size={5} />
@ -34,7 +33,6 @@
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link
replaceState
href="/settings/wallet"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
<Icon icon={Wallet} size={5} />
@ -42,21 +40,18 @@
</Link>
{/if}
<Link
replaceState
href="/settings/hosting"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
<Icon icon={ServerPath} size={5} />
Hosting
</Link>
<Link
replaceState
href="/settings/content"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
<Icon icon={GalleryMinimalistic} size={5} />
Content
</Link>
<Link
replaceState
href="/settings/privacy"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
<Icon icon={Shield} size={5} />
@ -64,9 +59,9 @@
</Link>
</div>
<div class="flex gap-3 items-center opacity-75 text-sm">
<Link replaceState href="/settings/theme">Theme</Link>
<Link href="/settings/theme">Theme</Link>
/
<Link replaceState href="/settings/about">About</Link>
<Link href="/settings/about">About</Link>
/
<Button onclick={logout}>Log Out</Button>
</div>

View file

@ -61,4 +61,4 @@
<svelte:window onkeydown={onKeyDown} />
<div bind:this={element}></div>
<div bind:this={element} data-sveltekit-replacestate="true"></div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {getAddress} from "@welshman/util"
import {publishAsRelay} from "@welshman/app"
import {Pinboard} from "@welshman/domain"
@ -49,7 +49,7 @@
back()
} else {
pushToast({message: "Shelf created!"})
goto(makeLibraryPath(url, getAddress(thunk.event)))
navigate(makeLibraryPath(url, getAddress(thunk.event)))
}
} catch (e) {
console.error(e)

View file

@ -4,9 +4,9 @@
import {Profiles} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import Sidebar from "@assets/icons/sidebar-minimalistic.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
@ -14,11 +14,12 @@
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import Search from "@app/components/Search.svelte"
import {userSpaceUrls} from "@app/rooms"
import SpaceMenuDrawer from "@app/components/SpaceMenuDrawer.svelte"
import {PLATFORM_RELAYS} from "@app/env"
import {pushModal} from "@app/modal"
import {modal, popModal, pushDrawer, pushModal} from "@app/modal"
import {notifications} from "@app/notifications"
import {goToChat, makeSpacePath} from "@app/routes"
import {userSpaceUrls} from "@app/rooms"
import {goToChat, lastSpaceUrl, makeSpacePath} from "@app/routes"
import {deriveUserItem} from "@app/core"
type Props = {
@ -35,12 +36,26 @@
const showSearch = () => pushModal(Search)
const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
// The menu is reachable from every page, so it opens on the space the user is in, or the last one
// they were in when they're somewhere else.
const spaceUrl = $derived($lastSpaceUrl ?? PLATFORM_RELAYS[0] ?? $userSpaceUrls[0])
const spaceMenuIsOpen = $derived($modal?.component === SpaceMenuDrawer)
const spaceMenuLabel = $derived(spaceMenuIsOpen ? "Close space menu" : "Open space menu")
const toggleSpaceMenu = () =>
spaceMenuIsOpen ? popModal() : pushDrawer(SpaceMenuDrawer, {url: spaceUrl})
const otherSpaceNotifications = $derived(
$userSpaceUrls.some(url => url !== spaceUrl && $notifications.has(makeSpacePath(url))),
)
</script>
<div class={cx("primary-nav", {"justify-between": PLATFORM_RELAYS.length === 0})}>
<div
class={cx("primary-nav ml-sai mt-sai mb-sai hidden md:flex", {
"justify-between": PLATFORM_RELAYS.length === 0,
})}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
@ -71,9 +86,17 @@
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-surface md:hidden">
</div>
<div
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-line bg-surface md:hidden">
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-(--mobile-nav-height) border border-line bg-surface md:hidden">
<div class="flex h-full justify-between px-2">
<div class="flex items-center gap-6">
{#if spaceUrl}
<PrimaryNavItem
onclick={toggleSpaceMenu}
aria-label={spaceMenuLabel}
notification={otherSpaceNotifications}>
<ImageIcon alt={spaceMenuLabel} src={Sidebar} size={8} />
</PrimaryNavItem>
{/if}
{#if PLATFORM_RELAYS.length === 1}
<PrimaryNavItemSpace url={PLATFORM_RELAYS[0]} />
{:else}
@ -84,11 +107,6 @@
<PrimaryNavItem href="/chat" onclick={chatHandler} notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/if}
</div>
<PrimaryNavItem onclick={showSettingsMenu}>
{#if $userProfile?.picture()}

View file

@ -1,9 +1,9 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import {relays} from "@app/core"
import {navigate} from "@app/modal"
import {makeSpacePath, makeSpaceEntryPath} from "@app/routes"
import {notifications} from "@app/notifications"
@ -17,7 +17,7 @@
const onClick = () => {
const entryPath = makeSpaceEntryPath(url)
goto(entryPath, {replaceState: entryPath === $page.url.pathname})
navigate(entryPath, {replaceState: entryPath === $page.url.pathname})
}
const path = makeSpacePath(url)

View file

@ -12,11 +12,19 @@
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
type Props = {
overflow?: boolean
}
const {overflow = true}: Props = $props()
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const itemLimit = $derived(
overflow ? Math.max(0, (windowHeight - navPadding) / itemHeight) : $userSpaceUrls.length,
)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(
secondarySpaceUrls.some(url => $notifications.has(makeSpacePath(url))),

View file

@ -1,6 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {removeUndefined, spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
@ -19,7 +18,7 @@
import ProfileMenu from "@app/components/ProfileMenu.svelte"
import {messagingRelayLists, profiles, relayManagement} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {makeProfilePath} from "@app/routes"
@ -34,7 +33,7 @@
const back = () => history.back()
const viewProfile = () => goto(makeProfilePath(pubkey), {replaceState: true})
const viewProfile = () => navigate(makeProfilePath(pubkey), {replaceState: true})
const report = (error: string | undefined, message: string) => {
if (error) {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {tagValue, tagSpec} from "@welshman/util"
@ -22,7 +22,7 @@
const h = tagValue(tagSpec("h"), event.tags)
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
const onClick = () => navigate(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
</script>
<Button class="cv card card-interactive" onclick={onClick}>

View file

@ -2,7 +2,7 @@
import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import type {Readable} from "svelte/store"
import {debounce} from "throttle-debounce"
import cx from "classnames"
@ -341,7 +341,7 @@
const scrollToBottom = () => {
if (!isNaN(at)) {
goto($page.url.pathname, {replaceState: true})
navigate($page.url.pathname, {replaceState: true})
} else {
element?.scrollTo({top: 0, behavior: "smooth"})
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {displayRelayUrl} from "@welshman/util"
import type {RoomMeta} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@ -22,7 +22,7 @@
const back = () => history.back()
const onsubmit = (room: RoomMeta) => goto(makeSpacePath(url, room.h))
const onsubmit = (room: RoomMeta) => navigate(makeSpacePath(url, room.h))
</script>
<RoomForm {url} {onsubmit}>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import type {Maybe} from "@welshman/lib"
import {MembershipStatus, publish} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
@ -16,7 +15,7 @@
import {joinRoom, leaveRoom} from "@app/access"
import {deriveUserIsRoomAdmin, deriveUserRoomMembershipStatus} from "@app/rooms"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
@ -63,7 +62,7 @@
pushToast({theme: "error", message})
} else {
await $roomLists.removeRoom(h, url).then(publish)
goto(makeSpacePath(url))
navigate(makeSpacePath(url))
}
},
})

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {displayRelayUrl} from "@welshman/util"
import type {RoomMeta} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@ -39,7 +39,7 @@
const back = () => history.back()
const onsubmit = () => goto(makeSpacePath(url, h))
const onsubmit = () => navigate(makeSpacePath(url, h))
</script>
<RoomForm {url} {onsubmit} {initialValues}>

View file

@ -38,7 +38,7 @@
const back = () => history.back()
const submit = () => shareTo(selection, share, {replaceState: true})
const submit = () => shareTo(selection, share)
let term = $state("")
let selection = $state("")

View file

@ -29,7 +29,7 @@
const back = () => history.back()
const onSubmit = () => {
shareTo(makeRoomPath(url, selection), {type: "event", value: event}, {replaceState: true})
shareTo(makeRoomPath(url, selection), {type: "event", value: event})
}
const toggleRoom = (h: string) => {

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import {publish} from "@welshman/app"
@ -18,7 +17,7 @@
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
import {publishLeaveRequest} from "@app/access"
import {roomLists} from "@app/core"
import {pushModal, clearModals} from "@app/modal"
import {clearModals, navigate, pushModal} from "@app/modal"
import {removeTrustedRelay} from "@app/settings"
type Props = {
@ -28,7 +27,7 @@
const {url, error}: Props = $props()
const back = () => goto("/home")
const back = () => navigate("/home")
const requestAccess = () => pushModal(SpaceAccessRequest, {url, callback: clearModals})
@ -43,7 +42,7 @@
loading = false
}
goto("/home")
navigate("/home")
}
let loading = $state(false)

View file

@ -1,14 +1,12 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import {decodeRelay} from "@app/relays"
import {makeSpacePath} from "@app/routes"
interface Props {
back?: () => unknown
@ -18,16 +16,18 @@
[key: string]: any
}
const {back = () => goto(makeSpacePath(url)), leading, title, action, ...props}: Props = $props()
const {back, leading, title, action, ...props}: Props = $props()
const url = decodeRelay($page.params.relay!)
</script>
<PageBar {...props}>
<div class="flex">
<Button onclick={back} class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} />
</Button>
{#if back}
<Button onclick={back} aria-label="Go back" class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} />
</Button>
{/if}
<div class="flex grow items-center justify-between gap-4">
<div class="flex min-w-0 flex-col">
<div class="flex min-w-0 items-start gap-2">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {displayRelayUrl} from "@welshman/util"
import {publish} from "@welshman/app"
import {preventDefault} from "@lib/html"
@ -31,7 +31,7 @@
loading = false
}
goto("/home")
navigate("/home")
}
let loading = $state(false)

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {debounce} from "throttle-debounce"
import {maybe} from "@welshman/lib"
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
@ -58,7 +58,7 @@
const qp = data.code ? `?code=${encodeURIComponent(data.code)}` : ""
pushToast({message: "Welcome to the room!"})
await goto(path + qp)
await navigate(path + qp)
return
}

View file

@ -95,16 +95,13 @@
`${$shouldNotify ? "Turn off" : "Turn on"} notifications`,
)}
{:else}
<Link href="/settings/alerts" replaceState class="button button-neutral w-full justify-start">
<Link href="/settings/alerts" class="button button-neutral w-full justify-start">
<Icon size={4} icon={Bell} />
Enable notifications
</Link>
{/if}
{#if HOSTING_ENABLED && $hostedRelay.relay}
<Link
href={makeSpacePath(url, "admin")}
replaceState
class="button button-neutral w-full justify-start">
<Link href={makeSpacePath(url, "admin")} class="button button-neutral w-full justify-start">
<Icon size={4} icon={ServerPath} />
Hosting settings
</Link>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import {translate} from "@lib/transition"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SpaceMenu from "@app/components/SpaceMenu.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const duration = 200
const parallax = 10
const slide = (delay: number) => ({
axis: "x" as const,
offset: -100,
unit: "cqw",
duration,
delay,
})
</script>
<div class="@container flex h-full min-h-0 w-full">
<div
class="primary-nav flex overflow-y-auto pb-2"
in:translate={slide(0)}
out:translate={slide(parallax)}>
<PrimaryNavSpaces overflow={false} />
</div>
<div class="flex w-0 min-w-0 grow" in:translate={slide(parallax)} out:translate={slide(0)}>
<SecondaryNav visible class="secondary-nav--mobile-space mt-0 h-full w-0 min-w-0 grow pb-0">
<SpaceMenu {url} mobile />
</SecondaryNav>
</div>
</div>

View file

@ -28,7 +28,7 @@
const openMenu = () => {
if (mobile) {
pushModal(SpaceMenuMobile, {url})
pushModal(SpaceMenuMobile, {url}, {nested: true})
} else {
showMenu = true
}

View file

@ -13,11 +13,10 @@
type Props = {
url: string
h: string
replaceState?: boolean
tooltip?: boolean
}
const {url, h, replaceState = false, tooltip = true}: Props = $props()
const {url, h, tooltip = true}: Props = $props()
const room = $rooms.forRoom(url, h)
const roomType = $derived(getRoomType($room))
@ -28,9 +27,9 @@
</script>
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} />
<VoiceRoomItem {url} {h} {notification} />
{:else}
<SecondaryNavItem href={path} title={tooltip ? roomName : ""} {replaceState} {notification}>
<SecondaryNavItem href={path} title={tooltip ? roomName : ""} {notification}>
<RoomNameWithImage {url} {h} />
{#if $isMuted}
<Icon icon={BellOff} size={4} class="ml-auto opacity-50" />

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {remove} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {publish} from "@welshman/app"
@ -18,7 +17,7 @@
import InfoSignatures from "@app/components/InfoSignatures.svelte"
import {relaysPendingTrust} from "@app/policies"
import {addTrustedRelay, removeTrustedRelay} from "@app/settings"
import {popModal, pushModal} from "@app/modal"
import {navigate, popModal, pushModal} from "@app/modal"
import {roomLists} from "@app/core"
type Props = {
@ -35,7 +34,7 @@
try {
await $roomLists.removeRelay(url).then(publish)
await removeTrustedRelay(url)
goto("/home")
navigate("/home")
} finally {
loading = false
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {derived} from "svelte/store"
import {filter, formatTimestamp, max, spec} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@ -26,7 +26,7 @@
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
const title = tagValue(tagSpec("title"), event.tags)
const path = makeThreadPath(url, event.id)
const onClick = () => goto(path)
const onClick = () => navigate(path)
</script>
{#if mobile}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {goto} from "$app/navigation"
import {makeRoomKey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@ -10,7 +9,7 @@
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {makeRoomPath} from "@app/routes"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
import {
@ -30,11 +29,10 @@
interface Props {
url: string
h: string
replaceState?: boolean
notification?: boolean
}
const {url, h, replaceState = false, notification = false}: Props = $props()
const {url, h, notification = false}: Props = $props()
// Beyond this many participants, a full name+avatar row per person makes the
// sidebar item too tall — fall back to the compact ProfileCircles cluster instead.
@ -59,7 +57,7 @@
}
e.preventDefault()
await goto(makeRoomPath(url, h), {replaceState})
await navigate(makeRoomPath(url, h))
pushModal(VoiceRoomJoinDialog, {url, h})
}
@ -76,7 +74,6 @@
<SecondaryNavItem
href={makeRoomPath(url, h)}
{replaceState}
{notification}
onclick={handleClick}
class={cx("items-start!", isActive && "bg-surface! text-content!")}>

View file

@ -1,11 +1,10 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {publish} from "@welshman/app"
import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import {roomLists, user} from "@app/core"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
import {HOSTING_RELAY_DOMAIN} from "@app/env"
import {
@ -56,7 +55,7 @@
const tenant = await getTenant($user.pubkey)
await goto(makeSpacePath(url, "admin"), {replaceState: true})
await navigate(makeSpacePath(url, "admin"), {replaceState: true})
if (values.plan_id !== "free" && !autopayConfigured(tenant)) {
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey))

View file

@ -2,6 +2,7 @@ import type {Component} from "svelte"
import {get, writable} from "svelte/store"
import {randomId, always, assoc, Emitter} from "@welshman/lib"
import {deriveDeduplicated} from "@welshman/store"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import type {DialogSize} from "@lib/components/Dialog.svelte"
@ -56,6 +57,16 @@ const setModalHash = (hash: string, replace: boolean) => {
modalHash.set(hash)
}
// An open modal owns the current history entry, so a navigation that drops it takes that entry over
const closesModal = (path: string) => {
const hash = get(modalHash)
return hash !== "" && !path.endsWith(hash)
}
export const navigate = (path: string, options?: Parameters<typeof goto>[1]) =>
goto(path, {...options, replaceState: options?.replaceState || closesModal(path)})
export const modalStack = deriveDeduplicated([modalHash, modals], ([$hash, $modals]) => {
return getIdsFromHash($hash)
.map(id => $modals[id])

View file

@ -7,7 +7,7 @@ import {
type Token,
} from "@capacitor/push-notifications"
import type {PluginListenerHandle} from "@capacitor/core"
import {goto} from "$app/navigation"
import {navigate} from "@app/modal"
import {assoc, call, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
import {LOCAL_RELAY_URL} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
@ -140,9 +140,9 @@ export const onPushNotificationAction = async (action: ActionPerformed) => {
const target = event?.kind === WRAP ? await loadNotificationRumor(event) : event
const path = target && makeEventPath(target, [relay])
// Kinds we have no route for get a coracle.social permalink, which goto refuses
// Kinds we have no route for get a coracle.social permalink, which navigate refuses
if (path && !path.includes("://")) {
goto(path)
navigate(path)
} else if (event?.kind === WRAP) {
goToChat()
} else {

View file

@ -1,7 +1,6 @@
import theme from "tailwindcss/defaultTheme"
import {get} from "svelte/store"
import {get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {identity} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@ -23,10 +22,10 @@ import {
import {app, messagingRelayLists, relays, user} from "@app/core"
import {makeChatId} from "@app/chats"
import {entityLink, PLATFORM_URL, PLATFORM_RELAYS} from "@app/env"
import {encodeRelay} from "@app/relays"
import {decodeRelay, encodeRelay} from "@app/relays"
import {DM_KINDS} from "@app/content"
import {ROOM} from "@app/rooms"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import ChatEnable from "@app/components/ChatEnable.svelte"
// State
@ -35,10 +34,15 @@ export let lastChatUrl: string | undefined = undefined
export const lastPageBySpaceUrl = new Map<string, string>()
// The space the user was in most recently, so the space menu can be opened from a page that isn't
// in a space. A store because it's read from markup, unlike lastChatUrl.
export const lastSpaceUrl = writable<string | undefined>(undefined)
export const setupHistory = () =>
page.subscribe($page => {
if ($page.params.relay) {
lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname)
lastSpaceUrl.set(decodeRelay($page.params.relay))
}
if ($page.params.chat) {
@ -62,9 +66,9 @@ export const goToChat = (pubkeys: string[] = [], options: {replaceState?: boolea
if (messagingRelayLists.get().urls(user.get().pubkey).get().length === 0) {
pushModal(ChatEnable, {next: () => goToChat(pubkeys, options)})
} else if (pubkeys.length === 0) {
goto(lastChatUrl ?? "/chat", options)
navigate(lastChatUrl ?? "/chat", options)
} else {
goto(makeChatPath(pubkeys), options)
navigate(makeChatPath(pubkeys), options)
}
}
@ -104,17 +108,19 @@ export const makeSpaceEntryPath = (url: string) => {
}
export const goToSpace = (url: string, options: {replaceState?: boolean} = {}) =>
goto(makeSpaceEntryPath(url), options)
navigate(makeSpaceEntryPath(url), options)
export const goToMovedSpace = (oldUrl: string, newUrl: string) =>
goto(get(page).url.pathname.replace(encodeRelay(oldUrl), encodeRelay(newUrl)))
navigate(get(page).url.pathname.replace(encodeRelay(oldUrl), encodeRelay(newUrl)))
export const goToHome = () => {
if (PLATFORM_RELAYS.length > 0) {
return goto(makeSpaceEntryPath(PLATFORM_RELAYS[0]) + get(page).url.hash, {replaceState: true})
return navigate(makeSpaceEntryPath(PLATFORM_RELAYS[0]) + get(page).url.hash, {
replaceState: true,
})
}
return goto("/home" + get(page).url.hash)
return navigate("/home" + get(page).url.hash)
}
// Content types, events
@ -262,6 +268,6 @@ export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}
} else if (!scrollToEvent(event.id)) {
const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname
goto(path, {replaceState, ...options})
navigate(path, {replaceState, ...options})
}
}

View file

@ -1,12 +1,11 @@
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {Capacitor, registerPlugin} from "@capacitor/core"
import type {PluginListenerHandle} from "@capacitor/core"
import {noop} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {app, relays} from "@app/core"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {makeSpaceChatPath} from "@app/routes"
import {pushToast} from "@app/toast"
import {UPLOAD_MIME_TYPES} from "@app/uploads"
@ -20,13 +19,13 @@ export type Share =
export const pendingShare = writable<Maybe<Share>>(undefined)
// Set pendingShare after goto so the current view doesn't pop it
// Set pendingShare after navigating so the current view doesn't pop it
export const shareTo = async (
path: string,
share: Share,
options: {replaceState?: boolean} = {},
) => {
await goto(path, options)
await navigate(path, options)
pendingShare.set(share)
}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type {Component} from "svelte"
import {fade, translate} from "@lib/transition"
import {fade} from "@lib/transition"
type Props = {
onClose?: any
@ -13,7 +13,8 @@
const {onClose, children}: Props = $props()
</script>
<div class="drawer fixed inset-0 z-modal">
<div
class="drawer fixed inset-x-0 top-0 z-modal bottom-[calc(var(--mobile-nav-height)+var(--saib))] md:bottom-0">
<button
aria-label="Close drawer"
class="absolute inset-0 cursor-pointer bg-black opacity-50"
@ -21,8 +22,7 @@
onclick={onClose}>
</button>
<div
class="bg-surface scroll-container py-sai pr-sair absolute bottom-0 right-0 top-0 w-72 overflow-auto lg:w-96"
transition:translate={{axis: "x", duration: 300}}>
class="pt-sai pl-sai border-line absolute bottom-0 left-0 top-0 flex w-full overflow-hidden border-r border-solid shadow-xl md:w-80 md:pb-sai lg:w-96">
<children.component {...children.props} />
</div>
</div>

View file

@ -1,8 +1,8 @@
<script lang="ts">
import type {Snippet} from "svelte"
import cx from "classnames"
import {goto} from "$app/navigation"
import {stopPropagation} from "@lib/html"
import {navigate} from "@app/modal"
const {
children,
@ -26,7 +26,7 @@
if (!external) {
e.preventDefault()
goto(href, {replaceState})
navigate(href, {replaceState})
}
}
</script>

View file

@ -25,7 +25,7 @@
</script>
{#if onclick}
<Button {onclick} data-tip={title} class={className}>
<Button {...restProps} {onclick} data-tip={title} class={className}>
{@render children?.()}
{#if notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">
@ -33,7 +33,7 @@
{/if}
</Button>
{:else}
<Link {href} data-tip={title} class={className}>
<Link {...restProps} {href} data-tip={title} class={className}>
{@render children?.()}
{#if notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">

View file

@ -25,14 +25,7 @@
import {fade} from "@lib/transition"
import {page} from "$app/stores"
const {
children,
href = "",
title = "",
notification = false,
replaceState = false,
...restProps
} = $props()
const {children, href = "", title = "", notification = false, ...restProps} = $props()
const active = $derived($page.url.pathname === href)
const wrapperClass = $derived(
@ -48,12 +41,7 @@
</script>
{#if href}
<a
{href}
{...restProps}
data-tip={title}
data-sveltekit-replacestate={replaceState}
class={wrapperClass}>
<a {href} {...restProps} data-tip={title} class={wrapperClass}>
{@render children?.()}
{#if notification}
<div

View file

@ -56,6 +56,7 @@
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
--mobile-nav-height: 3.5rem;
text-size-adjust: 100%;
font-size: 110%;
@ -203,6 +204,7 @@
body.keyboard-open {
--saib: 0px;
--mobile-nav-height: 0px;
}
body.keyboard-open .hide-on-keyboard {
@apply hidden;

View file

@ -1,6 +1,10 @@
@layer components {
.primary-nav {
@apply ml-sai mt-sai mb-sai z-popover bg-surface border-line relative isolate hidden w-14 shrink-0 flex-col border-r border-solid pt-2 md:flex;
@apply z-popover bg-surface border-line relative isolate w-14 shrink-0 flex-col border-r border-solid pt-2;
}
.drawer .primary-nav .tip[data-tip]::before {
@apply hidden;
}
.primary-nav__nav-item {

View file

@ -13,19 +13,27 @@ export type TranslateParams = {
duration?: number
easing?: (t: number) => number
axis?: "x" | "y"
reverse?: boolean
offset?: number
unit?: string
}
export const translate = (
node: Element,
{delay = 0, duration = 400, easing = cubicOut, axis = "y", reverse = false}: TranslateParams = {},
{
delay = 0,
duration = 400,
easing = cubicOut,
axis = "y",
offset = 100,
unit = "%",
}: TranslateParams = {},
) => {
return {
delay,
duration,
easing,
css: (t: number) => {
const p = reverse ? `${t * 100}%` : `${100 - t * 100}%`
const p = `${offset - t * offset}${unit}`
if (axis === "x") {
return `transform: translateX(${p})`

View file

@ -1,14 +1,13 @@
<script lang="ts">
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Home from "@assets/icons/home.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 Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
@ -16,18 +15,22 @@
import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
import {relays} from "@app/core"
import {makeSpacePath} from "@app/routes"
import {decodeRelay} from "@app/relays"
const url = decodeRelay($page.params.relay!)
const relay = $relays.one(url)
const showMenu = () => goto(makeSpacePath(url))
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={Home} />
{/snippet}
{#snippet title()}
<strong>Space Details</strong>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<Button onclick={showMenu} class="button button-neutral md:hidden place-self-start">
<Icon icon={ArrowLeft} size={7} /> Go Back
</Button>
<div class="card flex flex-col gap-4">
<div class="relative flex gap-4">
<div class="relative">

View file

@ -1,31 +1,32 @@
<script lang="ts">
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RelayDetailCard from "@app/components/hosting/RelayDetailCard.svelte"
import ActivityFeed from "@app/components/hosting/ActivityFeed.svelte"
import {deriveHostedRelay, deriveRelayActivity} from "@app/hosting"
import {decodeRelay} from "@app/relays"
import {makeSpacePath} from "@app/routes"
const url = decodeRelay($page.params.relay!)
const hostedRelay = deriveHostedRelay(url)
const relayActivity = deriveRelayActivity(derived(hostedRelay, $h => $h.relay?.id))
const showMenu = () => goto(makeSpacePath(url))
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={Server} />
{/snippet}
{#snippet title()}
<strong>Hosting settings</strong>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<Button onclick={showMenu} class="button button-neutral md:hidden place-self-start">
<Icon icon={ArrowLeft} size={7} /> Go Back
</Button>
{#if $hostedRelay.relay}
<RelayDetailCard relay={$hostedRelay.relay} />
<ActivityFeed activity={$relayActivity.activity} loading={$relayActivity.loading} />