fix: stabilize list loading and show correct list count

This commit is contained in:
Bhavishy
2026-04-14 19:26:43 +05:30
parent 2cea6c4ef4
commit 0db751bd45
13 changed files with 792 additions and 491 deletions
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayPubkey} from "@welshman/util"
import {formatTimestamp} from "@welshman/lib"
import {displayProfileByPubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
type BookmarkItem = {
key: string
event: TrustedEvent
href: string
external: boolean
image: string | undefined
video: string | undefined
contentType: "image" | "video" | "text"
preview: string
pollOptions: string[]
searchable: string
}
type Props = {
item: BookmarkItem
showAddToSaved: boolean
onOpen: (item: BookmarkItem) => Promise<void> | void
onCopyLink: (item: BookmarkItem) => Promise<void> | void
onAddToSaved: (item: BookmarkItem) => Promise<void> | void
onRemove: (item: BookmarkItem) => Promise<void> | void
}
const {item, showAddToSaved, onOpen, onCopyLink, onAddToSaved, onRemove}: Props = $props()
let openMenu = $state(false)
const closeMenu = () => {
openMenu = false
}
const toggleMenu = (event: Event) => {
event.preventDefault()
event.stopPropagation()
openMenu = !openMenu
}
const openItem = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onOpen(item)
}
const copyLink = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onCopyLink(item)
}
const addToSaved = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onAddToSaved(item)
}
const removeFromList = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
closeMenu()
await onRemove(item)
}
</script>
<svelte:window onclick={closeMenu} />
<Link
href={item.href}
external={item.external}
class="card2 bg-alt col-2 w-full gap-3 p-3 transition-colors hover:bg-base-100 md:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<ProfileCircle pubkey={item.event.pubkey} size={5} />
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-primary">
{displayProfileByPubkey(item.event.pubkey)}
</p>
<p class="truncate text-[11px] opacity-60">
{displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)}
</p>
</div>
</div>
<div class="relative" role="presentation">
<Button class="btn btn-ghost btn-xs btn-square" onclick={toggleMenu}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if openMenu}
<ul
class="menu absolute right-0 top-7 z-popover w-44 rounded-box bg-base-100 p-2 shadow-xl"
role="menu">
<li>
<Button class="justify-start" onclick={openItem}>Open bookmark</Button>
</li>
<li>
<Button class="justify-start" onclick={copyLink}>Copy link</Button>
</li>
{#if showAddToSaved}
<li>
<Button class="justify-start" onclick={addToSaved}>Add to Saved Items</Button>
</li>
{/if}
<li>
<Button class="justify-start text-error" onclick={removeFromList}
>Remove from this list</Button>
</li>
</ul>
{/if}
</div>
</div>
{#if item.image}
<ContentLinkBlockImage
value={{url: item.image}}
event={item.event}
class="max-h-[28rem] w-full rounded-lg object-contain" />
{:else if item.video}
<video
src={item.video}
class="max-h-[28rem] w-full rounded-lg object-contain"
muted
playsinline
preload="metadata"></video>
{/if}
<NoteContentMinimal event={item.event} />
</Link>
@@ -0,0 +1,23 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import {pushModal} from "@app/util/modal"
type Props = {
event: TrustedEvent
}
const {event}: Props = $props()
const bookmark = () => pushModal(BookmarkListPicker, {event})
</script>
<li>
<Button onclick={bookmark}>
<Icon size={4} icon={Bookmark} />
Bookmark
</Button>
</li>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import BookmarkCard from "@app/components/BookmarkCard.svelte"
type BookmarkItem = {
key: string
event: TrustedEvent
href: string
external: boolean
image: string | undefined
video: string | undefined
contentType: "image" | "video" | "text"
preview: string
pollOptions: string[]
searchable: string
}
type Props = {
items: BookmarkItem[]
showAddToSaved: boolean
onOpen: (item: BookmarkItem) => Promise<void> | void
onCopyLink: (item: BookmarkItem) => Promise<void> | void
onAddToSaved: (item: BookmarkItem) => Promise<void> | void
onRemove: (item: BookmarkItem) => Promise<void> | void
}
const {items, showAddToSaved, onOpen, onCopyLink, onAddToSaved, onRemove}: Props = $props()
</script>
<div class="flex w-full flex-wrap content-start items-stretch gap-3">
{#each items as item (item.key)}
<BookmarkCard {item} {showAddToSaved} {onOpen} {onCopyLink} {onAddToSaved} {onRemove} />
{:else}
<div class="card2 bg-alt col-2 max-w-lg p-5 text-sm opacity-80">
No items match your current filters.
</div>
{/each}
</div>
+34 -14
View File
@@ -2,7 +2,14 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {first, uniqBy} from "@welshman/lib" import {first, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {DELETE, getTagValue, getTagValues, sortEventsDesc} from "@welshman/util" import {
Address,
DELETE,
getAddress,
getTagValue,
getTagValues,
sortEventsDesc,
} from "@welshman/util"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {pubkey, waitForThunkError} from "@welshman/app" import {pubkey, waitForThunkError} from "@welshman/app"
import Bookmark from "@assets/icons/bookmark.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl"
@@ -18,7 +25,13 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {createBookmarkList, addEventBookmark, deleteBookmarkList} from "@app/core/commands" import {createBookmarkList, addEventBookmark, deleteBookmarkList} from "@app/core/commands"
import {deriveEvents, INDEXER_RELAYS, shouldIgnoreError} from "@app/core/state" import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents,
INDEXER_RELAYS,
shouldIgnoreError,
} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
type Props = { type Props = {
@@ -36,10 +49,14 @@
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}]) const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
const getListKey = (listEvent: TrustedEvent) => const getListKey = (listEvent: TrustedEvent) =>
`${listEvent.kind}:${getTagValue("d", listEvent.tags) || ""}` listEvent.kind === BOOKMARKS
? new Address(BOOKMARKS, listEvent.pubkey, "").toString()
: getAddress(listEvent)
const getListLabel = (listEvent: TrustedEvent) => const getListLabel = (listEvent: TrustedEvent) =>
getTagValue("d", listEvent.tags) || (listEvent.kind === 10003 ? "Saved Items" : "Untitled List") getTagValue("title", listEvent.tags) ||
getTagValue("d", listEvent.tags) ||
(listEvent.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
const userLists = $derived( const userLists = $derived(
sortEventsDesc($listEvents).filter( sortEventsDesc($listEvents).filter(
@@ -54,8 +71,7 @@
) )
const isDeletedList = (listEvent: TrustedEvent) => { const isDeletedList = (listEvent: TrustedEvent) => {
const d = getTagValue("d", listEvent.tags) || "" const address = getListKey(listEvent)
const address = `${listEvent.kind}:${listEvent.pubkey}:${d}`
return userDeletes.some(deleteEvent => { return userDeletes.some(deleteEvent => {
if (deleteEvent.created_at < listEvent.created_at) { if (deleteEvent.created_at < listEvent.created_at) {
@@ -82,13 +98,13 @@
}), }),
) )
const savedItems = first(mapped.filter(list => list.key === "10003:")) || { const savedItems = first(mapped.filter(list => Address.from(list.key).kind === BOOKMARKS)) || {
key: "10003:", key: $pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "10003:",
label: "Saved Items", label: "Saved Items",
count: 0, count: 0,
} }
return [savedItems, ...mapped.filter(list => list.key !== "10003:")] return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
}) })
let listName = $state("") let listName = $state("")
@@ -159,7 +175,7 @@
} }
const deleteList = async (key: string, label: string) => { const deleteList = async (key: string, label: string) => {
if (deleting || key === "10003:") { if (deleting || Address.from(key).kind === BOOKMARKS) {
return return
} }
@@ -198,7 +214,7 @@
await load({ await load({
relays: INDEXER_RELAYS, relays: INDEXER_RELAYS,
filters: [{kinds: [10003, 30003, DELETE], authors: [$pubkey]}], filters: [{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE], authors: [$pubkey]}],
}) })
} }
@@ -249,7 +265,9 @@
onclick={() => addToList(list.key, list.label)} onclick={() => addToList(list.key, list.label)}
disabled={selecting}> disabled={selecting}>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<Icon size={4} icon={list.key === "10003:" ? Bookmark : Folder} /> <Icon
size={4}
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
{list.label} {list.label}
</span> </span>
<span class="badge badge-sm badge-neutral">{list.count}</span> <span class="badge badge-sm badge-neutral">{list.count}</span>
@@ -257,12 +275,14 @@
{:else} {:else}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2"> <div class="card2 card2-sm bg-alt flex items-center justify-between gap-2">
<span class="min-w-0 truncate inline-flex items-center gap-2"> <span class="min-w-0 truncate inline-flex items-center gap-2">
<Icon size={4} icon={list.key === "10003:" ? Bookmark : Folder} /> <Icon
size={4}
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
{list.label} {list.label}
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="badge badge-sm badge-neutral">{list.count}</span> <span class="badge badge-sm badge-neutral">{list.count}</span>
{#if list.key !== "10003:"} {#if Address.from(list.key).kind !== BOOKMARKS}
<Button <Button
class="btn btn-ghost btn-xs btn-square text-error" class="btn btn-ghost btn-xs btn-square text-error"
onclick={() => deleteList(list.key, list.label)} onclick={() => deleteList(list.key, list.label)}
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import {type TrustedEvent} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Folder from "@assets/icons/folder.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
type BookmarkList = {
key: string
label: string
count: number
event?: TrustedEvent
}
type Props = {
lists: BookmarkList[]
selectedKey: string
totalCount: number
onOpenManager: () => void
onSelect: (key: string) => void
onRename: (key: string, label: string, nextLabel: string) => Promise<void>
onDelete: (key: string, label: string) => Promise<void>
}
const {lists, selectedKey, totalCount, onOpenManager, onSelect, onRename, onDelete}: Props =
$props()
let menuOpen = $state(false)
let menuX = $state(0)
let menuY = $state(0)
let menuListKey = $state("")
let menuListLabel = $state("")
let listDialogMode: "rename" | "delete" | undefined = $state()
let dialogListKey = $state("")
let dialogListLabel = $state("")
let nextListLabel = $state("")
let dialogPending = $state(false)
const closeMenu = () => {
menuOpen = false
}
const openListMenu = (event: MouseEvent, key: string, label: string) => {
event.preventDefault()
menuOpen = true
menuX = event.clientX
menuY = event.clientY
menuListKey = key
menuListLabel = label
}
const openMenuList = () => {
closeMenu()
onSelect(menuListKey)
}
const openRenameDialog = () => {
closeMenu()
listDialogMode = "rename"
dialogListKey = menuListKey
dialogListLabel = menuListLabel
nextListLabel = menuListLabel
}
const openDeleteDialog = () => {
closeMenu()
listDialogMode = "delete"
dialogListKey = menuListKey
dialogListLabel = menuListLabel
nextListLabel = menuListLabel
}
const closeDialog = (force = false) => {
if (!force && dialogPending) {
return
}
listDialogMode = undefined
dialogListKey = ""
dialogListLabel = ""
nextListLabel = ""
}
const submitRename = async () => {
if (listDialogMode !== "rename") {
return
}
dialogPending = true
try {
await onRename(dialogListKey, dialogListLabel, nextListLabel)
closeDialog(true)
} finally {
dialogPending = false
}
}
const submitDelete = async () => {
if (listDialogMode !== "delete") {
return
}
dialogPending = true
try {
await onDelete(dialogListKey, dialogListLabel)
closeDialog(true)
} finally {
dialogPending = false
}
}
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu()
closeDialog()
}
}
</script>
<svelte:window onclick={closeMenu} onkeydown={handleWindowKeydown} />
<SecondaryNav class="relative">
<SecondaryNavSection>
<SecondaryNavHeader>
<span class="flex items-center gap-2 uppercase tracking-wide">
<Icon icon={Bookmark} />
Bookmarks
</span>
<span class="badge badge-sm badge-neutral">{totalCount}</span>
</SecondaryNavHeader>
<div
class="flex items-center justify-between px-1 pt-1 text-xs uppercase tracking-wide opacity-70">
<span>My Lists</span>
<Button class="btn btn-ghost btn-xs btn-square" onclick={onOpenManager}>
<Icon size={3.5} icon={Add} />
</Button>
</div>
</SecondaryNavSection>
<div class="col-2 gap-2 overflow-y-auto px-2 pb-2">
{#each lists as list (list.key)}
<div
role="button"
tabindex="-1"
oncontextmenu={event => openListMenu(event, list.key, list.label)}>
<Link href={`/bookmarks?list=${encodeURIComponent(list.key)}`}>
<div
class={`card2 card2-sm bg-alt col-2 gap-1 transition-colors hover:bg-base-100 ${selectedKey === list.key ? "bg-base-100" : ""}`}>
<div class="flex items-center justify-between gap-2">
<p class="truncate font-medium">
<span class="inline-flex items-center gap-2">
<Icon size={4} icon={list.key.startsWith("10003:") ? Bookmark : Folder} />
{list.label}
</span>
</p>
<span class="badge badge-sm badge-neutral">{list.count}</span>
</div>
</div>
</Link>
</div>
{:else}
<div class="card2 card2-sm bg-alt text-sm opacity-70">No lists found yet.</div>
{/each}
</div>
{#if menuOpen}
<div class="fixed inset-0 z-popover" role="presentation">
<div
class="menu rounded-box bg-base-100 shadow-xl"
role="menu"
style={`position: fixed; left: ${menuX}px; top: ${menuY}px; min-width: 11rem;`}>
<li>
<Button class="justify-start" onclick={openMenuList}>Open List</Button>
</li>
{#if menuListKey !== selectedKey}
<li>
<Button class="justify-start" onclick={openRenameDialog}>Rename List</Button>
</li>
<li>
<Button class="justify-start text-error" onclick={openDeleteDialog}>Delete List</Button>
</li>
{/if}
</div>
</div>
{/if}
{#if listDialogMode}
<div class="fixed inset-0 z-modal bg-black/45 p-4 md:p-6">
<button
type="button"
class="absolute inset-0"
aria-label="Close dialog"
onclick={() => closeDialog()}></button>
<div class="center relative h-full w-full">
<div
class="card2 bg-base-100 w-full max-w-md gap-4 p-5 shadow-2xl"
role="dialog"
aria-modal="true"
tabindex="-1">
{#if listDialogMode === "rename"}
<div class="col-2 gap-1">
<h3 class="text-lg font-semibold">Rename List</h3>
<p class="text-sm opacity-70">Choose a new name for "{dialogListLabel}".</p>
</div>
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Folder} />
<input bind:value={nextListLabel} class="grow" type="text" placeholder="List name" />
</label>
<div class="flex items-center justify-end gap-2">
<Button class="btn btn-ghost" onclick={() => closeDialog()} disabled={dialogPending}
>Cancel</Button>
<Button
class="btn btn-neutral"
onclick={submitRename}
disabled={dialogPending || !nextListLabel.trim()}>
Save
</Button>
</div>
{:else}
<div class="col-2 gap-1">
<h3 class="text-lg font-semibold">Delete List?</h3>
<p class="text-sm opacity-70">
This will remove "{dialogListLabel}" and cannot be undone.
</p>
</div>
<div class="flex items-center justify-end gap-2">
<Button class="btn btn-ghost" onclick={() => closeDialog()} disabled={dialogPending}
>Cancel</Button>
<Button class="btn btn-error" onclick={submitDelete} disabled={dialogPending}
>Delete</Button>
</div>
{/if}
</div>
</div>
</div>
{/if}
</SecondaryNav>
@@ -12,6 +12,7 @@
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte" import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeCalendarPath, makeSpacePath} from "@app/util/routes" import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -51,6 +52,7 @@
{/if} {/if}
<EventActions {url} {event} noun="Event"> <EventActions {url} {event} noun="Event">
{#snippet customActions()} {#snippet customActions()}
<BookmarkEventMenuItem {event} />
{#if event.pubkey === $pubkey} {#if event.pubkey === $pubkey}
<li> <li>
<Button onclick={editEvent}> <Button onclick={editEvent}>
@@ -15,6 +15,7 @@
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte" import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes" import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -64,6 +65,7 @@
{/if} {/if}
<EventActions {url} {event} noun="Listing"> <EventActions {url} {event} noun="Listing">
{#snippet customActions()} {#snippet customActions()}
<BookmarkEventMenuItem {event} />
{#if event.pubkey === $pubkey} {#if event.pubkey === $pubkey}
<li> <li>
<Button onclick={editClassified}> <Button onclick={editClassified}>
+6 -1
View File
@@ -4,6 +4,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
@@ -34,6 +35,10 @@
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
<EventActions {url} {event} noun="Comment" /> <EventActions {url} {event} noun="Comment">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{/snippet}
</EventActions>
</div> </div>
</div> </div>
+104 -8
View File
@@ -1,6 +1,18 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {userProfile} from "@welshman/app" import {onMount} from "svelte"
import {uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
Address,
DELETE,
getAddress,
getTagValue,
getTagValues,
sortEventsDesc,
} from "@welshman/util"
import {load} from "@welshman/net"
import {pubkey, userProfile} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -8,11 +20,19 @@
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte" import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state" import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents,
INDEXER_RELAYS,
loadUserBookmarkCollections,
loadUserBookmarkList,
userSpaceUrls,
PLATFORM_RELAYS,
} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {goToChat, makeSpacePath} from "@app/util/routes" import {goToChat, makeSpacePath} from "@app/util/routes"
@@ -30,18 +50,90 @@
const anySpaceNotifications = $derived( const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))), $userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
) )
const bookmarkListEvents = deriveEvents([{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE]}])
let loadedBookmarkCountPubkey: string | undefined = $state()
const getListKey = (event: TrustedEvent) =>
event.kind === BOOKMARKS
? new Address(BOOKMARKS, event.pubkey, "").toString()
: getAddress(event)
const isDeletedList = (event: TrustedEvent, deleteEvents: TrustedEvent[]) => {
const address =
event.kind === BOOKMARKS
? new Address(BOOKMARKS, event.pubkey, "").toString()
: `${event.kind}:${event.pubkey}:${getTagValue("d", event.tags) || ""}`
return deleteEvents.some(deleteEvent => {
if (deleteEvent.created_at < event.created_at) {
return false
}
return (
getTagValues("e", deleteEvent.tags).includes(event.id) ||
getTagValues("a", deleteEvent.tags).includes(address)
)
})
}
const bookmarkListCount = $derived.by(() => {
if (!$pubkey) {
return 0
}
const ownEvents = sortEventsDesc($bookmarkListEvents).filter(event => event.pubkey === $pubkey)
const deleteEvents = ownEvents.filter(event => event.kind === DELETE)
const listEvents = ownEvents.filter(
event => event.kind === BOOKMARKS || event.kind === BOOKMARK_LISTS,
)
const visibleLists = uniqBy(getListKey, listEvents).filter(
event => !isDeletedList(event, deleteEvents),
)
const hasSavedItems = visibleLists.some(event => event.kind === BOOKMARKS)
return visibleLists.length + (hasSavedItems ? 0 : 1)
})
const loadBookmarkCountData = async () => {
if (!$pubkey || loadedBookmarkCountPubkey === $pubkey) {
return
}
loadedBookmarkCountPubkey = $pubkey
await Promise.all([
loadUserBookmarkList(),
loadUserBookmarkCollections(),
load({
relays: INDEXER_RELAYS,
filters: [{kinds: [DELETE], authors: [$pubkey]}],
}),
])
}
onMount(() => {
void loadBookmarkCountData()
})
$effect(() => {
if ($pubkey && loadedBookmarkCountPubkey !== $pubkey) {
void loadBookmarkCountData()
}
})
</script> </script>
<div <div
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block"> class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}> <div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces /> <PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
<div class="flex flex-col"> <div class="flex flex-col">
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks"> <PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} /> <div class="relative">
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
<span class="badge badge-xs badge-neutral absolute -right-2 -top-2"
>{bookmarkListCount}</span>
</div>
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings"> <PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
{#if $userProfile?.picture} {#if $userProfile?.picture}
@@ -83,7 +175,11 @@
<ImageIcon alt="Messages" src={Letter} size={8} /> <ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks"> <PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} /> <div class="relative">
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
<span class="badge badge-xs badge-neutral absolute -right-2 -top-2"
>{bookmarkListCount}</span>
</div>
</PrimaryNavItem> </PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1} {#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}> <PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
+6 -1
View File
@@ -7,6 +7,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath, makeSpacePath} from "@app/util/routes" import {makeThreadPath, makeSpacePath} from "@app/util/routes"
@@ -41,5 +42,9 @@
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
<EventActions {url} {event} noun="Thread" /> <EventActions {url} {event} noun="Thread">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{/snippet}
</EventActions>
</div> </div>
+55 -69
View File
@@ -14,6 +14,7 @@ import {
simpleCache, simpleCache,
normalizeUrl, normalizeUrl,
nthNe, nthNe,
randomId,
} from "@welshman/lib" } from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer" import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor" import type {UploadTask} from "@welshman/editor"
@@ -33,11 +34,11 @@ import {
ROOMS, ROOMS,
COMMENT, COMMENT,
APP_DATA, APP_DATA,
asDecryptedEvent,
isSignedEvent, isSignedEvent,
makeEvent, makeEvent,
normalizeRelayUrl, normalizeRelayUrl,
makeList, makeList,
Address,
addToListPublicly, addToListPublicly,
removeFromListByPredicate, removeFromListByPredicate,
updateList, updateList,
@@ -49,7 +50,6 @@ import {
toNostrURI, toNostrURI,
RelayMode, RelayMode,
getTagValues, getTagValues,
readList,
uploadBlob, uploadBlob,
canUploadBlob, canUploadBlob,
encryptFile, encryptFile,
@@ -86,10 +86,14 @@ import {
import {compressFile} from "@lib/html" import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state" import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
import { import {
BOOKMARKS,
BOOKMARK_LISTS,
SETTINGS, SETTINGS,
PROTECTED, PROTECTED,
INDEXER_RELAYS, INDEXER_RELAYS,
DEFAULT_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVERS,
getBookmarkList,
getBookmarkCollection,
userSpaceUrls, userSpaceUrls,
userSettingsValues, userSettingsValues,
getSetting, getSetting,
@@ -171,59 +175,51 @@ export const broadcastUserData = async (relays: string[]) => {
// List updates // List updates
const BOOKMARKS = 10003 const getSavedItemsList = (owner = pubkey.get() || "") => {
const BOOKMARK_LISTS = 30003 if (!owner) {
return makeList({kind: BOOKMARKS})
const parseListKey = (key: string) => {
const [kind, ...rest] = key.split(":")
return {
kind: parseInt(kind),
d: rest.join(":"),
} }
return getBookmarkList().get(owner) || makeList({kind: BOOKMARKS})
} }
const getUserBookmarkList = (key = `${BOOKMARKS}:`) => { const getBookmarkListFromAddress = (address: string) => {
const author = pubkey.get() const parsed = Address.from(address)
const {kind, d} = parseListKey(key)
if (!author) { if (parsed.kind === BOOKMARKS) {
return makeList({kind}) return getSavedItemsList(parsed.pubkey)
} }
const latest = first( if (parsed.kind === BOOKMARK_LISTS) {
repository return getBookmarkCollection().get(address)
.query([{kinds: [kind], authors: [author]}]) }
.filter(event => getTagValue("d", event.tags) === d),
)
return latest ? readList(asDecryptedEvent(latest)) : makeList({kind}) return undefined
} }
export const createBookmarkList = async (title: string) => { export const createBookmarkList = async (title: string) => {
const d = title.trim() const label = title.trim()
if (!d) { if (!label) {
return
}
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${d}`)
if (existing.event?.id) {
return return
} }
const list = makeList({kind: BOOKMARK_LISTS}) const list = makeList({kind: BOOKMARK_LISTS})
const event = await updateList(list, {publicTags: [["d", d]]}).reconcile(nip44EncryptToSelf) const event = await updateList(list, {
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) publicTags: [
["d", randomId()],
["title", label],
],
}).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => { export const addEventBookmark = async (target: TrustedEvent, address?: string) => {
const list = getUserBookmarkList(key) const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
const {d} = parseListKey(key)
if (d && !list.event?.id) { if (!list) {
return return
} }
@@ -234,20 +230,15 @@ export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}
} }
const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = Router.get().FromUser().getUrls()
...Router.get().FromUser().getUrls(),
...Router.get().Event(target).limit(3).getUrls(),
...getRelayTagValues(event.tags),
])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => { export const removeEventBookmark = async (target: TrustedEvent, address?: string) => {
const list = getUserBookmarkList(key) const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
const {d} = parseListKey(key)
if (d && !list.event?.id) { if (!list) {
return return
} }
@@ -269,51 +260,46 @@ export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMAR
(tag[0] === "e" && tag[1] === target.id) || (tag[0] === "e" && tag[1] === target.id) ||
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress), (targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
).reconcile(nip44EncryptToSelf) ).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = Router.get().FromUser().getUrls()
...INDEXER_RELAYS,
...getRelayTagValues(event.tags),
...Router.get().Event(target).limit(3).getUrls(),
])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const deleteBookmarkList = async (key: string) => { export const deleteBookmarkList = async (address: string) => {
const list = getUserBookmarkList(key) const list = getBookmarkCollection().get(address)
const {kind, d} = parseListKey(key) const {kind} = Address.from(address)
if (kind !== BOOKMARK_LISTS || !d || !list.event) { if (kind !== BOOKMARK_LISTS || !list?.event) {
return return
} }
const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)]) const relays = Router.get().FromUser().getUrls()
const address = `${kind}:${list.event.pubkey}:${d}`
return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays}) return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays})
} }
export const renameBookmarkList = async (key: string, title: string) => { export const renameBookmarkList = async (address: string, title: string) => {
const list = getUserBookmarkList(key) const list = getBookmarkCollection().get(address)
const {kind, d} = parseListKey(key) const {kind} = Address.from(address)
const nextD = title.trim() const nextTitle = title.trim()
if (kind !== BOOKMARK_LISTS || !d || !nextD || !list.event) { if (kind !== BOOKMARK_LISTS || !nextTitle || !list?.event) {
return return
} }
if (nextD === d) { const currentTitle =
getTagValue("title", list.event.tags) || getTagValue("d", list.event.tags) || ""
if (nextTitle === currentTitle) {
return return
} }
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${nextD}`) const publicTags = [
["d", getTagValue("d", list.event.tags) || randomId()],
if (existing.event?.id && existing.event.id !== list.event.id) { ["title", nextTitle],
return ]
}
const publicTags = [["d", nextD], ...list.publicTags.filter(tag => tag[0] !== "d")]
const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf) const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)]) const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
+48
View File
@@ -161,6 +161,10 @@ export const PROTECTED = ["-"]
export const MESSAGE_KINDS = [MESSAGE] export const MESSAGE_KINDS = [MESSAGE]
export const BOOKMARKS = 10003
export const BOOKMARK_LISTS = 30003
export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"] export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
@@ -706,6 +710,50 @@ export const deriveOtherVoiceRooms = (url: string) =>
// User space/room lists // User space/room lists
export const bookmarkListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [BOOKMARKS]}],
getKey: list => list.event.pubkey,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const getBookmarkList = getter(bookmarkListsByPubkey)
export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList)
export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList)
export const loadUserBookmarkList = makeUserLoader(loadBookmarkList)
export const bookmarkCollectionsByAddress = deriveItemsByKey({
repository,
filters: [{kinds: [BOOKMARK_LISTS]}],
getKey: list => getAddress(list.event),
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const getBookmarkCollection = getter(bookmarkCollectionsByAddress)
export const bookmarkCollections = deriveItems(bookmarkCollectionsByAddress)
export const bookmarkCollectionsByPubkey = derived(bookmarkCollections, $lists =>
groupBy(list => list.event.pubkey, $lists),
)
export const getBookmarkCollections = getter(bookmarkCollectionsByPubkey)
export const loadBookmarkCollections = makeLoadItem(
makeOutboxLoader(BOOKMARK_LISTS),
getBookmarkCollections,
)
export const userBookmarkCollections = makeUserData(
bookmarkCollectionsByPubkey,
loadBookmarkCollections,
)
export const loadUserBookmarkCollections = makeUserLoader(loadBookmarkCollections)
export const groupListsByPubkey = deriveItemsByKey({ export const groupListsByPubkey = deriveItemsByKey({
repository, repository,
filters: [{kinds: [ROOMS]}], filters: [{kinds: [ROOMS]}],
+85 -398
View File
@@ -1,45 +1,31 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {onMount} from "svelte" import {onMount} from "svelte"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {page} from "$app/stores" import {page} from "$app/stores"
import {first, formatTimestamp, uniqBy} from "@welshman/lib" import {first, uniqBy} from "@welshman/lib"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import { import {
DELETE, DELETE,
displayPubkey,
getIdFilters, getIdFilters,
getTags, getTags,
getTagValue, getTagValue,
getTagValues, getTagValues,
sortEventsDesc, sortEventsDesc,
tagsFromIMeta, tagsFromIMeta,
Address,
getAddress,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {loadProfile, pubkey, repository, tracker, waitForThunkError} from "@welshman/app"
displayProfileByPubkey,
loadProfile,
pubkey,
repository,
tracker,
waitForThunkError,
} from "@welshman/app"
import Bookmark from "@assets/icons/bookmark.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Folder from "@assets/icons/folder.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import SliderMinimalisticHorizontal from "@assets/icons/slider-minimalistic-horizontal.svg?dataurl" import SliderMinimalisticHorizontal from "@assets/icons/slider-minimalistic-horizontal.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
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 SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte" import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte" import BookmarkGrid from "@app/components/BookmarkGrid.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import BookmarkSidebar from "@app/components/BookmarkSidebar.svelte"
import { import {
addEventBookmark, addEventBookmark,
deleteBookmarkList, deleteBookmarkList,
@@ -47,9 +33,13 @@
renameBookmarkList, renameBookmarkList,
} from "@app/core/commands" } from "@app/core/commands"
import { import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents, deriveEvents,
IMAGE_CONTENT_TYPES, IMAGE_CONTENT_TYPES,
INDEXER_RELAYS, INDEXER_RELAYS,
loadUserBookmarkCollections,
loadUserBookmarkList,
shouldIgnoreError, shouldIgnoreError,
VIDEO_CONTENT_TYPES, VIDEO_CONTENT_TYPES,
} from "@app/core/state" } from "@app/core/state"
@@ -73,17 +63,15 @@
external: boolean external: boolean
image: string | undefined image: string | undefined
video: string | undefined video: string | undefined
contentType: Exclude<ContentType, "all"> contentType: "image" | "video" | "text"
preview: string preview: string
pollOptions: string[] pollOptions: string[]
searchable: string searchable: string
} }
const BOOKMARKS = 10003 const savedItemsKey = $derived($pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "")
const CUSTOM_BOOKMARKS = 30003
const MANAGED_LIST_KINDS = [BOOKMARKS, CUSTOM_BOOKMARKS]
const listEvents = deriveEvents([{kinds: [...MANAGED_LIST_KINDS, DELETE]}]) const listEvents = deriveEvents([{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE]}])
const isImageUrl = (url: string) => /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(url) const isImageUrl = (url: string) => /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(url)
@@ -129,14 +117,6 @@
} }
} }
const asLinkValue = (url: string) => {
try {
return {url: new URL(url)}
} catch {
return undefined
}
}
const getPollQuestion = (event: TrustedEvent) => { const getPollQuestion = (event: TrustedEvent) => {
const lines = (event.content || "") const lines = (event.content || "")
.split("\n") .split("\n")
@@ -163,7 +143,7 @@
return clean.length > 140 ? `${clean.slice(0, 140).trim()}...` : clean return clean.length > 140 ? `${clean.slice(0, 140).trim()}...` : clean
} }
const detectContentType = (event: TrustedEvent): Exclude<ContentType, "all"> => { const detectContentType = (event: TrustedEvent): "image" | "video" | "text" => {
const media = getMediaPreview(event) const media = getMediaPreview(event)
if (media.image) { if (media.image) {
@@ -177,10 +157,15 @@
return "text" return "text"
} }
const getListKey = (event: TrustedEvent) => `${event.kind}:${getTagValue("d", event.tags) || ""}` const getListKey = (event: TrustedEvent) =>
event.kind === BOOKMARKS
? new Address(BOOKMARKS, event.pubkey, "").toString()
: getAddress(event)
const getListLabel = (event: TrustedEvent) => const getListLabel = (event: TrustedEvent) =>
getTagValue("d", event.tags) || (event.kind === BOOKMARKS ? "Saved Items" : "Untitled List") getTagValue("title", event.tags) ||
getTagValue("d", event.tags) ||
(event.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
const userListEvents = $derived( const userListEvents = $derived(
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE), sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE),
@@ -218,14 +203,14 @@
) )
.filter(list => (list.event ? !isDeletedList(list.event) : true)) .filter(list => (list.event ? !isDeletedList(list.event) : true))
const savedItems = first(mapped.filter(list => list.key === "10003:")) || { const savedItems = first(mapped.filter(list => list.key === savedItemsKey)) || {
key: "10003:", key: savedItemsKey,
label: "Saved Items", label: "Saved Items",
count: 0, count: 0,
event: undefined, event: undefined,
} }
return [savedItems, ...mapped.filter(list => list.key !== "10003:")] return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
}) })
const defaultListKey = $derived(sidebarLists[0]?.key || "") const defaultListKey = $derived(sidebarLists[0]?.key || "")
@@ -243,49 +228,9 @@
let searchTerm = $state("") let searchTerm = $state("")
let showSearch = $state(false) let showSearch = $state(false)
let contentType = $state<ContentType>("all") let contentType = $state<ContentType>("all")
let listsReady = $state(false)
let loadedListPubkey: string | undefined = $state() let loadedListPubkey: string | undefined = $state()
let loadedSelectionKey: string | undefined = $state() let loadedSelectionKey: string | undefined = $state()
let menuOpen = $state(false)
let menuX = $state(0)
let menuY = $state(0)
let menuListKey = $state("")
let menuListLabel = $state("")
let openBookmarkMenuKey: string | undefined = $state()
let listDialogMode: "rename" | "delete" | undefined = $state()
let dialogListKey = $state("")
let dialogListLabel = $state("")
let nextListLabel = $state("")
let listDialogPending = $state(false)
const closeListContextMenu = () => {
menuOpen = false
}
const closeBookmarkMenu = () => {
openBookmarkMenuKey = undefined
}
const toggleBookmarkMenu = (key: string) => {
openBookmarkMenuKey = openBookmarkMenuKey === key ? undefined : key
}
const openListDialog = (mode: "rename" | "delete", key: string, label: string) => {
listDialogMode = mode
dialogListKey = key
dialogListLabel = label
nextListLabel = label
}
const closeListDialog = (force = false) => {
if (!force && listDialogPending) {
return
}
listDialogMode = undefined
dialogListKey = ""
dialogListLabel = ""
nextListLabel = ""
}
const setFilterAll = () => { const setFilterAll = () => {
contentType = "all" contentType = "all"
@@ -315,8 +260,6 @@
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events") shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
const openBookmarkFromMenu = async (item: BookmarkItem) => { const openBookmarkFromMenu = async (item: BookmarkItem) => {
closeBookmarkMenu()
if (item.external) { if (item.external) {
window.open(item.href, "_blank", "noopener,noreferrer") window.open(item.href, "_blank", "noopener,noreferrer")
return return
@@ -326,8 +269,6 @@
} }
const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => { const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => {
closeBookmarkMenu()
try { try {
await navigator.clipboard.writeText(item.href) await navigator.clipboard.writeText(item.href)
pushToast({message: "Link copied"}) pushToast({message: "Link copied"})
@@ -337,9 +278,11 @@
} }
const addBookmarkToSavedItems = async (item: BookmarkItem) => { const addBookmarkToSavedItems = async (item: BookmarkItem) => {
closeBookmarkMenu() if (!savedItemsKey) {
return
}
const thunk = await addEventBookmark(item.event, "10003:") const thunk = await addEventBookmark(item.event, savedItemsKey)
if (!thunk) { if (!thunk) {
pushToast({message: "Already in Saved Items"}) pushToast({message: "Already in Saved Items"})
@@ -357,8 +300,6 @@
} }
const removeBookmarkFromCurrentList = async (item: BookmarkItem) => { const removeBookmarkFromCurrentList = async (item: BookmarkItem) => {
closeBookmarkMenu()
if (!selectedKey) { if (!selectedKey) {
return return
} }
@@ -383,7 +324,7 @@
} }
const deleteListFromSidebar = async (key: string, label: string) => { const deleteListFromSidebar = async (key: string, label: string) => {
if (key === "10003:") { if (key.startsWith(`${BOOKMARKS}:`)) {
return return
} }
@@ -404,12 +345,12 @@
pushToast({message: `Deleted ${label}`}) pushToast({message: `Deleted ${label}`})
if (selectedKey === key) { if (selectedKey === key) {
await goto(selectedListHref("10003:"), {replaceState: true, noScroll: true}) await goto(selectedListHref(savedItemsKey), {replaceState: true, noScroll: true})
} }
} }
const renameListFromSidebar = async (key: string, label: string, nextLabel: string) => { const renameListFromSidebar = async (key: string, label: string, nextLabel: string) => {
if (key === "10003:") { if (key.startsWith(`${BOOKMARKS}:`)) {
return return
} }
@@ -433,95 +374,7 @@
return return
} }
const deleteOldThunk = await deleteBookmarkList(key)
if (deleteOldThunk) {
const deleteOldError = await waitForThunkError(deleteOldThunk)
if (deleteOldError && !isIgnorableBookmarkListError(deleteOldError)) {
pushToast({theme: "error", message: deleteOldError})
return
}
}
pushToast({message: `Renamed to ${normalized}`}) pushToast({message: `Renamed to ${normalized}`})
if (selectedKey === key) {
await goto(selectedListHref(`${CUSTOM_BOOKMARKS}:${normalized}`), {
replaceState: true,
noScroll: true,
})
}
}
const onListContextMenu = async (event: MouseEvent, key: string, label: string) => {
event.preventDefault()
menuOpen = true
menuX = event.clientX
menuY = event.clientY
menuListKey = key
menuListLabel = label
}
const openListFromMenu = async () => {
closeListContextMenu()
await goto(selectedListHref(menuListKey), {replaceState: true, noScroll: true})
}
const renameListFromMenu = async () => {
closeListContextMenu()
openListDialog("rename", menuListKey, menuListLabel)
}
const deleteListFromMenu = async () => {
closeListContextMenu()
openListDialog("delete", menuListKey, menuListLabel)
}
const submitRenameDialog = async () => {
if (listDialogMode !== "rename") {
return
}
listDialogPending = true
try {
await renameListFromSidebar(dialogListKey, dialogListLabel, nextListLabel)
closeListDialog(true)
} finally {
listDialogPending = false
}
}
const submitDeleteDialog = async () => {
if (listDialogMode !== "delete") {
return
}
listDialogPending = true
try {
await deleteListFromSidebar(dialogListKey, dialogListLabel)
closeListDialog(true)
} finally {
listDialogPending = false
}
}
const handleListDialogBackdropClick = () => {
closeListDialog()
}
const cancelListDialog = () => {
closeListDialog()
}
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeListContextMenu()
closeBookmarkMenu()
closeListDialog()
}
} }
const normalizedTerm = $derived(searchTerm.trim().toLowerCase()) const normalizedTerm = $derived(searchTerm.trim().toLowerCase())
@@ -565,22 +418,31 @@
const selectedListHref = (key: string) => `/bookmarks?list=${encodeURIComponent(key)}` const selectedListHref = (key: string) => `/bookmarks?list=${encodeURIComponent(key)}`
const listClass = (active: boolean) =>
cx("card2 card2-sm bg-alt col-2 gap-1 transition-colors hover:bg-base-100", {
"bg-base-100": active,
})
const loadBookmarkLists = async () => { const loadBookmarkLists = async () => {
if (!$pubkey || loadedListPubkey === $pubkey) { if (!$pubkey) {
listsReady = false
return
}
if (loadedListPubkey === $pubkey && listsReady) {
return return
} }
loadedListPubkey = $pubkey loadedListPubkey = $pubkey
listsReady = false
await load({ try {
relays: INDEXER_RELAYS, await Promise.all([
filters: [{kinds: [...MANAGED_LIST_KINDS, DELETE], authors: [$pubkey]}], loadUserBookmarkList(),
}) loadUserBookmarkCollections(),
load({
relays: INDEXER_RELAYS,
filters: [{kinds: [DELETE], authors: [$pubkey]}],
}),
])
} finally {
listsReady = true
}
// Ensure selected-list references are reloaded once list metadata arrives. // Ensure selected-list references are reloaded once list metadata arrives.
loadedSelectionKey = undefined loadedSelectionKey = undefined
@@ -644,144 +506,33 @@
$effect(() => { $effect(() => {
if (selectedList) { if (selectedList) {
void loadSelectedReferences() void loadSelectedReferences()
} else {
selectedEvents = []
} }
}) })
</script> </script>
<svelte:window <BookmarkSidebar
onclick={() => { lists={listsReady ? sidebarListsWithDisplayCount : []}
closeListContextMenu() {selectedKey}
closeBookmarkMenu() totalCount={listsReady ? totalListCount : 0}
}} onOpenManager={openListManager}
onkeydown={handleWindowKeydown} /> onSelect={key => goto(selectedListHref(key), {replaceState: true, noScroll: true})}
onRename={renameListFromSidebar}
<SecondaryNav class="relative"> onDelete={deleteListFromSidebar} />
<SecondaryNavSection>
<SecondaryNavHeader>
<span class="flex items-center gap-2 uppercase tracking-wide">
<Icon icon={Bookmark} />
Bookmarks
</span>
<span class="badge badge-sm badge-neutral">{totalListCount}</span>
</SecondaryNavHeader>
<div
class="flex items-center justify-between px-1 pt-1 text-xs uppercase tracking-wide opacity-70">
<span>My Lists</span>
<Button class="btn btn-ghost btn-xs btn-square" onclick={openListManager}>
<Icon size={3.5} icon={Add} />
</Button>
</div>
</SecondaryNavSection>
<div class="col-2 overflow-y-auto px-2 pb-2">
{#each sidebarListsWithDisplayCount as list (list.key)}
<div
role="button"
tabindex="-1"
oncontextmenu={event => onListContextMenu(event, list.key, list.label)}>
<Link href={selectedListHref(list.key)} class={listClass(selectedKey === list.key)}>
<div class="flex items-center justify-between gap-2">
<p class="truncate font-medium">
<span class="inline-flex items-center gap-2">
<Icon size={4} icon={list.key === "10003:" ? Bookmark : Folder} />
{list.label}
</span>
</p>
<span class="badge badge-sm badge-neutral">{list.displayCount}</span>
</div>
</Link>
</div>
{:else}
<div class="card2 card2-sm bg-alt text-sm opacity-70">No lists found yet.</div>
{/each}
</div>
{#if menuOpen}
<div class="fixed inset-0 z-popover" role="presentation">
<div
class="menu rounded-box bg-base-100 shadow-xl"
role="menu"
style={`position: fixed; left: ${menuX}px; top: ${menuY}px; min-width: 11rem;`}>
<li>
<Button class="justify-start" onclick={openListFromMenu}>Open List</Button>
</li>
{#if menuListKey !== "10003:"}
<li>
<Button class="justify-start" onclick={renameListFromMenu}>Rename List</Button>
</li>
<li>
<Button class="justify-start text-error" onclick={deleteListFromMenu}
>Delete List</Button>
</li>
{/if}
</div>
</div>
{/if}
{#if listDialogMode}
<div class="fixed inset-0 z-modal bg-black/45 p-4 md:p-6">
<button
type="button"
class="absolute inset-0"
aria-label="Close dialog"
onclick={handleListDialogBackdropClick}></button>
<div class="center relative h-full w-full">
<div
class="card2 bg-base-100 w-full max-w-md gap-4 p-5 shadow-2xl"
role="dialog"
aria-modal="true"
tabindex="-1">
{#if listDialogMode === "rename"}
<div class="col-2 gap-1">
<h3 class="text-lg font-semibold">Rename List</h3>
<p class="text-sm opacity-70">Choose a new name for "{dialogListLabel}".</p>
</div>
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Folder} />
<input bind:value={nextListLabel} class="grow" type="text" placeholder="List name" />
</label>
<div class="flex items-center justify-end gap-2">
<Button class="btn btn-ghost" onclick={cancelListDialog} disabled={listDialogPending}
>Cancel</Button>
<Button
class="btn btn-neutral"
onclick={submitRenameDialog}
disabled={listDialogPending || !nextListLabel.trim()}>
Save
</Button>
</div>
{:else}
<div class="col-2 gap-1">
<h3 class="text-lg font-semibold">Delete List?</h3>
<p class="text-sm opacity-70">
This will remove "{dialogListLabel}" and cannot be undone.
</p>
</div>
<div class="flex items-center justify-end gap-2">
<Button class="btn btn-ghost" onclick={cancelListDialog} disabled={listDialogPending}
>Cancel</Button>
<Button
class="btn btn-error"
onclick={submitDeleteDialog}
disabled={listDialogPending}>
Delete
</Button>
</div>
{/if}
</div>
</div>
</div>
{/if}
</SecondaryNav>
<Page class="overflow-hidden"> <Page class="overflow-hidden">
<div class="col-3 gap-3 p-3 md:p-4"> <div class="col-3 gap-3 p-3 md:p-4">
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2 p-2"> <div class="card2 card2-sm bg-alt flex items-center justify-between gap-2 p-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<Icon size={4} icon={Bookmark} /> <Icon size={4} icon={Bookmark} />
<p class="truncate text-sm font-medium">{selectedList?.label || "Saved Items"}</p> <p class="truncate text-sm font-medium">
{#if listsReady}
{selectedList?.label || "Saved Items"}
{:else}
Loading bookmarks...
{/if}
</p>
<span class="badge badge-sm badge-neutral">{filteredItems.length}</span> <span class="badge badge-sm badge-neutral">{filteredItems.length}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -810,6 +561,12 @@
</div> </div>
</div> </div>
<div class="flex w-full flex-wrap gap-2 px-1 text-[11px] uppercase tracking-wide opacity-70">
<span>{filteredItems.length} items</span>
<span></span>
<span>{listsReady ? selectedList?.label || "Saved Items" : "Loading"}</span>
</div>
{#if showSearch} {#if showSearch}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Magnifier} /> <Icon icon={Magnifier} />
@@ -817,86 +574,16 @@
</label> </label>
{/if} {/if}
<div class="flex w-full flex-wrap content-start items-stretch gap-3"> {#if listsReady}
{#each filteredItems as item (item.key)} <BookmarkGrid
<Link items={filteredItems}
href={item.href} showAddToSaved={selectedKey !== savedItemsKey}
external={item.external} onOpen={openBookmarkFromMenu}
class="card2 bg-alt col-2 w-full gap-3 p-3 transition-colors hover:bg-base-100 md:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]"> onCopyLink={copyBookmarkLinkFromMenu}
<div class="flex items-start justify-between gap-2"> onAddToSaved={addBookmarkToSavedItems}
<div class="flex min-w-0 items-center gap-2"> onRemove={removeBookmarkFromCurrentList} />
<ProfileCircle pubkey={item.event.pubkey} size={5} /> {:else}
<div class="min-w-0"> <div class="card2 card2-sm bg-alt text-sm opacity-70">Loading bookmark lists...</div>
<p class="truncate text-sm font-semibold text-primary"> {/if}
{displayProfileByPubkey(item.event.pubkey)}
</p>
<p class="truncate text-[11px] opacity-60">
{displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)}
</p>
</div>
</div>
<div class="relative" role="presentation">
<Button
class="btn btn-ghost btn-xs btn-square"
onclick={() => toggleBookmarkMenu(item.key)}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if openBookmarkMenuKey === item.key}
<ul
class="menu absolute right-0 top-7 z-popover w-44 rounded-box bg-base-100 p-2 shadow-xl"
role="menu">
<li>
<Button class="justify-start" onclick={() => openBookmarkFromMenu(item)}>
Open bookmark
</Button>
</li>
<li>
<Button class="justify-start" onclick={() => copyBookmarkLinkFromMenu(item)}>
Copy link
</Button>
</li>
{#if selectedKey !== "10003:"}
<li>
<Button class="justify-start" onclick={() => addBookmarkToSavedItems(item)}>
Add to Saved Items
</Button>
</li>
{/if}
<li>
<Button
class="justify-start text-error"
onclick={() => removeBookmarkFromCurrentList(item)}>
Remove from this list
</Button>
</li>
</ul>
{/if}
</div>
</div>
{#if item.image}
{#if asLinkValue(item.image)}
<ContentLinkBlockImage
value={asLinkValue(item.image)}
event={item.event}
class="max-h-[28rem] w-full rounded-lg object-contain" />
{/if}
{:else if item.video}
<video
src={item.video}
class="max-h-[28rem] w-full rounded-lg object-contain"
muted
playsinline
preload="metadata"></video>
{/if}
<p class="break-words min-w-0 text-base leading-relaxed opacity-90">{item.preview}</p>
</Link>
{:else}
<div class="card2 bg-alt col-2 max-w-lg p-5 text-sm opacity-80">
No items match your current filters.
</div>
{/each}
</div>
</div> </div>
</Page> </Page>