- {#if PLATFORM_RELAYS.length > 0}
-
- {/if}
+
+
+
+ {bookmarkListCount}
+
+
{#if $userProfile?.picture}
@@ -78,6 +174,13 @@
notification={$notifications.has("/chat")}>
+
+
+
+ {bookmarkListCount}
+
+
{#if PLATFORM_RELAYS.length !== 1}
diff --git a/src/app/components/RoomItemMenu.svelte b/src/app/components/RoomItemMenu.svelte
index e03e47c8..7ba16bb7 100644
--- a/src/app/components/RoomItemMenu.svelte
+++ b/src/app/components/RoomItemMenu.svelte
@@ -3,14 +3,16 @@
import {ManagementMethod} from "@welshman/util"
import {pubkey, manageRelay, repository} from "@welshman/app"
import Code2 from "@assets/icons/code-2.svg?dataurl"
+ import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
+ import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
- import Report from "@app/components/Report.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
+ import Report from "@app/components/Report.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {deriveUserIsSpaceAdmin} from "@app/core/state"
@@ -40,6 +42,11 @@
pushModal(EventDeleteConfirm, {url, event})
}
+ const bookmarkMessage = () => {
+ onClick()
+ pushModal(BookmarkListPicker, {event})
+ }
+
const showAdminDelete = () =>
pushModal(Confirm, {
title: `Delete Message`,
@@ -68,6 +75,12 @@
Show JSON
+
+
+
{#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 @@