feat: implement bookmarks page with list management and deep-link scroll targeting #196

Closed
Ghost wants to merge 5 commits from (deleted):feat-bookmarks into dev
13 changed files with 792 additions and 491 deletions
Showing only changes of commit 0db751bd45 - Show all commits
+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 {first, uniqBy} from "@welshman/lib"
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 {pubkey, waitForThunkError} from "@welshman/app"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
@@ -18,7 +25,13 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
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"
hodlbod marked this conversation as resolved
Review

Import these from @welshman/util

Import these from @welshman/util
Review

Done. Now these are imported from @welshman/util

Done. Now these are imported from @welshman/util
type Props = {
@@ -36,10 +49,14 @@
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
Review

These kinds shouldn't be hard-coded

These kinds shouldn't be hard-coded
Review

Also, no need to derive deletes, deriveEvents automatically filters out deleted stuff.

Also, no need to derive deletes, deriveEvents automatically filters out deleted stuff.
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) =>
getTagValue("d", listEvent.tags) || (listEvent.kind === 10003 ? "Saved Items" : "Untitled List")
getTagValue("title", listEvent.tags) ||
getTagValue("d", listEvent.tags) ||
Review

Remove the d fallback, if it doesn't have a title we should just show untitled.

Remove the d fallback, if it doesn't have a title we should just show untitled.
(listEvent.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
const userLists = $derived(
sortEventsDesc($listEvents).filter(
@@ -54,8 +71,7 @@
)
const isDeletedList = (listEvent: TrustedEvent) => {
const d = getTagValue("d", listEvent.tags) || ""
const address = `${listEvent.kind}:${listEvent.pubkey}:${d}`
const address = getListKey(listEvent)
return userDeletes.some(deleteEvent => {
if (deleteEvent.created_at < listEvent.created_at) {
1
@@ -82,13 +98,13 @@
}),
)
Review

Just return the events themselves here and save the rendering stuff for elsewhere

Just return the events themselves here and save the rendering stuff for elsewhere
const savedItems = first(mapped.filter(list => list.key === "10003:")) || {
key: "10003:",
const savedItems = first(mapped.filter(list => Address.from(list.key).kind === BOOKMARKS)) || {
key: $pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "10003:",
label: "Saved Items",
count: 0,
}
return [savedItems, ...mapped.filter(list => list.key !== "10003:")]
return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
})
let listName = $state("")
1
@@ -159,7 +175,7 @@
}
const deleteList = async (key: string, label: string) => {
if (deleting || key === "10003:") {
if (deleting || Address.from(key).kind === BOOKMARKS) {
return
}
@@ -198,7 +214,7 @@
await load({
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)}
disabled={selecting}>
<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}
</span>
<span class="badge badge-sm badge-neutral">{list.count}</span>
@@ -257,12 +275,14 @@
{:else}
<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">
<Icon size={4} icon={list.key === "10003:" ? Bookmark : Folder} />
<Icon
size={4}
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
{list.label}
</span>
<div class="flex items-center gap-2">
<span class="badge badge-sm badge-neutral">{list.count}</span>
{#if list.key !== "10003:"}
{#if Address.from(list.key).kind !== BOOKMARKS}
<Button
class="btn btn-ghost btn-xs btn-square text-error"
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 EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -51,6 +52,7 @@
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
@@ -15,6 +15,7 @@
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -64,6 +65,7 @@
{/if}
<EventActions {url} {event} noun="Listing">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editClassified}>
+6 -1
View File
@@ -4,6 +4,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeSpacePath} from "@app/util/routes"
@@ -34,6 +35,10 @@
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
<EventActions {url} {event} noun="Comment">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{/snippet}
</EventActions>
</div>
</div>
+104 -8
View File
@@ -1,6 +1,18 @@
<script lang="ts">
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 Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -8,11 +20,19 @@
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.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 {notifications} from "@app/util/notifications"
import {goToChat, makeSpacePath} from "@app/util/routes"
@@ -30,18 +50,90 @@
const anySpaceNotifications = $derived(
$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>
<div
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}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
<div class="flex flex-col">
<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 title="Settings" href="/settings/profile" prefix="/settings">
{#if $userProfile?.picture}
@@ -83,7 +175,11 @@
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
<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>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
+6 -1
View File
@@ -7,6 +7,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
@@ -41,5 +42,9 @@
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Thread" />
<EventActions {url} {event} noun="Thread">
{#snippet customActions()}
<BookmarkEventMenuItem {event} />
{/snippet}
</EventActions>
</div>
+55 -69
View File
@@ -14,6 +14,7 @@ import {
simpleCache,
normalizeUrl,
nthNe,
randomId,
} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
@@ -33,11 +34,11 @@ import {
ROOMS,
COMMENT,
APP_DATA,
asDecryptedEvent,
isSignedEvent,
makeEvent,
normalizeRelayUrl,
makeList,
Address,
addToListPublicly,
removeFromListByPredicate,
updateList,
@@ -49,7 +50,6 @@ import {
toNostrURI,
RelayMode,
getTagValues,
readList,
uploadBlob,
canUploadBlob,
encryptFile,
@@ -86,10 +86,14 @@ import {
import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
import {
BOOKMARKS,
BOOKMARK_LISTS,
SETTINGS,
PROTECTED,
INDEXER_RELAYS,
DEFAULT_BLOSSOM_SERVERS,
getBookmarkList,
getBookmarkCollection,
userSpaceUrls,
userSettingsValues,
getSetting,
@@ -171,59 +175,51 @@ export const broadcastUserData = async (relays: string[]) => {
hodlbod marked this conversation as resolved Outdated
Outdated
Review

These can be imported from @welshman/util

These can be imported from @welshman/util
Outdated
Review

Done. Now these are imported from @welshman/util

Done. Now these are imported from @welshman/util
// List updates
const BOOKMARKS = 10003
const BOOKMARK_LISTS = 30003
const parseListKey = (key: string) => {
const [kind, ...rest] = key.split(":")
return {
kind: parseInt(kind),
d: rest.join(":"),
const getSavedItemsList = (owner = pubkey.get() || "") => {
if (!owner) {
return makeList({kind: BOOKMARKS})
}
return getBookmarkList().get(owner) || makeList({kind: BOOKMARKS})
}
const getUserBookmarkList = (key = `${BOOKMARKS}:`) => {
const author = pubkey.get()
const {kind, d} = parseListKey(key)
const getBookmarkListFromAddress = (address: string) => {
const parsed = Address.from(address)
if (!author) {
return makeList({kind})
if (parsed.kind === BOOKMARKS) {
return getSavedItemsList(parsed.pubkey)
}
const latest = first(
repository
.query([{kinds: [kind], authors: [author]}])
.filter(event => getTagValue("d", event.tags) === d),
)
if (parsed.kind === BOOKMARK_LISTS) {
return getBookmarkCollection().get(address)
}
return latest ? readList(asDecryptedEvent(latest)) : makeList({kind})
return undefined
}
export const createBookmarkList = async (title: string) => {
hodlbod marked this conversation as resolved
Review

This should go in state and should be as simple as:

export const bookmarkListsByPubkey = deriveItemsByKey({
  repository,
  eventToItem: event => readList(asDecryptedEvent(event)),
  filters: [{kinds: [BOOKMARKS]}],
  getKey: list => list.event.pubkey,
})

export const getBookmarkList = (pubkey: string) => getBookmarkList().get(pubkey)

export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList)

export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList)

See @welshman/app's user.ts file for examples.

This should go in `state` and should be as simple as: ``` export const bookmarkListsByPubkey = deriveItemsByKey({ repository, eventToItem: event => readList(asDecryptedEvent(event)), filters: [{kinds: [BOOKMARKS]}], getKey: list => list.event.pubkey, }) export const getBookmarkList = (pubkey: string) => getBookmarkList().get(pubkey) export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList) export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList) ``` See `@welshman/app`'s `user.ts` file for examples.
Review

I think part of the problem is that you're using the same function to handle two different kinds of list. You should create separate functions for 10003 vs 30003 since they work quite differently.

I think part of the problem is that you're using the same function to handle two different kinds of list. You should create separate functions for 10003 vs 30003 since they work quite differently.
Review

Done.

Done.
const d = title.trim()
const label = title.trim()
if (!d) {
return
}
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${d}`)
if (existing.event?.id) {
if (!label) {
hodlbod marked this conversation as resolved Outdated
Outdated
Review

d should be randomly generated, not a slug of the title

`d` should be randomly generated, not a slug of the title
Outdated
Review

Fixed. d is now opaque/random, and title is stored separately.

Fixed. d is now opaque/random, and title is stored separately.
return
}
const list = makeList({kind: BOOKMARK_LISTS})
const event = await updateList(list, {publicTags: [["d", d]]}).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
const event = await updateList(list, {
publicTags: [
["d", randomId()],
["title", label],
],
}).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => {
const list = getUserBookmarkList(key)
const {d} = parseListKey(key)
export const addEventBookmark = async (target: TrustedEvent, address?: string) => {
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
if (d && !list.event?.id) {
if (!list) {
return
}
@@ -234,20 +230,15 @@ export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}
}
const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf)
const relays = uniq([
...Router.get().FromUser().getUrls(),
...Router.get().Event(target).limit(3).getUrls(),
...getRelayTagValues(event.tags),
])
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => {
const list = getUserBookmarkList(key)
const {d} = parseListKey(key)
export const removeEventBookmark = async (target: TrustedEvent, address?: string) => {
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
hodlbod marked this conversation as resolved
Review

The bookmark list shouldn't be published to relays based on its content, just publish it to the user's outbox

The bookmark list shouldn't be published to relays based on its content, just publish it to the user's outbox
Review

Fixed. Bookmark list updates are now published only to user outbox relays.

Fixed. Bookmark list updates are now published only to user outbox relays.
if (d && !list.event?.id) {
if (!list) {
return
}
@@ -269,51 +260,46 @@ export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMAR
(tag[0] === "e" && tag[1] === target.id) ||
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
).reconcile(nip44EncryptToSelf)
const relays = uniq([
...INDEXER_RELAYS,
...getRelayTagValues(event.tags),
...Router.get().Event(target).limit(3).getUrls(),
])
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
export const deleteBookmarkList = async (key: string) => {
const list = getUserBookmarkList(key)
const {kind, d} = parseListKey(key)
export const deleteBookmarkList = async (address: string) => {
const list = getBookmarkCollection().get(address)
const {kind} = Address.from(address)
if (kind !== BOOKMARK_LISTS || !d || !list.event) {
if (kind !== BOOKMARK_LISTS || !list?.event) {
return
}
const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)])
const address = `${kind}:${list.event.pubkey}:${d}`
const relays = Router.get().FromUser().getUrls()
return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays})
}
export const renameBookmarkList = async (key: string, title: string) => {
const list = getUserBookmarkList(key)
const {kind, d} = parseListKey(key)
const nextD = title.trim()
export const renameBookmarkList = async (address: string, title: string) => {
const list = getBookmarkCollection().get(address)
const {kind} = Address.from(address)
const nextTitle = title.trim()
if (kind !== BOOKMARK_LISTS || !d || !nextD || !list.event) {
if (kind !== BOOKMARK_LISTS || !nextTitle || !list?.event) {
return
}
if (nextD === d) {
const currentTitle =
getTagValue("title", list.event.tags) || getTagValue("d", list.event.tags) || ""
if (nextTitle === currentTitle) {
return
}
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${nextD}`)
if (existing.event?.id && existing.event.id !== list.event.id) {
return
}
const publicTags = [["d", nextD], ...list.publicTags.filter(tag => tag[0] !== "d")]
const publicTags = [
["d", getTagValue("d", list.event.tags) || randomId()],
["title", nextTitle],
]
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})
}
2
+48
View File
@@ -161,6 +161,10 @@ export const PROTECTED = ["-"]
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 VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
@@ -706,6 +710,50 @@ export const deriveOtherVoiceRooms = (url: string) =>
// 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({
repository,
filters: [{kinds: [ROOMS]}],
+85 -398
View File
@@ -1,45 +1,31 @@
<script lang="ts">
import cx from "classnames"
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {first, formatTimestamp, uniqBy} from "@welshman/lib"
import {first, uniqBy} from "@welshman/lib"
import {load} from "@welshman/net"
import {
DELETE,
displayPubkey,
getIdFilters,
getTags,
getTagValue,
getTagValues,
sortEventsDesc,
tagsFromIMeta,
Address,
getAddress,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {
displayProfileByPubkey,
loadProfile,
pubkey,
repository,
tracker,
waitForThunkError,
} from "@welshman/app"
import {loadProfile, pubkey, repository, tracker, waitForThunkError} from "@welshman/app"
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 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 Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.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 ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import BookmarkGrid from "@app/components/BookmarkGrid.svelte"
import BookmarkSidebar from "@app/components/BookmarkSidebar.svelte"
import {
addEventBookmark,
deleteBookmarkList,
@@ -47,9 +33,13 @@
renameBookmarkList,
} from "@app/core/commands"
import {
BOOKMARKS,
BOOKMARK_LISTS,
deriveEvents,
IMAGE_CONTENT_TYPES,
INDEXER_RELAYS,
loadUserBookmarkCollections,
loadUserBookmarkList,
shouldIgnoreError,
VIDEO_CONTENT_TYPES,
} from "@app/core/state"
@@ -73,17 +63,15 @@
external: boolean
image: string | undefined
video: string | undefined
contentType: Exclude<ContentType, "all">
contentType: "image" | "video" | "text"
preview: string
pollOptions: string[]
searchable: string
}
const BOOKMARKS = 10003
const CUSTOM_BOOKMARKS = 30003
const MANAGED_LIST_KINDS = [BOOKMARKS, CUSTOM_BOOKMARKS]
const savedItemsKey = $derived($pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "")
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)
@@ -129,14 +117,6 @@
}
}
const asLinkValue = (url: string) => {
try {
return {url: new URL(url)}
} catch {
return undefined
}
}
const getPollQuestion = (event: TrustedEvent) => {
const lines = (event.content || "")
.split("\n")
@@ -163,7 +143,7 @@
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)
if (media.image) {
@@ -177,10 +157,15 @@
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) =>
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(
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE),
@@ -218,14 +203,14 @@
)
.filter(list => (list.event ? !isDeletedList(list.event) : true))
const savedItems = first(mapped.filter(list => list.key === "10003:")) || {
key: "10003:",
const savedItems = first(mapped.filter(list => list.key === savedItemsKey)) || {
key: savedItemsKey,
label: "Saved Items",
count: 0,
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 || "")
@@ -243,49 +228,9 @@
let searchTerm = $state("")
let showSearch = $state(false)
let contentType = $state<ContentType>("all")
let listsReady = $state(false)
let loadedListPubkey: 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 = () => {
contentType = "all"
@@ -315,8 +260,6 @@
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
const openBookmarkFromMenu = async (item: BookmarkItem) => {
closeBookmarkMenu()
if (item.external) {
window.open(item.href, "_blank", "noopener,noreferrer")
return
@@ -326,8 +269,6 @@
}
const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => {
closeBookmarkMenu()
try {
await navigator.clipboard.writeText(item.href)
pushToast({message: "Link copied"})
@@ -337,9 +278,11 @@
}
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) {
pushToast({message: "Already in Saved Items"})
@@ -357,8 +300,6 @@
}
const removeBookmarkFromCurrentList = async (item: BookmarkItem) => {
closeBookmarkMenu()
if (!selectedKey) {
return
}
@@ -383,7 +324,7 @@
}
const deleteListFromSidebar = async (key: string, label: string) => {
if (key === "10003:") {
if (key.startsWith(`${BOOKMARKS}:`)) {
return
}
@@ -404,12 +345,12 @@
pushToast({message: `Deleted ${label}`})
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) => {
if (key === "10003:") {
if (key.startsWith(`${BOOKMARKS}:`)) {
return
}
@@ -433,95 +374,7 @@
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}`})
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())
@@ -565,22 +418,31 @@
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 () => {
if (!$pubkey || loadedListPubkey === $pubkey) {
if (!$pubkey) {
listsReady = false
return
}
if (loadedListPubkey === $pubkey && listsReady) {
return
}
loadedListPubkey = $pubkey
listsReady = false
await load({
relays: INDEXER_RELAYS,
filters: [{kinds: [...MANAGED_LIST_KINDS, DELETE], authors: [$pubkey]}],
})
try {
await Promise.all([
loadUserBookmarkList(),
loadUserBookmarkCollections(),
load({
relays: INDEXER_RELAYS,
filters: [{kinds: [DELETE], authors: [$pubkey]}],
}),
])
} finally {
listsReady = true
}
// Ensure selected-list references are reloaded once list metadata arrives.
loadedSelectionKey = undefined
@@ -644,144 +506,33 @@
$effect(() => {
if (selectedList) {
void loadSelectedReferences()
} else {
selectedEvents = []
}
})
</script>
<svelte:window
onclick={() => {
closeListContextMenu()
closeBookmarkMenu()
}}
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">{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>
<BookmarkSidebar
lists={listsReady ? sidebarListsWithDisplayCount : []}
{selectedKey}
totalCount={listsReady ? totalListCount : 0}
onOpenManager={openListManager}
onSelect={key => goto(selectedListHref(key), {replaceState: true, noScroll: true})}
onRename={renameListFromSidebar}
onDelete={deleteListFromSidebar} />
<Page class="overflow-hidden">
<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="flex min-w-0 items-center gap-2">
<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>
</div>
<div class="flex items-center gap-2">
@@ -810,6 +561,12 @@
</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}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Magnifier} />
@@ -817,86 +574,16 @@
</label>
{/if}
<div class="flex w-full flex-wrap content-start items-stretch gap-3">
{#each filteredItems as item (item.key)}
<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={() => 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>
{#if listsReady}
<BookmarkGrid
items={filteredItems}
showAddToSaved={selectedKey !== savedItemsKey}
onOpen={openBookmarkFromMenu}
onCopyLink={copyBookmarkLinkFromMenu}
onAddToSaved={addBookmarkToSavedItems}
onRemove={removeBookmarkFromCurrentList} />
{:else}
<div class="card2 card2-sm bg-alt text-sm opacity-70">Loading bookmark lists...</div>
{/if}
</div>
</Page>