Let space icons be dragged to reorder in the sidebar
This commit is contained in:
parent
d1da119389
commit
2c06a107f2
6 changed files with 174 additions and 120 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
118
src/lib/components/DragList.svelte
Normal file
118
src/lib/components/DragList.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue