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 ## 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 As alice, I want to find new spaces and keep the ones I'm in in the order I
get where I'm going and discover communities. want, so that I can discover communities and get where I'm going.
Acceptance: Acceptance:
- `/spaces` shows a "Your spaces" section listing every space alice has joined - `/spaces` lists the spaces alice hasn't joined, under "Browse Spaces". The
and a "Browse Spaces" section of the rest. ones she has are in the sidebar rail, all of them, and the rail scrolls.
- Typing a term filters both sections live, matching name, url, or description. - Typing a term filters the list live, matching name, url, or description.
- Clicking a joined space opens it; clicking one she hasn't joined opens a join - Clicking a space she hasn't joined opens a join prompt; a space in the rail
prompt instead. opens.
- Dragging a joined space above another, on the page or in the sidebar rail, - Dragging a space above another in the rail reorders the list immediately, and
reorders the list immediately, and the order survives a reload. the order survives a reload.
### US-010 — Join a space from an invite link ### US-010 — Join a space from an invite link
@ -1564,10 +1564,9 @@ check first.
Acceptance: Acceptance:
- After alice posts in a room bob hasn't opened, an unread dot appears on that - 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`, room and on its space in his sidebar, without a reload.
without a reload. - Opening the room clears its dot, and the dot stays cleared when he leaves the
- Opening the room clears its dot, and the dot stays cleared when he returns to space.
the space list.
### US-104 — Mute a room or a whole 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 bob = await as(users.bob, "/spaces")
const alice = await as(users.alice, roomPath(space.url, "general")) 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) const navItem = spaceNavItem(bob, space.name)
await expect(spaceRow).toBeVisible()
await expect(navItem).toBeVisible() await expect(navItem).toBeVisible()
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(unreadDot(navItem)).toHaveCount(0) await expect(unreadDot(navItem)).toHaveCount(0)
await post(alice, "the server is on fire") 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 // Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation
await expect(unreadDot(spaceRow)).toBeVisible()
await expect(unreadDot(navItem)).toBeVisible() await expect(unreadDot(navItem)).toBeVisible()
// Inside the space, the dot points at the room the message landed in. A space's room list only // 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. // exists in its own menu, so this is the one indicator the rail can't show.
await spaceRow.click() await navItem.click()
const general = roomLink(bob, "General") const general = roomLink(bob, "General")
const random = roomLink(bob, "Random") 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(general).toBeVisible()
await expect(unreadDot(general)).toHaveCount(0) 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 bob.locator('.primary-nav a[href="/spaces"]').click()
await expect(spaceRow).toBeVisible() await expect(navItem).toBeVisible()
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(unreadDot(navItem)).toHaveCount(0) await expect(unreadDot(navItem)).toHaveCount(0)
}) })

View file

