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/BookmarkCard.svelte b/src/app/components/BookmarkCard.svelte new file mode 100644 index 00000000..7f942843 --- /dev/null +++ b/src/app/components/BookmarkCard.svelte @@ -0,0 +1,142 @@ + + + + + +
+
+ +
+

+ {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 new file mode 100644 index 00000000..f649564b --- /dev/null +++ b/src/app/components/BookmarkListPicker.svelte @@ -0,0 +1,304 @@ + + + + + + {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 Address.from(list.key).kind !== BOOKMARKS} + + {/if} +
    +
    + {/if} + {/each} +
    +
    +
    + + + + +
    diff --git a/src/app/components/BookmarkSidebar.svelte b/src/app/components/BookmarkSidebar.svelte new file mode 100644 index 00000000..5f4cbce9 --- /dev/null +++ b/src/app/components/BookmarkSidebar.svelte @@ -0,0 +1,247 @@ + + + + + + + + + + Bookmarks + + {totalCount} + +
    + My Lists + +
    +
    + +
    + {#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}
  • {/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/components/ThreadActions.svelte b/src/app/components/ThreadActions.svelte index d34dedea..2752e16c 100644 --- a/src/app/components/ThreadActions.svelte +++ b/src/app/components/ThreadActions.svelte @@ -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} {/if} - + + {#snippet customActions()} + + {/snippet} + diff --git a/src/app/core/commands.ts b/src/app/core/commands.ts index 97a866d3..5daa2fd3 100644 --- a/src/app/core/commands.ts +++ b/src/app/core/commands.ts @@ -14,6 +14,7 @@ import { simpleCache, normalizeUrl, nthNe, + randomId, } from "@welshman/lib" import {Nip01Signer} from "@welshman/signer" import type {UploadTask} from "@welshman/editor" @@ -37,12 +38,15 @@ import { makeEvent, normalizeRelayUrl, makeList, + Address, addToListPublicly, removeFromListByPredicate, updateList, getTag, + getTagValue, getListTags, getRelayTagValues, + getEventTagValues, toNostrURI, RelayMode, getTagValues, @@ -82,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, @@ -167,6 +175,135 @@ export const broadcastUserData = async (relays: string[]) => { // List updates +const getSavedItemsList = (owner = pubkey.get() || "") => { + if (!owner) { + return makeList({kind: BOOKMARKS}) + } + + return getBookmarkList().get(owner) || makeList({kind: BOOKMARKS}) +} + +const getBookmarkListFromAddress = (address: string) => { + const parsed = Address.from(address) + + if (parsed.kind === BOOKMARKS) { + return getSavedItemsList(parsed.pubkey) + } + + if (parsed.kind === BOOKMARK_LISTS) { + return getBookmarkCollection().get(address) + } + + return undefined +} + +export const createBookmarkList = async (title: string) => { + const label = title.trim() + + if (!label) { + return + } + + const list = makeList({kind: BOOKMARK_LISTS}) + 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, address?: string) => { + const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList() + + if (!list) { + 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 = Router.get().FromUser().getUrls() + + return publishThunk({event, relays}) +} + +export const removeEventBookmark = async (target: TrustedEvent, address?: string) => { + const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList() + + if (!list) { + 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 = Router.get().FromUser().getUrls() + + return publishThunk({event, relays}) +} + +export const deleteBookmarkList = async (address: string) => { + const list = getBookmarkCollection().get(address) + const {kind} = Address.from(address) + + if (kind !== BOOKMARK_LISTS || !list?.event) { + return + } + + const relays = Router.get().FromUser().getUrls() + + return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays}) +} + +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 || !nextTitle || !list?.event) { + return + } + + const currentTitle = + getTagValue("title", list.event.tags) || getTagValue("d", list.event.tags) || "" + + if (nextTitle === currentTitle) { + return + } + + const publicTags = [ + ["d", getTagValue("d", list.event.tags) || randomId()], + ["title", nextTitle], + ] + const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf) + const relays = Router.get().FromUser().getUrls() + + 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 78e8576c..4ddef3d3 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -159,6 +159,12 @@ export const ROOM = "h" 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"] @@ -704,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]}], 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..c630dfa6 --- /dev/null +++ b/src/routes/bookmarks/+page.svelte @@ -0,0 +1,589 @@ + + + goto(selectedListHref(key), {replaceState: true, noScroll: true})} + onRename={renameListFromSidebar} + onDelete={deleteListFromSidebar} /> + + +
    +
    +
    + +

    + {#if listsReady} + {selectedList?.label || "Saved Items"} + {:else} + Loading bookmarks... + {/if} +

    + {filteredItems.length} +
    +
    + + +
    +
    + +
    + {filteredItems.length} items + + {listsReady ? selectedList?.label || "Saved Items" : "Loading"} +
    + + {#if showSearch} + + {/if} + + {#if listsReady} + + {:else} +
    Loading bookmark lists...
    + {/if} +
    +
    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 @@