Scroll the space rail instead of hiding spaces behind an overflow menu

This commit is contained in:
Coracle-Bot 2026-09-17 18:26:01 +00:00
parent f30ef6d344
commit 5ca8eeff51
9 changed files with 156 additions and 195 deletions

View file

@ -141,20 +141,20 @@ Acceptance:
## Spaces
### US-009 — Browse, search, and reorder your spaces
### US-009 — Browse and search spaces, and reorder your own
As alice, I want to see the spaces I've joined and find new ones, so that I can
get where I'm going and discover communities.
As alice, I want to find new spaces and keep the ones I'm in in the order I
want, so that I can discover communities and get where I'm going.
Acceptance:
- `/spaces` shows a "Your spaces" section listing every space alice has joined
and a "Browse Spaces" section of the rest.
- Typing a term filters both sections live, matching name, url, or description.
- Clicking a joined space opens it; clicking one she hasn't joined opens a join
prompt instead.
- Dragging a joined space above another, on the page or in the sidebar rail,
reorders the list immediately, and the order survives a reload.
- `/spaces` lists the spaces alice hasn't joined, under "Browse Spaces". The
ones she has are in the sidebar rail, all of them, and the rail scrolls.
- Typing a term filters the list live, matching name, url, or description.
- Clicking a space she hasn't joined opens a join prompt; a space in the rail
opens.
- Dragging a space above another in the rail reorders the list immediately, and
the order survives a reload.
### US-010 — Join a space from an invite link
@ -1564,10 +1564,9 @@ check first.
Acceptance:
- After alice posts in a room bob hasn't opened, an unread dot appears on that
room and on its space in his sidebar, and on the space's row in `/spaces`,
without a reload.
- Opening the room clears its dot, and the dot stays cleared when he returns to
the space list.
room and on its space in his sidebar, without a reload.
- Opening the room clears its dot, and the dot stays cleared when he leaves the
space.
### US-104 — Mute a room or a whole space

View file

@ -135,23 +135,19 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => {
const bob = await as(users.bob, "/spaces")
const alice = await as(users.alice, roomPath(space.url, "general"))
const spaceRow = bob.getByRole("listitem").filter({hasText: space.url})
const navItem = spaceNavItem(bob, space.name)
await expect(spaceRow).toBeVisible()
await expect(navItem).toBeVisible()
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(unreadDot(navItem)).toHaveCount(0)
await post(alice, "the server is on fire")
// Bob is sitting on the space list the whole time, so both dots arrive without a navigation
await expect(unreadDot(spaceRow)).toBeVisible()
// Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation
await expect(unreadDot(navItem)).toBeVisible()
// Inside the space, the dot points at the room the message landed in. A space's room list only
// exists in its own menu, so this is the one indicator the list above can't show.
await spaceRow.click()
// exists in its own menu, so this is the one indicator the rail can't show.
await navItem.click()
const general = roomLink(bob, "General")
const random = roomLink(bob, "Random")
@ -172,11 +168,10 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => {
await expect(general).toBeVisible()
await expect(unreadDot(general)).toHaveCount(0)
// ...and it stays cleared back on the space list he started from
// ...and it stays cleared back on the page he started from
await bob.locator('.primary-nav a[href="/spaces"]').click()
await expect(spaceRow).toBeVisible()
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(navItem).toBeVisible()
await expect(unreadDot(navItem)).toHaveCount(0)
})

View file

