From ad3f88208186871c0964739372d375d2194ad3b8 Mon Sep 17 00:00:00 2001 From: Bhavishy Date: Tue, 14 Apr 2026 01:21:27 +0530 Subject: [PATCH 1/2] feat: implement bookmarks page --- src/app.css | 2 +- src/app/components/BookmarkListPicker.svelte | 284 ++++++ src/app/components/Chat.svelte | 88 +- src/app/components/ChatMessageMenu.svelte | 10 + .../components/ChatMessageMenuMobile.svelte | 11 + src/app/components/PrimaryNav.svelte | 7 + src/app/components/RoomItemMenu.svelte | 15 +- src/app/components/RoomItemMenuMobile.svelte | 19 +- src/app/core/commands.ts | 151 +++ src/app/core/state.ts | 2 + src/app/util/routes.ts | 21 +- src/app/util/title.ts | 1 + src/routes/bookmarks/+page.svelte | 902 ++++++++++++++++++ src/routes/spaces/[relay]/[h]/+page.svelte | 89 +- src/routes/spaces/[relay]/chat/+page.svelte | 95 +- 15 files changed, 1623 insertions(+), 74 deletions(-) create mode 100644 src/app/components/BookmarkListPicker.svelte create mode 100644 src/routes/bookmarks/+page.svelte diff --git a/src/app.css b/src/app.css index e36458bd..fa0b8a76 100644 --- a/src/app.css +++ b/src/app.css @@ -327,7 +327,7 @@ .note-editor .tiptap { --tiptap-object-bg: var(--color-base-200); - @apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6; + @apply input rounded-box h-auto min-h-32 p-[.65rem] pb-6; } .input-editor .tiptap { diff --git a/src/app/components/BookmarkListPicker.svelte b/src/app/components/BookmarkListPicker.svelte new file mode 100644 index 00000000..039a0c24 --- /dev/null +++ b/src/app/components/BookmarkListPicker.svelte @@ -0,0 +1,284 @@ + + + + + + {event ? "Add to Bookmark List" : "Manage Bookmark Lists"} + + +
+ + {#snippet label()} + New list name + {/snippet} + {#snippet input()} +
+ + +
+ {/snippet} +
+ +
+ {#each lists as list (list.key)} + {#if event} + + {:else} +
+ + + {list.label} + +
+ {list.count} + {#if list.key !== "10003:"} + + {/if} +
+
+ {/if} + {/each} +
+
+
+ + + + +
diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index be5f02a9..e330d1b5 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -1,6 +1,7 @@
@@ -31,6 +38,9 @@ {/if} + diff --git a/src/app/components/ChatMessageMenuMobile.svelte b/src/app/components/ChatMessageMenuMobile.svelte index 279a8d8a..3b65552f 100644 --- a/src/app/components/ChatMessageMenuMobile.svelte +++ b/src/app/components/ChatMessageMenuMobile.svelte @@ -12,10 +12,12 @@ import ModalBody from "@lib/components/ModalBody.svelte" import Button from "@lib/components/Button.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte" + import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte" import EventInfo from "@app/components/EventInfo.svelte" import {makeReaction} from "@app/core/commands" import {pushModal} from "@app/util/modal" import {clip} from "@app/util/toast" + import Bookmark from "@assets/icons/bookmark.svg?dataurl" type Props = { pubkeys: string[] @@ -52,6 +54,11 @@ clip(event.content) } + const bookmarkMessage = () => { + history.back() + pushModal(BookmarkListPicker, {event}, {replaceState: true}) + } + const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true}) @@ -66,6 +73,10 @@ Copy Text + +
  • + +
  • {#if event.pubkey === $pubkey}
  • + {#if path} diff --git a/src/app/core/commands.ts b/src/app/core/commands.ts index 9f99c659..2de878ff 100644 --- a/src/app/core/commands.ts +++ b/src/app/core/commands.ts @@ -33,6 +33,7 @@ import { ROOMS, COMMENT, APP_DATA, + asDecryptedEvent, isSignedEvent, makeEvent, normalizeRelayUrl, @@ -41,11 +42,14 @@ import { removeFromListByPredicate, updateList, getTag, + getTagValue, getListTags, getRelayTagValues, + getEventTagValues, toNostrURI, RelayMode, getTagValues, + readList, uploadBlob, canUploadBlob, encryptFile, @@ -167,6 +171,153 @@ export const broadcastUserData = async (relays: string[]) => { // 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 getUserBookmarkList = (key = `${BOOKMARKS}:`) => { + const author = pubkey.get() + const {kind, d} = parseListKey(key) + + if (!author) { + return makeList({kind}) + } + + const latest = first( + repository + .query([{kinds: [kind], authors: [author]}]) + .filter(event => getTagValue("d", event.tags) === d), + ) + + return latest ? readList(asDecryptedEvent(latest)) : makeList({kind}) +} + +export const createBookmarkList = async (title: string) => { + const d = title.trim() + + if (!d) { + return + } + + const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${d}`) + + if (existing.event?.id) { + 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)]) + + return publishThunk({event, relays}) +} + +export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => { + const list = getUserBookmarkList(key) + const {d} = parseListKey(key) + + if (d && !list.event?.id) { + return + } + + const existing = new Set(getEventTagValues(getListTags(list))) + + if (existing.has(target.id)) { + return + } + + 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), + ]) + + return publishThunk({event, relays}) +} + +export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => { + const list = getUserBookmarkList(key) + const {d} = parseListKey(key) + + if (d && !list.event?.id) { + return + } + + const targetD = getTagValue("d", target.tags) + const targetAddress = targetD ? `${target.kind}:${target.pubkey}:${targetD}` : undefined + const hasMatch = getListTags(list).some( + tag => + (tag[0] === "e" && tag[1] === target.id) || + (targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress), + ) + + if (!hasMatch) { + return + } + + const event = await removeFromListByPredicate( + list, + tag => + (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(), + ]) + + return publishThunk({event, relays}) +} + +export const deleteBookmarkList = async (key: string) => { + const list = getUserBookmarkList(key) + const {kind, d} = parseListKey(key) + + if (kind !== BOOKMARK_LISTS || !d || !list.event) { + return + } + + const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)]) + const address = `${kind}:${list.event.pubkey}:${d}` + + 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() + + if (kind !== BOOKMARK_LISTS || !d || !nextD || !list.event) { + return + } + + if (nextD === d) { + 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 event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf) + const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)]) + + return publishThunk({event, relays}) +} + export const addSpaceMembership = async (url: string) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 767b16a9..927495cd 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -159,6 +159,8 @@ export const ROOM = "h" export const PROTECTED = ["-"] +export const MESSAGE_KINDS = [MESSAGE] + export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"] diff --git a/src/app/util/routes.ts b/src/app/util/routes.ts index 1a5371ae..b6552f99 100644 --- a/src/app/util/routes.ts +++ b/src/app/util/routes.ts @@ -26,7 +26,16 @@ import ChatEnable from "@app/components/ChatEnable.svelte" // Chat -export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}` +export const makeChatPath = (pubkeys: string[], event?: TrustedEvent) => { + let path = `/chat/${makeChatId(pubkeys)}` + + if (event) { + const qp = new URLSearchParams({at: String(event.created_at), e: event.id}) + path += "?" + qp.toString() + } + + return path +} export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}` @@ -63,11 +72,11 @@ export const goToSpace = async (url: string) => { const prevPath = lastPageBySpaceUrl.get(encodeRelay(url)) if (prevPath && prevPath !== makeSpacePath(url)) { - goto(prevPath, {replaceState: true}) + goto(prevPath) } else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) { - goto(makeSpacePath(url, "recent"), {replaceState: true}) + goto(makeSpacePath(url, "recent")) } else { - goto(makeSpacePath(url), {replaceState: true}) + goto(makeSpacePath(url)) } } @@ -76,7 +85,7 @@ export const goToSpace = async (url: string) => { export const makeMessagePath = (url: string, event: TrustedEvent) => { const h = getTagValue(ROOM, event.tags) const path = h ? makeRoomPath(url, h) : makeSpaceChatPath(url) - const qp = new URLSearchParams({at: String(event.created_at)}) + const qp = new URLSearchParams({at: String(event.created_at), e: event.id}) return path + "?" + qp.toString() } @@ -127,7 +136,7 @@ export const goToEvent = (event: TrustedEvent, options: Record = {} export const getEventPath = (event: TrustedEvent, urls: string[]) => { if (DM_KINDS.includes(event.kind)) { - return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)]) + return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)], event) } if (urls.length > 0) { diff --git a/src/app/util/title.ts b/src/app/util/title.ts index ac60ac26..9dbf27d7 100644 --- a/src/app/util/title.ts +++ b/src/app/util/title.ts @@ -19,6 +19,7 @@ const staticTitles = new Map([ ["/spaces/[relay]/goals", "Goals"], ["/spaces/[relay]/polls", "Polls"], ["/chat", "Messages"], + ["/bookmarks", "Bookmarks"], ["/join", "Join Space"], ["/people", "Find People"], ["/settings/about", "About"], diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte new file mode 100644 index 00000000..0ec98f90 --- /dev/null +++ b/src/routes/bookmarks/+page.svelte @@ -0,0 +1,902 @@ + + + { + closeListContextMenu() + closeBookmarkMenu() + }} + onkeydown={handleWindowKeydown} /> + + + + + + + Bookmarks + + {totalListCount} + +
    + My Lists + +
    +
    +
    + {#each sidebarListsWithDisplayCount as list (list.key)} +
    onListContextMenu(event, list.key, list.label)}> + +
    +

    + + + {list.label} + +

    + {list.displayCount} +
    + +
    + {:else} +
    No lists found yet.
    + {/each} +
    + + {#if menuOpen} +
  • + +
  • + {#if menuListKey !== "10003:"} +
  • + +
  • +
  • + +
  • + {/if} +
    + + {/if} + + {#if listDialogMode} +
    + +
    + +
    +
    + {/if} + + + +
    +
    +
    + +

    {selectedList?.label || "Saved Items"}

    + {filteredItems.length} +
    +
    + + +
    +
    + + {#if showSearch} + + {/if} + +
    + {#each filteredItems as item (item.key)} + +
    +
    + +
    +

    + {displayProfileByPubkey(item.event.pubkey)} +

    +

    + {displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)} +

    +
    +
    + +
    + + {#if item.image} + {#if asLinkValue(item.image)} + + {/if} + {:else if item.video} + + {/if} + +

    {item.preview}

    + + {:else} +
    + No items match your current filters. +
    + {/each} +
    +
    +
    diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index bde74a9a..5866dd6d 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -1,5 +1,5 @@ + + + + +
    +
    + +
    +

    + {displayProfileByPubkey(item.event.pubkey)} +

    +

    + {displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)} +

    +
    +
    + + +
    + + {#if item.image} + + {:else if item.video} + + {/if} + + + diff --git a/src/app/components/BookmarkEventMenuItem.svelte b/src/app/components/BookmarkEventMenuItem.svelte new file mode 100644 index 00000000..ae0cb4b3 --- /dev/null +++ b/src/app/components/BookmarkEventMenuItem.svelte @@ -0,0 +1,23 @@ + + +
  • + +
  • diff --git a/src/app/components/BookmarkGrid.svelte b/src/app/components/BookmarkGrid.svelte new file mode 100644 index 00000000..c9fc5ad9 --- /dev/null +++ b/src/app/components/BookmarkGrid.svelte @@ -0,0 +1,38 @@ + + +
    + {#each items as item (item.key)} + + {:else} +
    + No items match your current filters. +
    + {/each} +
    diff --git a/src/app/components/BookmarkListPicker.svelte b/src/app/components/BookmarkListPicker.svelte index 039a0c24..f649564b 100644 --- a/src/app/components/BookmarkListPicker.svelte +++ b/src/app/components/BookmarkListPicker.svelte @@ -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" type Props = { @@ -36,10 +49,14 @@ const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}]) 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) || + (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) { @@ -82,13 +98,13 @@ }), ) - 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("") @@ -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}> - + {list.label} {list.count} @@ -257,12 +275,14 @@ {:else}
    - + {list.label}
    {list.count} - {#if list.key !== "10003:"} + {#if Address.from(list.key).kind !== BOOKMARKS} +
    + + +
    + {#each lists as list (list.key)} +
    openListMenu(event, list.key, list.label)}> + +
    +
    +

    + + + {list.label} + +

    + {list.count} +
    +
    + +
    + {:else} +
    No lists found yet.
    + {/each} +
    + + {#if menuOpen} + + {/if} + + {#if listDialogMode} +
    + +
    + +
    +
    + {/if} + diff --git a/src/app/components/CalendarEventActions.svelte b/src/app/components/CalendarEventActions.svelte index 864fe35f..676e189c 100644 --- a/src/app/components/CalendarEventActions.svelte +++ b/src/app/components/CalendarEventActions.svelte @@ -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} {#snippet customActions()} + {#if event.pubkey === $pubkey}
  • diff --git a/src/app/components/PrimaryNav.svelte b/src/app/components/PrimaryNav.svelte index 0dab1210..f8d25136 100644 --- a/src/app/components/PrimaryNav.svelte +++ b/src/app/components/PrimaryNav.svelte @@ -1,6 +1,18 @@