Let space icons be dragged to reorder in the sidebar

This commit is contained in:
Coracle-Bot 2026-09-09 21:06:59 +00:00
parent d1da119389
commit 2c06a107f2
6 changed files with 174 additions and 120 deletions

View file

@ -153,8 +153,8 @@ Acceptance:
- 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 reorders the list immediately, and the
order survives a reload.
- Dragging a joined space above another, on the page or in the sidebar rail,
reorders the list immediately, and the order survives a reload.
### US-010 — Join a space from an invite link

View file

@ -83,6 +83,24 @@ test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
// 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]")
await rail.nth(0).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(1).dispatchEvent("drop", {dataTransfer})
await expect(joined.first()).toContainText(other.url)
await rail.nth(1).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(0).dispatchEvent("drop", {dataTransfer})
await expect(joined.first()).toContainText(space.url)
// Dragging in the page's own list moves the same room list. It goes last because it shows the new
// order the moment it is dropped, ahead of the room list it publishes, so a drag in the rail
// straight afterwards would be working from the order it replaced.
const source = joined.filter({hasText: other.url})
const target = joined.filter({hasText: space.url})

View file

@ -5,9 +5,10 @@
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 {userSpaceUrls} from "@app/rooms"
import {reorderSpaceUrls, userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
@ -48,9 +49,14 @@
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<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"

View file

@ -1,10 +1,10 @@
import * as nip19 from "nostr-tools/nip19"
import {derived} from "svelte/store"
import {derived, get} from "svelte/store"
import {formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {MESSAGE, makeEvent, outbox, seen, toNostrURI} from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util"
import {MembershipStatus, RoomLists, makeRoomKey, createSearch} from "@welshman/app"
import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app"
import type {Room, RoomMeta} from "@welshman/app"
import {
deriveUserItem,
@ -126,6 +126,16 @@ export const userRoomList = deriveUserItem(RoomLists)
export const userSpaceUrls = derived(userRoomList, $userRoomList => $userRoomList?.urls() ?? [])
// Spaces get reordered from lists that show only some of them, so the urls given here go back
// in the slots the ones they replace occupied.
export const reorderSpaceUrls = (urls: string[]) => {
let index = 0
const nextUrls = get(userSpaceUrls).map(url => (urls.includes(url) ? urls[index++] : url))
return roomLists.get().setRelays(nextUrls).then(publish)
}
// Rooms in the space the user has joined, limited to those the relay still advertises.
export const deriveUserRooms = (url: string) =>
derived(

View file

@ -0,0 +1,118 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing"
import {insertAt, removeAt} from "@welshman/lib"
type Props = {
items: string[]
onReorder: (items: string[]) => void
item: Snippet<[string]>
class?: string
itemClass?: string
role?: string
itemRole?: string
}
const {
items,
onReorder,
item,
class: className = "",
itemClass = "",
role = undefined,
itemRole = undefined,
}: Props = $props()
const isSameOrder = (a: string[], b: string[]) =>
a.length === b.length && a.every((item, index) => item === b[index])
const moveTo = (source: string, target: string) => {
const from = order.indexOf(source)
const to = order.indexOf(target)
if (from >= 0 && to >= 0 && from !== to) {
preview = insertAt(to, order[from], removeAt(from, order))
}
}
const onDragStart = (e: DragEvent, value: string) => {
dragged = value
dropped = false
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
}
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
}
const onDragEnter = (e: DragEvent, value: string) => {
e.preventDefault()
if (dragged) {
moveTo(dragged, value)
}
}
const onDrop = (e: DragEvent, value: string) => {
e.preventDefault()
if (dragged) {
moveTo(dragged, value)
}
dragged = undefined
dropped = true
if (preview) {
onReorder(preview)
}
}
const onDragEnd = () => {
if (!dropped) {
preview = undefined
}
dragged = undefined
}
let preview = $state<string[] | undefined>()
let lastItems = $state<string[]>([])
let dragged = $state<string | undefined>()
let dropped = $state(false)
// Reordering is previewed locally, so a drag reads as movement without the caller hearing about
// every position the item passes through. It hears about it once, on drop.
const order = $derived(preview ?? items)
// The preview outlives the drop, since the caller takes a moment to publish the new order, and
// is dropped as soon as the items it was built from change.
$effect(() => {
if (!isSameOrder(items, lastItems)) {
lastItems = items
preview = undefined
}
})
</script>
<div class={className} {role}>
{#each order as value (value)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class={cx("transition-opacity duration-200", itemClass, {"opacity-50": dragged === value})}
draggable="true"
role={itemRole}
ondragstart={e => onDragStart(e, value)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, value)}
ondrop={e => onDrop(e, value)}
ondragend={onDragEnd}>
{@render item(value)}
</div>
{/each}
</div>

View file

@ -1,13 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing"
import {derived as _derived} from "svelte/store"
import {addToMapKey, dec, insertAt, removeAt, sleep, spec} from "@welshman/lib"
import {addToMapKey, dec, sleep, spec} from "@welshman/lib"
import {ROOMS} from "@welshman/util"
import type {Relay} from "@welshman/domain"
import {throttled} from "@welshman/store"
import {Sync, createSearch, publish} from "@welshman/app"
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"
@ -16,6 +14,7 @@
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"
@ -27,7 +26,7 @@
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import {app, relays, roomLists, user} from "@app/core"
import {userSpaceUrls} from "@app/rooms"
import {reorderSpaceUrls, userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
import {bootstrapPubkeys} from "@app/social"
import {parseInviteLink} from "@app/access"
@ -85,108 +84,15 @@
}
}
const reconcileUrls = (currentUrls: string[], nextUrls: string[]) => {
const mergedUrls = currentUrls.filter(url => nextUrls.includes(url))
for (const url of nextUrls) {
if (!mergedUrls.includes(url)) {
mergedUrls.push(url)
}
}
return mergedUrls
}
const isSameOrder = (a: string[], b: string[]) =>
a.length === b.length && a.every((url, index) => url === b[index])
const reorderSpaceUrls = (targetUrl: string) => {
if (!draggedUrl) return
const sourceIndex = orderedSpaceUrls.indexOf(draggedUrl)
const targetIndex = orderedSpaceUrls.indexOf(targetUrl)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) return
orderedSpaceUrls = insertAt(
targetIndex,
orderedSpaceUrls[sourceIndex],
removeAt(sourceIndex, orderedSpaceUrls),
)
}
const onDragStart = (e: DragEvent, url: string) => {
draggedUrl = url
dragStartOrder = [...orderedSpaceUrls]
lastDragTarget = undefined
didDrop = false
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", url)
}
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
}
const onDragEnter = (e: DragEvent, targetUrl: string) => {
e.preventDefault()
if (lastDragTarget === targetUrl) return
lastDragTarget = targetUrl
reorderSpaceUrls(targetUrl)
}
const onDrop = (e: DragEvent, targetUrl: string) => {
e.preventDefault()
reorderSpaceUrls(targetUrl)
didDrop = true
draggedUrl = undefined
lastDragTarget = undefined
if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
void $roomLists.setRelays(orderedSpaceUrls).then(publish).catch(console.error)
}
dragStartOrder = undefined
}
const onDragEnd = () => {
if (!didDrop && dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
orderedSpaceUrls = dragStartOrder
}
draggedUrl = undefined
dragStartOrder = undefined
lastDragTarget = undefined
didDrop = false
}
$effect(() => {
const nextUrls = reconcileUrls(orderedSpaceUrls, $userSpaceUrls)
if (!isSameOrder(nextUrls, orderedSpaceUrls)) {
orderedSpaceUrls = nextUrls
}
})
let term = $state("")
let limit = $state(20)
let element: Element
let orderedSpaceUrls = $state<string[]>([])
let draggedUrl = $state<string | undefined>()
let dragStartOrder = $state<string[] | undefined>()
let lastDragTarget = $state<string | undefined>()
let didDrop = $state(false)
const inviteData = $derived(parseInviteLink(term))
const searchResults = $derived($relaySearch.searchOptions(term))
const userSpaceSet = $derived(new Set($userSpaceUrls))
const filteredUserUrls = $derived(
term ? orderedSpaceUrls.filter(url => searchResults.some(spec({url}))) : orderedSpaceUrls,
term ? $userSpaceUrls.filter(url => searchResults.some(spec({url}))) : $userSpaceUrls,
)
const otherSpaces = $derived(
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
@ -267,17 +173,13 @@
{/if}
{#if filteredUserUrls.length > 0}
<Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, url)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<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)}>
@ -294,8 +196,8 @@
</div>
{/if}
</Button>
</div>
{/each}
{/snippet}
</DragList>
{:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}