@ -1,6 +1,7 @@
import {DAY, HOUR, WEEK, sortBy} from "@welshman/lib"
import {ROOMS} from "@welshman/util"
import type {Page} from "@playwright/test"
import type {SeededSpace} from "../harness"
import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
// The space this user's room list names first, read from the copy on disk the app restores itself
@ -13,17 +14,27 @@ const cachedFirstSpace = async (page: Page, pubkey: string) => {
return newest?.tags.find(tag => tag[0] === "r")?.[1]
}
// The rail shows icons and no text, so a row is read by the tooltip naming its relay. See
// spaceNavItem in notifications.spec.ts for why that is a tooltip rather than an accessible name.
const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]")
const expectRailFirst = (page: Page, name: string) =>
expect(railSpaces(page).first().locator("[data-tip]")).toHaveAttribute(
"data-tip",
new RegExp(`^${name}`),
)
// A drop reorders the list in place and publishes a new room list behind it, so the order on screen
// is ahead of the one the app has settled on. Reading the next drag off that optimistic order is
// what made this spec fail under a full suite and pass alone: the relay is slower when the box is
// busy, and a room list landing back from it after the next drop replaces the order that drop
// applied. Waiting for the copy on disk to name the new first space is waiting for the round trip.
const expectReordered = async (page: Page, pubkey: string, url: string) => {
await expect(page.getByRole("listitem").first()).toContainText(url)
await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(url)
const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => {
await expectRailFirst(page, space.name)
await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url)
}
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
test("US-009 browse and search spaces, and reorder your own", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
@ -50,76 +61,56 @@ test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
// bootstraps from, so pointing that at bob is what puts his other space in front of alice.
const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
await expect(page.getByText("Your spaces")).toBeVisible()
await expect(page.getByText(space.url)).toBeVisible()
await expect(page.getByText(other.url)).toBeVisible()
// ...and the rest of them in a section of their own
// The page is for spaces she hasn't joined. The ones she has are in the rail, all of them.
await expect(page.getByText("Browse Spaces")).toBeVisible()
await expect(page.getByText(unsigned.url)).toBeVisible()
await expect(page.getByText(space.url)).toHaveCount(0)
await expect(page.getByText(other.url)).toHaveCount(0)
await expect(railSpaces(page)).toHaveCount(2)
const term = page.getByPlaceholder("Search for spaces...")
await term.fill("unsigned")
await expect(page.getByText(unsigned.url)).toBeVisible()
await expect(page.getByText(space.url)).toHaveCount(0)
await expect(page.getByText(other.url)).toHaveCount(0)
await term.fill("nothing by that name")
await expect(page.getByText(unsigned.url)).toHaveCount(0)
await term.fill("")
await expect(page.getByText(space.url)).toBeVisible()
// A space she's joined opens
await page.getByRole("listitem").filter({hasText: space.url}).click()
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
// A space she hasn't asks her to join first
await page.goto("/spaces")
// A space she hasn't joined asks her to join first
await page.getByRole("button").filter({hasText: unsigned.url}).click()
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled()
// A space she has joined opens from the rail
await page.goto("/spaces")
await railSpaces(page).first().click()
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
// Reordering by dragging, which lives in her room list and so outlives the page
await page.goto("/spaces")
const joined = page.getByRole("listitem")
await expect(joined.first()).toContainText(space.url)
await expectRailFirst(page, space.name)
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag
// starts the drag and moves it, but never delivers the drop the reorder is committed in, so the
// row would snap back to where it came from.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
// The sidebar rail is the same list and reorders the same way. It shows icons and no text, so
// its rows are named by position and the list on the page is where the result is read.
const rail = page.locator(".primary-nav [draggable=true]")
const rail = railSpaces(page)
await rail.nth(0).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(1).dispatchEvent("drop", {dataTransfer})
await expectReordered(page, users.alice.pubkey, other.url)
await rail.nth(1).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(0).dispatchEvent("drop", {dataTransfer})
await expectReordered(page, users.alice.pubkey, space.url)
// Dragging in the page's own list moves the same room list.
const source = joined.filter({hasText: other.url})
const target = joined.filter({hasText: space.url})
await source.dispatchEvent("dragstart", {dataTransfer})
await target.dispatchEvent("drop", {dataTransfer})
await expectReordered(page, users.alice.pubkey, other.url)
await expectReordered(page, users.alice.pubkey, other)
await page.reload()
await expect(page.getByRole("listitem").first()).toContainText(other.url)
await expectRailFirst(page, other.name)
})
test("US-010 join a space from an invite link", async ({seed, as}) => {
@ -268,7 +259,7 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
await bob.goto("/spaces")
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
await expect(railSpaces(bob)).toHaveCount(0)
})
test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
@ -296,7 +287,7 @@ test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
// page that reloads before it reaches disk reads the list he had a moment ago.
await bob.getByRole("link", {name: "All Spaces"}).click()
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
await expect(railSpaces(bob)).toHaveCount(0)
const alice = await as(users.alice, roomPath(unsigned.url, "general"))
@ -343,10 +334,8 @@ test("US-013 follow a space that has moved", async ({seed, as}) => {
// that reloads before it reaches disk reads the old address back.
await alice.getByRole("link", {name: "All Spaces"}).click()
const aliceSpaces = alice.getByRole("listitem")
await expect(aliceSpaces).toHaveCount(1)
await expect(aliceSpaces.first()).toContainText(other.url)
await expect(railSpaces(alice)).toHaveCount(1)
await expectRailFirst(alice, other.name)
const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo})
@ -358,10 +347,8 @@ test("US-013 follow a space that has moved", async ({seed, as}) => {
await bob.goto("/spaces")
const bobSpaces = bob.getByRole("listitem")
await expect(bobSpaces).toHaveCount(1)
await expect(bobSpaces.first()).toContainText(space.url)
await expect(railSpaces(bob)).toHaveCount(1)
await expectRailFirst(bob, space.name)
})
test("US-014 leave a space", async ({seed, as}) => {
@ -389,7 +376,7 @@ test("US-014 leave a space", async ({seed, as}) => {
// reloads before it reaches disk reads the list he had a moment ago.
await page.getByRole("link", {name: "All Spaces"}).click()
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
await expect(railSpaces(page)).toHaveCount(0)
// Nothing stops him coming back
await page.getByRole("button", {name: "Add Space"}).click()

View file

@ -1,47 +1,45 @@
<script lang="ts">
import {splitAt} from "@welshman/lib"
import cx from "classnames"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import DragList from "@lib/components/DragList.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import PrimaryNavSpacesOverflow from "@app/components/PrimaryNavSpacesOverflow.svelte"
import {reorderSpaceUrls, userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
type Props = {
overflow?: boolean
const fadeSize = 24
let element: HTMLElement | undefined = $state()
let hiddenAbove = $state(0)
let hiddenBelow = $state(0)
const measure = () => {
if (element) {
hiddenAbove = element.scrollTop
hiddenBelow = element.scrollHeight - element.clientHeight - element.scrollTop
}
}
const {overflow = true}: Props = $props()
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 8 * 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))),
)
// Tippy mounts its content component once, so pass a stable reactive object it can read from
const overflowProps = $state({urls: [] as string[]})
// Nothing fires on a change of scroll height, and the list has one whenever a space is joined
// or left, or the window resizes under it.
$effect(() => {
overflowProps.urls = secondarySpaceUrls
const observer = new ResizeObserver(measure)
if (element) {
observer.observe(element)
for (const child of element.children) {
observer.observe(child)
}
}
return () => observer.disconnect()
})
</script>
<svelte:window bind:innerHeight={windowHeight} />
<div class="flex flex-col items-center">
<div class={cx("flex min-h-0 flex-col items-center", {"flex-1": PLATFORM_RELAYS.length === 0})}>
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
@ -49,32 +47,23 @@
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
<DragList
class="flex flex-col items-center"
items={primarySpaceUrls}
onReorder={reorderSpaceUrls}>
{#snippet item(url)}
<PrimaryNavItemSpace {url} />
{/snippet}
</DragList>
{#snippet allSpaces(title: string)}
<PrimaryNavItem
href="/spaces"
{title}
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/snippet}
{#if secondarySpaceUrls.length > 0}
<Tippy
component={PrimaryNavSpacesOverflow}
props={overflowProps}
params={{placement: "right", interactive: true}}>
{@render allSpaces("")}
</Tippy>
{:else}
{@render allSpaces("All Spaces")}
{/if}
<div
bind:this={element}
onscroll={measure}
class="primary-nav__spaces"
style:--fade-top="{Math.min(hiddenAbove, fadeSize)}px"
style:--fade-bottom="{Math.min(hiddenBelow, fadeSize)}px">
<DragList
class="flex flex-col items-center"
items={$userSpaceUrls}
onReorder={reorderSpaceUrls}>
{#snippet item(url)}
<PrimaryNavItemSpace {url} />
{/snippet}
</DragList>
</div>
<PrimaryNavItem href="/spaces" title="All Spaces" prefix="no-highlight">
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/each}
</div>

View file

@ -1,16 +0,0 @@
<script lang="ts">
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div
class="flex max-h-[80vh] flex-col overflow-y-auto rounded-2xl border border-solid bg-surface p-1 shadow-xl border-line">
{#each urls as url (url)}
<PrimaryNavItemSpace {url} showTooltip={false} />
{/each}
</div>

View file

@ -28,10 +28,10 @@
<div class="@container flex h-full min-h-0 w-full">
<div
class="primary-nav flex overflow-y-auto pb-2"
class="primary-nav flex min-h-0 pb-2"
in:translate={slide(0)}
out:translate={slide(parallax)}>
<PrimaryNavSpaces overflow={false} />
<PrimaryNavSpaces />
</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">

View file

@ -7,6 +7,41 @@
@apply hidden;
}
.primary-nav__spaces {
@apply flex min-h-0 w-full flex-1 flex-col items-center self-start overflow-y-auto;
scrollbar-width: none;
-ms-overflow-style: none;
/* Fades whichever end of the list has spaces past it */
mask-image: linear-gradient(
to bottom,
transparent 0,
black var(--fade-top, 0px),
black calc(100% - var(--fade-bottom, 0px)),
transparent 100%
);
&::-webkit-scrollbar {
display: none;
}
}
/* Hover tooltips reach past the rail, and a scroll container clips whatever leaves it. The extra
width is padding, so it holds them without moving the items, and passes its pointer events to
the page underneath. Tooltips are a desktop affair, so the rail in the mobile drawer keeps the
width it draws in. */
@media (min-width: 768px) {
.primary-nav__spaces {
width: calc(100% + 12rem);
padding-right: 12rem;
pointer-events: none;
& > * {
pointer-events: auto;
}
}
}
.primary-nav__nav-item {
@apply text-content relative my-1 flex h-12 w-12 cursor-pointer items-center justify-center rounded-full transition-all;
}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import {onMount} from "svelte"
import Home from "@assets/icons/home.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Page from "@lib/components/Page.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
@ -27,9 +29,15 @@
<Page>
<PageBar>
<div class="flex items-center gap-2">
<Icon icon={Home} size={6} />
<strong>Home</strong>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Icon icon={Home} size={6} />
<strong>Home</strong>
</div>
<Link href="/spaces" class="button button-neutral button-sm">
<Icon icon={Widget} size={4} />
Browse spaces
</Link>
</div>
</PageBar>
<PageContent noPad bind:element class="flex flex-col bg-surface">

View file

@ -1,20 +1,18 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived as _derived} from "svelte/store"
import {addToMapKey, dec, sleep, spec} from "@welshman/lib"
import {addToMapKey, dec, sleep} from "@welshman/lib"
import {ROOMS} from "@welshman/util"
import type {Relay} from "@welshman/domain"
import {throttled} from "@welshman/store"
import {Sync, createSearch} from "@welshman/app"
import {createScroller, isMobile} from "@lib/html"
import {fly} from "@lib/transition"
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import DragList from "@lib/components/DragList.svelte"
import Page from "@lib/components/Page.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
@ -26,13 +24,12 @@
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import {app, relays, roomLists, user} from "@app/core"
import {reorderSpaceUrls, userSpaceUrls} from "@app/rooms"
import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
import {bootstrapPubkeys} from "@app/social"
import {parseInviteLink} from "@app/access"
import {pushModal} from "@app/modal"
import {goToSpace, makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
import {goToSpace} from "@app/routes"
const addSpace = () => pushModal(SpaceAdd)
@ -91,9 +88,6 @@
const inviteData = $derived(parseInviteLink(term))
const searchResults = $derived($relaySearch.searchOptions(term))
const userSpaceSet = $derived(new Set($userSpaceUrls))
const filteredUserUrls = $derived(
term ? $userSpaceUrls.filter(url => searchResults.some(spec({url}))) : $userSpaceUrls,
)
const otherSpaces = $derived(
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
)
@ -158,7 +152,7 @@
{#await userSpacesLoaded}
<div class="flex items-center justify-center py-20">
<Spinner size="sm" class="mr-3" />
Loading your spaces...
Loading spaces...
</div>
{:then}
{#if inviteData}
@ -171,36 +165,6 @@
</Button>
{/key}
{/if}
{#if filteredUserUrls.length > 0}
<Divider>Your spaces</Divider>
<DragList
class="flex flex-col gap-2"
role="list"
itemRole="listitem"
items={filteredUserUrls}
onReorder={reorderSpaceUrls}>
{#snippet item(url)}
<Button
class="group card card-interactive w-full relative min-w-0"
onclick={() => openSpace(url)}>
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-content-subtle transition-colors group-hover:text-content-muted">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))}
<div
class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary text-primary-content">
</div>
{/if}
</Button>
{/snippet}
</DragList>
{:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}
{#if otherSpaces.length > 0}
<Divider>Browse Spaces</Divider>
{/if}
@ -214,7 +178,7 @@
<Spinner loading>Looking for spaces...</Spinner>
{:then}
{#if otherSpaces.length === 0}
<Spinner>No other spaces found.</Spinner>
<Spinner>No spaces found.</Spinner>
{/if}
{/await}
</div>