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.
|
- 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
|
- Clicking a joined space opens it; clicking one she hasn't joined opens a join
|
||||||
prompt instead.
|
prompt instead.
|
||||||
- Dragging a joined space above another reorders the list immediately, and the
|
- Dragging a joined space above another, on the page or in the sidebar rail,
|
||||||
order survives a reload.
|
reorders the list immediately, and the order survives a reload.
|
||||||
|
|
||||||
### US-010 — Join a space from an invite link
|
### 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
|
// 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())
|
||||||
|
|
||||||
|
// 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 source = joined.filter({hasText: other.url})
|
||||||
const target = joined.filter({hasText: space.url})
|
const target = joined.filter({hasText: space.url})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.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 PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||||
import PrimaryNavSpacesOverflow from "@app/components/PrimaryNavSpacesOverflow.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 {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
@ -48,9 +49,14 @@
|
||||||
<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 />
|
||||||
{#each primarySpaceUrls as url (url)}
|
<DragList
|
||||||
<PrimaryNavItemSpace {url} />
|
class="flex flex-col items-center"
|
||||||
{/each}
|
items={primarySpaceUrls}
|
||||||
|
onReorder={reorderSpaceUrls}>
|
||||||
|
{#snippet item(url)}
|
||||||
|
<PrimaryNavItemSpace {url} />
|
||||||
|
{/snippet}
|
||||||
|
</DragList>
|
||||||
{#snippet allSpaces(title: string)}
|
{#snippet allSpaces(title: string)}
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
href="/spaces"
|
href="/spaces"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
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 {formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {MESSAGE, makeEvent, outbox, seen, toNostrURI} from "@welshman/util"
|
import {MESSAGE, makeEvent, outbox, seen, toNostrURI} from "@welshman/util"
|
||||||
import type {EventContent, TrustedEvent} 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 type {Room, RoomMeta} from "@welshman/app"
|
||||||
import {
|
import {
|
||||||
deriveUserItem,
|
deriveUserItem,
|
||||||
|
|
@ -126,6 +126,16 @@ export const userRoomList = deriveUserItem(RoomLists)
|
||||||
|
|
||||||
export const userSpaceUrls = derived(userRoomList, $userRoomList => $userRoomList?.urls() ?? [])
|
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.
|
// Rooms in the space the user has joined, limited to those the relay still advertises.
|
||||||
export const deriveUserRooms = (url: string) =>
|
export const deriveUserRooms = (url: string) =>
|
||||||
derived(
|
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">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {flip} from "svelte/animate"
|
|
||||||
import {cubicOut} from "svelte/easing"
|
|
||||||
import {derived as _derived} from "svelte/store"
|
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 {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, publish} 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 DragHandle from "@assets/icons/drag-handle.svg?dataurl"
|
||||||
|
|
@ -16,6 +14,7 @@
|
||||||
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"
|
||||||
|
|
@ -27,7 +26,7 @@
|
||||||
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 {userSpaceUrls} from "@app/rooms"
|
import {reorderSpaceUrls, 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"
|
||||||
|
|
@ -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 term = $state("")
|
||||||
let limit = $state(20)
|
let limit = $state(20)
|
||||||
let element: Element
|
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 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(
|
const filteredUserUrls = $derived(
|
||||||
term ? orderedSpaceUrls.filter(url => searchResults.some(spec({url}))) : orderedSpaceUrls,
|
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),
|
||||||
|
|
@ -267,17 +173,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if filteredUserUrls.length > 0}
|
{#if filteredUserUrls.length > 0}
|
||||||
<Divider>Your spaces</Divider>
|
<Divider>Your spaces</Divider>
|
||||||
{#each filteredUserUrls as url (url)}
|
<DragList
|
||||||
<div
|
class="flex flex-col gap-2"
|
||||||
animate:flip={{duration: 300, easing: cubicOut}}
|
role="list"
|
||||||
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
|
itemRole="listitem"
|
||||||
draggable="true"
|
items={filteredUserUrls}
|
||||||
role="listitem"
|
onReorder={reorderSpaceUrls}>
|
||||||
ondragstart={e => onDragStart(e, url)}
|
{#snippet item(url)}
|
||||||
ondragover={onDragOver}
|
|
||||||
ondragenter={e => onDragEnter(e, url)}
|
|
||||||
ondrop={e => onDrop(e, url)}
|
|
||||||
ondragend={onDragEnd}>
|
|
||||||
<Button
|
<Button
|
||||||
class="group card card-interactive w-full relative min-w-0"
|
class="group card card-interactive w-full relative min-w-0"
|
||||||
onclick={() => openSpace(url)}>
|
onclick={() => openSpace(url)}>
|
||||||
|
|
@ -294,8 +196,8 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{/snippet}
|
||||||
{/each}
|
</DragList>
|
||||||
{:else if !term}
|
{:else if !term}
|
||||||
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
|
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue