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` - Import from `app/modal.ts` or `app/toast.ts`
- Pass component objects with parameters - Pass component objects with parameters
- Use `$state.snapshot` if calling component might unmount - 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 ## Development Workflow

View file

@ -1565,14 +1565,14 @@ Acceptance:
### US-110 — See another space's unread activity from a phone ### 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 space wants attention, so that I don't have to leave the room I'm reading to
find out. find out.
Acceptance: Acceptance:
- While bob has a room open, a message in a space he isn't in raises an unread - 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`. `/spaces`.
- A message in another room of the space he's already in does not; that space's - A message in another room of the space he's already in does not; that space's
own room list carries it. own room list carries it.

View file

@ -41,10 +41,9 @@ const settingRow = (page: Page, label: string) =>
const spaceNavItem = (page: Page, name: string) => const spaceNavItem = (page: Page, name: string) =>
page.locator(`.primary-nav [data-tip^="${name}"]`) 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 // The phone's bottom bar opens the space menu in a drawer; the desktop rail has no equivalent,
// has an accessible name — both icons are masked svgs. Below tailwind's md breakpoint the rail is // since the menu is always on screen there.
// display:none, so on a phone the visible one is the bar's. const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"})
const spacesNavItem = (page: Page) => page.locator('a[href="/spaces"]:visible')
// The space menu's header, the one button in the secondary nav carrying the relay's address. // The space menu's header, the one button in the secondary nav carrying the relay's address.
const spaceMenu = (page: Page, url: string) => const spaceMenu = (page: Page, url: string) =>
@ -53,8 +52,7 @@ const spaceMenu = (page: Page, url: string) =>
const roomLink = (page: Page, name: string) => const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name}) 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 // The room's page bar carries a search button and the detail button, in that order.
// detail button, in that order.
const openRoomDetail = (page: Page) => const openRoomDetail = (page: Page) =>
page.locator('[data-component="PageBar"]').getByRole("button").last().click() 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}, context: {viewport: {width: 390, height: 844}, hasTouch: true},
}) })
const spacesButton = spacesNavItem(bob) const menuButton = spaceMenuNavItem(bob)
await expect(spacesButton).toBeVisible() await expect(menuButton).toBeVisible()
await expect(unreadDot(spacesButton)).toHaveCount(0) await expect(unreadDot(menuButton)).toHaveCount(0)
const inOther = await as(users.alice, roomPath(other.url, "general")) const inOther = await as(users.alice, roomPath(other.url, "general"))
await send(inOther, "the server is on fire") 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 // 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")) 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 bob.goto(spacePath(space.url))
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() 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 // 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. // left corner of the viewport is outside it.
const dismissMenu = (page: Page) => page.mouse.move(0, 0) 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 room's page bar carries 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.
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]') const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click() 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")}$`)) 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 all")).toBeVisible()
await expect(page.getByText("morning!")).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"> <script lang="ts">
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {goto} from "$app/navigation" import {navigate} from "@app/modal"
import {page} from "$app/stores" import {page} from "$app/stores"
import cx from "classnames" import cx from "classnames"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
@ -37,7 +37,7 @@
const goToRoom = () => { const goToRoom = () => {
if (!$callTargetRoom) return if (!$callTargetRoom) return
void goto(makeRoomPath($callTargetRoom.url, $callTargetRoom.h)) void navigate(makeRoomPath($callTargetRoom.url, $callTargetRoom.h))
} }
const toggleMute = async () => { const toggleMute = async () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,9 +4,9 @@
import {Profiles} from "@welshman/app" import {Profiles} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.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 UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.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 ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
@ -14,11 +14,12 @@
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte" import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import Search from "@app/components/Search.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 {PLATFORM_RELAYS} from "@app/env"
import {pushModal} from "@app/modal" import {modal, popModal, pushDrawer, pushModal} from "@app/modal"
import {notifications} from "@app/notifications" 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" import {deriveUserItem} from "@app/core"
type Props = { type Props = {
@ -35,12 +36,26 @@
const showSearch = () => pushModal(Search) const showSearch = () => pushModal(Search)
const anySpaceNotifications = $derived( // The menu is reachable from every page, so it opens on the space the user is in, or the last one
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))), // 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> </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 /> <PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0} {#if PLATFORM_RELAYS.length > 0}
<Divider /> <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 class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-surface md:hidden">
</div> </div>
<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 h-full justify-between px-2">
<div class="flex items-center gap-6"> <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} {#if PLATFORM_RELAYS.length === 1}
<PrimaryNavItemSpace url={PLATFORM_RELAYS[0]} /> <PrimaryNavItemSpace url={PLATFORM_RELAYS[0]} />
{:else} {:else}
@ -84,11 +107,6 @@
<PrimaryNavItem href="/chat" onclick={chatHandler} notification={$notifications.has("/chat")}> <PrimaryNavItem href="/chat" onclick={chatHandler} notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} /> <ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/if}
</div> </div>
<PrimaryNavItem onclick={showSettingsMenu}> <PrimaryNavItem onclick={showSettingsMenu}>
{#if $userProfile?.picture()} {#if $userProfile?.picture()}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,16 +95,13 @@
`${$shouldNotify ? "Turn off" : "Turn on"} notifications`, `${$shouldNotify ? "Turn off" : "Turn on"} notifications`,
)} )}
{:else} {: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} /> <Icon size={4} icon={Bell} />
Enable notifications Enable notifications
</Link> </Link>
{/if} {/if}
{#if HOSTING_ENABLED && $hostedRelay.relay} {#if HOSTING_ENABLED && $hostedRelay.relay}
<Link <Link href={makeSpacePath(url, "admin")} class="button button-neutral w-full justify-start">
href={makeSpacePath(url, "admin")}
replaceState
class="button button-neutral w-full justify-start">
<Icon size={4} icon={ServerPath} /> <Icon size={4} icon={ServerPath} />
Hosting settings Hosting settings
</Link> </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 = () => { const openMenu = () => {
if (mobile) { if (mobile) {
pushModal(SpaceMenuMobile, {url}) pushModal(SpaceMenuMobile, {url}, {nested: true})
} else { } else {
showMenu = true showMenu = true
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation"
import {publish} from "@welshman/app" import {publish} from "@welshman/app"
import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte" import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte" import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte" import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import {roomLists, user} from "@app/core" import {roomLists, user} from "@app/core"
import {pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {HOSTING_RELAY_DOMAIN} from "@app/env" import {HOSTING_RELAY_DOMAIN} from "@app/env"
import { import {
@ -56,7 +55,7 @@
const tenant = await getTenant($user.pubkey) 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)) { if (values.plan_id !== "free" && !autopayConfigured(tenant)) {
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey)) 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 {get, writable} from "svelte/store"
import {randomId, always, assoc, Emitter} from "@welshman/lib" import {randomId, always, assoc, Emitter} from "@welshman/lib"
import {deriveDeduplicated} from "@welshman/store" import {deriveDeduplicated} from "@welshman/store"
import {goto} from "$app/navigation"
import {page} from "$app/stores" import {page} from "$app/stores"
import type {DialogSize} from "@lib/components/Dialog.svelte" import type {DialogSize} from "@lib/components/Dialog.svelte"
@ -56,6 +57,16 @@ const setModalHash = (hash: string, replace: boolean) => {
modalHash.set(hash) 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]) => { export const modalStack = deriveDeduplicated([modalHash, modals], ([$hash, $modals]) => {
return getIdsFromHash($hash) return getIdsFromHash($hash)
.map(id => $modals[id]) .map(id => $modals[id])

View file

@ -7,7 +7,7 @@ import {
type Token, type Token,
} from "@capacitor/push-notifications" } from "@capacitor/push-notifications"
import type {PluginListenerHandle} from "@capacitor/core" 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 {assoc, call, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
import {LOCAL_RELAY_URL} from "@welshman/net" import {LOCAL_RELAY_URL} from "@welshman/net"
import type {RepositoryUpdate} 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 target = event?.kind === WRAP ? await loadNotificationRumor(event) : event
const path = target && makeEventPath(target, [relay]) 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("://")) { if (path && !path.includes("://")) {
goto(path) navigate(path)
} else if (event?.kind === WRAP) { } else if (event?.kind === WRAP) {
goToChat() goToChat()
} else { } else {

View file

@ -1,7 +1,6 @@
import theme from "tailwindcss/defaultTheme" import theme from "tailwindcss/defaultTheme"
import {get} from "svelte/store" import {get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {page} from "$app/stores" import {page} from "$app/stores"
import {identity} from "@welshman/lib" import {identity} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@ -23,10 +22,10 @@ import {
import {app, messagingRelayLists, relays, user} from "@app/core" import {app, messagingRelayLists, relays, user} from "@app/core"
import {makeChatId} from "@app/chats" import {makeChatId} from "@app/chats"
import {entityLink, PLATFORM_URL, PLATFORM_RELAYS} from "@app/env" 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 {DM_KINDS} from "@app/content"
import {ROOM} from "@app/rooms" import {ROOM} from "@app/rooms"
import {pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
// State // State
@ -35,10 +34,15 @@ export let lastChatUrl: string | undefined = undefined
export const lastPageBySpaceUrl = new Map<string, string>() 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 = () => export const setupHistory = () =>
page.subscribe($page => { page.subscribe($page => {
if ($page.params.relay) { if ($page.params.relay) {
lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname) lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname)
lastSpaceUrl.set(decodeRelay($page.params.relay))
} }
if ($page.params.chat) { 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) { if (messagingRelayLists.get().urls(user.get().pubkey).get().length === 0) {
pushModal(ChatEnable, {next: () => goToChat(pubkeys, options)}) pushModal(ChatEnable, {next: () => goToChat(pubkeys, options)})
} else if (pubkeys.length === 0) { } else if (pubkeys.length === 0) {
goto(lastChatUrl ?? "/chat", options) navigate(lastChatUrl ?? "/chat", options)
} else { } 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} = {}) => export const goToSpace = (url: string, options: {replaceState?: boolean} = {}) =>
goto(makeSpaceEntryPath(url), options) navigate(makeSpaceEntryPath(url), options)
export const goToMovedSpace = (oldUrl: string, newUrl: string) => 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 = () => { export const goToHome = () => {
if (PLATFORM_RELAYS.length > 0) { 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 // Content types, events
@ -262,6 +268,6 @@ export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}
} else if (!scrollToEvent(event.id)) { } else if (!scrollToEvent(event.id)) {
const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname 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 {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {Capacitor, registerPlugin} from "@capacitor/core" import {Capacitor, registerPlugin} from "@capacitor/core"
import type {PluginListenerHandle} from "@capacitor/core" import type {PluginListenerHandle} from "@capacitor/core"
import {noop} from "@welshman/lib" import {noop} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {app, relays} from "@app/core" import {app, relays} from "@app/core"
import {pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import {makeSpaceChatPath} from "@app/routes" import {makeSpaceChatPath} from "@app/routes"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {UPLOAD_MIME_TYPES} from "@app/uploads" import {UPLOAD_MIME_TYPES} from "@app/uploads"
@ -20,13 +19,13 @@ export type Share =
export const pendingShare = writable<Maybe<Share>>(undefined) 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 ( export const shareTo = async (
path: string, path: string,
share: Share, share: Share,
options: {replaceState?: boolean} = {}, options: {replaceState?: boolean} = {},
) => { ) => {
await goto(path, options) await navigate(path, options)
pendingShare.set(share) pendingShare.set(share)
} }

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {Component} from "svelte" import type {Component} from "svelte"
import {fade, translate} from "@lib/transition" import {fade} from "@lib/transition"
type Props = { type Props = {
onClose?: any onClose?: any
@ -13,7 +13,8 @@
const {onClose, children}: Props = $props() const {onClose, children}: Props = $props()
</script> </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 <button
aria-label="Close drawer" aria-label="Close drawer"
class="absolute inset-0 cursor-pointer bg-black opacity-50" class="absolute inset-0 cursor-pointer bg-black opacity-50"
@ -21,8 +22,7 @@
onclick={onClose}> onclick={onClose}>
</button> </button>
<div <div
class="bg-surface scroll-container py-sai pr-sair absolute bottom-0 right-0 top-0 w-72 overflow-auto lg:w-96" 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">
transition:translate={{axis: "x", duration: 300}}>
<children.component {...children.props} /> <children.component {...children.props} />
</div> </div>
</div> </div>

View file

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

View file

@ -25,7 +25,7 @@
</script> </script>
{#if onclick} {#if onclick}
<Button {onclick} data-tip={title} class={className}> <Button {...restProps} {onclick} data-tip={title} class={className}>
{@render children?.()} {@render children?.()}
{#if notification} {#if notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content"> <div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">
@ -33,7 +33,7 @@
{/if} {/if}
</Button> </Button>
{:else} {:else}
<Link {href} data-tip={title} class={className}> <Link {...restProps} {href} data-tip={title} class={className}>
{@render children?.()} {@render children?.()}
{#if notification} {#if notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content"> <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 {fade} from "@lib/transition"
import {page} from "$app/stores" import {page} from "$app/stores"
const { const {children, href = "", title = "", notification = false, ...restProps} = $props()
children,
href = "",
title = "",
notification = false,
replaceState = false,
...restProps
} = $props()
const active = $derived($page.url.pathname === href) const active = $derived($page.url.pathname === href)
const wrapperClass = $derived( const wrapperClass = $derived(
@ -48,12 +41,7 @@
</script> </script>
{#if href} {#if href}
<a <a {href} {...restProps} data-tip={title} class={wrapperClass}>
{href}
{...restProps}
data-tip={title}
data-sveltekit-replacestate={replaceState}
class={wrapperClass}>
{@render children?.()} {@render children?.()}
{#if notification} {#if notification}
<div <div

View file

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

View file

@ -1,6 +1,10 @@
@layer components { @layer components {
.primary-nav { .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 { .primary-nav__nav-item {

View file

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

View file

@ -1,14 +1,13 @@
<script lang="ts"> <script lang="ts">
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util" 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 ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl" import BillList from "@assets/icons/bill-list.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte" import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
@ -16,18 +15,22 @@
import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte" import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte" import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
import {relays} from "@app/core" import {relays} from "@app/core"
import {makeSpacePath} from "@app/routes"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const relay = $relays.one(url) const relay = $relays.one(url)
const showMenu = () => goto(makeSpacePath(url))
</script> </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"> <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="card flex flex-col gap-4">
<div class="relative flex gap-4"> <div class="relative flex gap-4">
<div class="relative"> <div class="relative">

View file

@ -1,31 +1,32 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {page} from "$app/stores" 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 Server from "@assets/icons/server.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RelayDetailCard from "@app/components/hosting/RelayDetailCard.svelte" import RelayDetailCard from "@app/components/hosting/RelayDetailCard.svelte"
import ActivityFeed from "@app/components/hosting/ActivityFeed.svelte" import ActivityFeed from "@app/components/hosting/ActivityFeed.svelte"
import {deriveHostedRelay, deriveRelayActivity} from "@app/hosting" import {deriveHostedRelay, deriveRelayActivity} from "@app/hosting"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeSpacePath} from "@app/routes"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const hostedRelay = deriveHostedRelay(url) const hostedRelay = deriveHostedRelay(url)
const relayActivity = deriveRelayActivity(derived(hostedRelay, $h => $h.relay?.id)) const relayActivity = deriveRelayActivity(derived(hostedRelay, $h => $h.relay?.id))
const showMenu = () => goto(makeSpacePath(url))
</script> </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"> <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} {#if $hostedRelay.relay}
<RelayDetailCard relay={$hostedRelay.relay} /> <RelayDetailCard relay={$hostedRelay.relay} />
<ActivityFeed activity={$relayActivity.activity} loading={$relayActivity.loading} /> <ActivityFeed activity={$relayActivity.activity} loading={$relayActivity.loading} />