@ -1,6 +1,7 @@
import {DAY, HOUR, WEEK, sortBy} from "@welshman/lib" import {DAY, HOUR, WEEK, sortBy} from "@welshman/lib"
import {ROOMS} from "@welshman/util" import {ROOMS} from "@welshman/util"
import type {Page} from "@playwright/test" import type {Page} from "@playwright/test"
import type {SeededSpace} from "../harness"
import {expect, readCachedEvents, roomPath, spacePath, test, users} 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 // 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] 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 // 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 // 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 // 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 // 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. // 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) => { const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => {
await expect(page.getByRole("listitem").first()).toContainText(url) await expectRailFirst(page, space.name)
await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(url) 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 scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
const other = relay("other") 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. // 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}}) const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
await expect(page.getByText("Your spaces")).toBeVisible() // The page is for spaces she hasn't joined. The ones she has are in the rail, all of them.
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
await expect(page.getByText("Browse Spaces")).toBeVisible() await expect(page.getByText("Browse Spaces")).toBeVisible()
await expect(page.getByText(unsigned.url)).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...") const term = page.getByPlaceholder("Search for spaces...")
await term.fill("unsigned") await term.fill("unsigned")
await expect(page.getByText(unsigned.url)).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 term.fill("nothing by that name")
await expect(page.getByText(unsigned.url)).toHaveCount(0)
await term.fill("") await term.fill("")
await expect(page.getByText(space.url)).toBeVisible() // A space she hasn't joined asks her to join first
// 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")
await page.getByRole("button").filter({hasText: unsigned.url}).click() await page.getByRole("button").filter({hasText: unsigned.url}).click()
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible() await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled() 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 // Reordering by dragging, which lives in her room list and so outlives the page
await page.goto("/spaces") await page.goto("/spaces")
const joined = page.getByRole("listitem") await expectRailFirst(page, space.name)
await expect(joined.first()).toContainText(space.url)
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag // 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 // 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. // row would snap back to where it came from.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer()) const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
const rail = railSpaces(page)
// 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]")
await rail.nth(0).dispatchEvent("dragstart", {dataTransfer}) await rail.nth(0).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(1).dispatchEvent("drop", {dataTransfer}) await rail.nth(1).dispatchEvent("drop", {dataTransfer})
await expectReordered(page, users.alice.pubkey, other.url) await expectReordered(page, users.alice.pubkey, other)
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 page.reload() 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}) => { 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 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}) => { 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. // page that reloads before it reaches disk reads the list he had a moment ago.
await bob.getByRole("link", {name: "All Spaces"}).click() 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")) 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. // that reloads before it reaches disk reads the old address back.
await alice.getByRole("link", {name: "All Spaces"}).click() await alice.getByRole("link", {name: "All Spaces"}).click()
const aliceSpaces = alice.getByRole("listitem") await expect(railSpaces(alice)).toHaveCount(1)
await expectRailFirst(alice, other.name)
await expect(aliceSpaces).toHaveCount(1)
await expect(aliceSpaces.first()).toContainText(other.url)
const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo}) 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") await bob.goto("/spaces")
const bobSpaces = bob.getByRole("listitem") await expect(railSpaces(bob)).toHaveCount(1)
await expectRailFirst(bob, space.name)
await expect(bobSpaces).toHaveCount(1)
await expect(bobSpaces.first()).toContainText(space.url)
}) })
test("US-014 leave a space", async ({seed, as}) => { 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. // reloads before it reaches disk reads the list he had a moment ago.
await page.getByRole("link", {name: "All Spaces"}).click() 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 // Nothing stops him coming back
await page.getByRole("button", {name: "Add Space"}).click() await page.getByRole("button", {name: "Add Space"}).click()

View file

@ -1,47 +1,45 @@
<script lang="ts"> <script lang="ts">
import {splitAt} from "@welshman/lib" import cx from "classnames"
import Widget from "@assets/icons/widget-4.svg?dataurl" import Widget from "@assets/icons/widget-4.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 Tippy from "@lib/components/Tippy.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import DragList from "@lib/components/DragList.svelte" import DragList from "@lib/components/DragList.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import PrimaryNavSpacesOverflow from "@app/components/PrimaryNavSpacesOverflow.svelte"
import {reorderSpaceUrls, userSpaceUrls} from "@app/rooms" import {reorderSpaceUrls, userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env" import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
type Props = { const fadeSize = 24
overflow?: boolean
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() // 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.
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[]})
$effect(() => { $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> </script>
<svelte:window bind:innerHeight={windowHeight} /> <div class={cx("flex min-h-0 flex-col items-center", {"flex-1": PLATFORM_RELAYS.length === 0})}>
<div class="flex flex-col items-center">
{#each PLATFORM_RELAYS as url (url)} {#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{:else} {:else}
@ -49,32 +47,23 @@
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} /> <ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem> </PrimaryNavItem>
<Divider /> <Divider />
<DragList <div
class="flex flex-col items-center" bind:this={element}
items={primarySpaceUrls} onscroll={measure}
onReorder={reorderSpaceUrls}> class="primary-nav__spaces"
{#snippet item(url)} style:--fade-top="{Math.min(hiddenAbove, fadeSize)}px"
<PrimaryNavItemSpace {url} /> style:--fade-bottom="{Math.min(hiddenBelow, fadeSize)}px">
{/snippet} <DragList
</DragList> class="flex flex-col items-center"
{#snippet allSpaces(title: string)} items={$userSpaceUrls}
<PrimaryNavItem onReorder={reorderSpaceUrls}>
href="/spaces" {#snippet item(url)}
{title} <PrimaryNavItemSpace {url} />
prefix="no-highlight" {/snippet}
notification={otherSpaceNotifications}> </DragList>
<ImageIcon alt="All Spaces" src={Widget} size={8} /> </div>
</PrimaryNavItem> <PrimaryNavItem href="/spaces" title="All Spaces" prefix="no-highlight">
{/snippet} <ImageIcon alt="All Spaces" src={Widget} size={8} />
{#if secondarySpaceUrls.length > 0} </PrimaryNavItem>
<Tippy
component={PrimaryNavSpacesOverflow}
props={overflowProps}
params={{placement: "right", interactive: true}}>
{@render allSpaces("")}
</Tippy>
{:else}
{@render allSpaces("All Spaces")}
{/if}
{/each} {/each}
</div> </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="@container flex h-full min-h-0 w-full">
<div <div
class="primary-nav flex overflow-y-auto pb-2" class="primary-nav flex min-h-0 pb-2"
in:translate={slide(0)} in:translate={slide(0)}
out:translate={slide(parallax)}> out:translate={slide(parallax)}>
<PrimaryNavSpaces overflow={false} /> <PrimaryNavSpaces />
</div> </div>
<div class="flex w-0 min-w-0 grow" in:translate={slide(parallax)} out:translate={slide(0)}> <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"> <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; @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 { .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; @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"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import Home from "@assets/icons/home.svg?dataurl" 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 Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
@ -27,9 +29,15 @@
<Page> <Page>
<PageBar> <PageBar>
<div class="flex items-center gap-2"> <div class="flex items-center justify-between gap-4">
<Icon icon={Home} size={6} /> <div class="flex items-center gap-2">
<strong>Home</strong> <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> </div>
</PageBar> </PageBar>
<PageContent noPad bind:element class="flex flex-col bg-surface"> <PageContent noPad bind:element class="flex flex-col bg-surface">

View file

@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {derived as _derived} from "svelte/store" 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 {ROOMS} from "@welshman/util"
import type {Relay} from "@welshman/domain" import type {Relay} from "@welshman/domain"
import {throttled} from "@welshman/store" import {throttled} from "@welshman/store"
import {Sync, createSearch} from "@welshman/app" import {Sync, createSearch} from "@welshman/app"
import {createScroller, isMobile} from "@lib/html" import {createScroller, isMobile} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.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 DragList from "@lib/components/DragList.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
@ -26,13 +24,12 @@
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import {app, relays, roomLists, user} from "@app/core" 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 {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
import {bootstrapPubkeys} from "@app/social" import {bootstrapPubkeys} from "@app/social"
import {parseInviteLink} from "@app/access" import {parseInviteLink} from "@app/access"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {goToSpace, makeSpacePath} from "@app/routes" import {goToSpace} from "@app/routes"
import {notifications} from "@app/notifications"
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
@ -91,9 +88,6 @@
const inviteData = $derived(parseInviteLink(term)) const inviteData = $derived(parseInviteLink(term))
const searchResults = $derived($relaySearch.searchOptions(term)) const searchResults = $derived($relaySearch.searchOptions(term))
const userSpaceSet = $derived(new Set($userSpaceUrls)) const userSpaceSet = $derived(new Set($userSpaceUrls))
const filteredUserUrls = $derived(
term ? $userSpaceUrls.filter(url => searchResults.some(spec({url}))) : $userSpaceUrls,
)
const otherSpaces = $derived( const otherSpaces = $derived(
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url), searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
) )
@ -158,7 +152,7 @@
{#await userSpacesLoaded} {#await userSpacesLoaded}
<div class="flex items-center justify-center py-20"> <div class="flex items-center justify-center py-20">
<Spinner size="sm" class="mr-3" /> <Spinner size="sm" class="mr-3" />
Loading your spaces... Loading spaces...
</div> </div>
{:then} {:then}
{#if inviteData} {#if inviteData}
@ -171,36 +165,6 @@
</Button> </Button>
{/key} {/key}
{/if} {/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} {#if otherSpaces.length > 0}
<Divider>Browse Spaces</Divider> <Divider>Browse Spaces</Divider>
{/if} {/if}
@ -214,7 +178,7 @@
<Spinner loading>Looking for spaces...</Spinner> <Spinner loading>Looking for spaces...</Spinner>
{:then} {:then}
{#if otherSpaces.length === 0} {#if otherSpaces.length === 0}
<Spinner>No other spaces found.</Spinner> <Spinner>No spaces found.</Spinner>
{/if} {/if}
{/await} {/await}
</div> </div>