mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b64fd6cedb | ||
|
|
c748f173b3 | ||
|
|
51f009a489 | ||
|
|
18d936e222 | ||
|
|
ce7fbdbdf3 | ||
|
|
6e6d43cb25 | ||
|
|
0ed7c1497f | ||
|
|
a2c31b32de | ||
|
|
cdce972e72 | ||
|
|
00638410c0 | ||
|
|
2e5d0e3725 | ||
|
|
a66c051444 | ||
|
|
eb282fcbb0 | ||
|
|
d54313b015 | ||
|
|
03c6a0c9c7 | ||
|
|
dc36992199 | ||
|
|
08fc541eaa | ||
|
|
3eca2879ef | ||
|
|
de428b8719 | ||
|
|
ac7f1007a7 | ||
|
|
4db147ddf3 | ||
|
|
2eda8f3227 | ||
|
|
64825175a7 | ||
|
|
5ee0f49b69 | ||
|
|
7d26372878 | ||
|
|
ab00bd84e6 | ||
|
|
ec4473fc51 | ||
|
|
0f57338866 | ||
|
|
a8cdeeaef2 | ||
|
|
92d49468cd | ||
|
|
a625203fe4 | ||
|
|
e3efcd4a7c | ||
|
|
ba76a6a9ef | ||
|
|
c5a32b911d | ||
|
|
610de95481 | ||
|
|
82c63e5d18 | ||
|
|
b112520056 | ||
|
|
06b15f3fe2 | ||
|
|
4be8eff80a | ||
|
|
559e7ee944 | ||
|
|
7fd8e5341e | ||
|
|
211a89afbb | ||
|
|
695d3509ac | ||
|
|
7bb037f12a | ||
|
|
a7cfc802d1 | ||
|
|
465278742e | ||
|
|
79f83b214f | ||
|
|
170feb1bd7 | ||
|
|
f37deefa36 | ||
|
|
ebdfa47bd8 | ||
|
|
6e57c6227c | ||
|
|
e0acd2f7e7 | ||
|
|
2253172e04 | ||
|
|
15d155c565 | ||
|
|
9c34e8d806 | ||
|
|
934043f858 | ||
|
|
774ce0f1bf | ||
|
|
6481dd1bed | ||
|
|
d7b5b4f9b4 | ||
|
|
bed0f3d508 | ||
|
|
44954a6c15 | ||
|
|
a43e742183 | ||
|
|
a97808b23e | ||
|
|
bf79bbceb8 | ||
|
|
e2690e7177 |
@@ -1,12 +1,10 @@
|
||||
---
|
||||
description: applesauce reference documentation and examples
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
If you can use an applesauce-module for something, use applesauce. https://hzrd149.github.io/applesauce/typedoc/modules.html
|
||||
If you can use an applesauce-module for something, use applesauce.
|
||||
|
||||
Code snippets & examples:
|
||||
- https://hzrd149.github.io/applesauce/snippets/?q=applesauce#nevent1qgszv6q4uryjzr06xfxxew34wwc5hmjfmfpqn229d72gfegsdn2q3fgppemhxue69uhkummn9ekx7mp0qqs8c7umrjum47vjp9jxyyedhyq4v6kvahs6s8tu0r87dvv4cx2ekdq2nepz3
|
||||
- https://hzrd149.github.io/applesauce/snippets/?q=applesauce#nevent1qgszv6q4uryjzr06xfxxew34wwc5hmjfmfpqn229d72gfegsdn2q3fgpz9mhxue69uhkummnw3ezumrpdejz7qpq860x9snxtqxg2jyn8dpmq8we8j6avnw5dhkpgl2s66fzy3rumatqm36qyh
|
||||
- https://hzrd149.github.io/applesauce/snippets/?q=applesauce#nevent1qgsrkwjz6dx0pg2q95vd2dkf62kzavwxqxdfz5a72uyyeqt96xfwxfgppemhxue69uhkummn9ekx7mp0qqsgpexqt77wq4hl3j8l4gvza9cq0hedtlcp6veg04ghg5kl322t7tgqk205c
|
||||
- https://hzrd149.github.io/applesauce/snippets/?q=applesauce#nevent1qgszv6q4uryjzr06xfxxew34wwc5hmjfmfpqn229d72gfegsdn2q3fgpz9mhxue69uhkummnw3ezumrpdejz7qpqqjfzehsxvdq4r2eqc9c5hd80nkznj8sspcs6f77l3498qwz5ne2sst2t48
|
||||
- https://hzrd149.github.io/applesauce/snippets/?q=applesauce#nevent1qgszv6q4uryjzr06xfxxew34wwc5hmjfmfpqn229d72gfegsdn2q3fgpz9mhxue69uhkummnw3ezumrpdejz7qpqsjgpalr742kqcke5av2ey8pnfz7j837u78l30wuzktw3v8j6vufqufzyte
|
||||
Documentation: https://hzrd149.github.io/applesauce/typedoc/modules.html
|
||||
|
||||
When unsure how to use applesauce correctly, look at the examples in the `applesauce/packages/examples` directory.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -116,3 +116,6 @@ temp/
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# applesauce examples
|
||||
applesauce/
|
||||
|
||||
1
applesauce
Symbolic link
1
applesauce
Symbolic link
@@ -0,0 +1 @@
|
||||
../applesauce
|
||||
48
node_modules/.package-lock.json
generated
vendored
48
node_modules/.package-lock.json
generated
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markr",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -846,6 +846,52 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
|
||||
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
||||
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
|
||||
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.0.2.tgz",
|
||||
"integrity": "sha512-cmp/nT0pPC7HUALF8uc3+D5ECwEBWxYQbOIHwtGUWEu72sWtZc26k5onr920HWOViF0nYaC+Qzz6Ln56SQcaVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "markr",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "markr",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"applesauce-accounts": "^3.1.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
"applesauce-core": "^3.1.0",
|
||||
@@ -851,6 +854,52 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
|
||||
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
||||
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
|
||||
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.0.2.tgz",
|
||||
"integrity": "sha512-cmp/nT0pPC7HUALF8uc3+D5ECwEBWxYQbOIHwtGUWEu72sWtZc26k5onr920HWOViF0nYaC+Qzz6Ln56SQcaVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markr",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,9 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"applesauce-accounts": "^3.1.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
"applesauce-core": "^3.1.0",
|
||||
@@ -43,7 +46,8 @@
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"dist",
|
||||
".eslintrc.cjs"
|
||||
".eslintrc.cjs",
|
||||
"applesauce"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
|
||||
@@ -21,9 +21,13 @@ function App() {
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.nostr.band'
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
|
||||
60
src/components/BookmarkItem.tsx
Normal file
60
src/components/BookmarkItem.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faLock, faGlobe, faCopy } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index }) => {
|
||||
const copy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (error) {
|
||||
console.warn('Failed to copy to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
<FontAwesomeIcon icon={bookmark.isPrivate ? faLock : faGlobe} className={`bookmark-visibility ${bookmark.isPrivate ? 'private' : 'public'}`} />
|
||||
<span className="bookmark-type-label">{bookmark.type}</span>
|
||||
</span>
|
||||
<span className="bookmark-id">
|
||||
{short(bookmark.id)}
|
||||
<button className="copy-btn" onClick={() => copy(bookmark.id)} title="Copy event id">
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</button>
|
||||
</span>
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<div className="bookmark-content">
|
||||
<p>{bookmark.content}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bookmark-meta">
|
||||
<span>Kind: {bookmark.kind}</span>
|
||||
<span>
|
||||
Author: {short(bookmark.pubkey)}
|
||||
<button className="copy-btn" onClick={() => copy(bookmark.pubkey)} title="Copy author pubkey">
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
src/components/BookmarkList.tsx
Normal file
99
src/components/BookmarkList.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { Bookmark, ActiveAccount } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
activeAccount: ActiveAccount | null
|
||||
onLogout: () => void
|
||||
formatUserDisplay: () => string
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
bookmarks,
|
||||
activeAccount,
|
||||
onLogout,
|
||||
formatUserDisplay
|
||||
}) => {
|
||||
return (
|
||||
<div className="bookmarks-container">
|
||||
<div className="bookmarks-header">
|
||||
<div>
|
||||
<h2>Your Bookmarks ({bookmarks.length})</h2>
|
||||
{activeAccount && (
|
||||
<p className="user-info">Logged in as: {formatUserDisplay()}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onLogout} className="logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{bookmarks.map((bookmark, index) => (
|
||||
<div key={`${bookmark.id}-${index}`} className="bookmark-item">
|
||||
<h3>{bookmark.title}</h3>
|
||||
{bookmark.bookmarkCount && (
|
||||
<p className="bookmark-count">
|
||||
{bookmark.bookmarkCount} bookmarks in this list
|
||||
</p>
|
||||
)}
|
||||
{bookmark.urlReferences && bookmark.urlReferences.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
<h4>URLs:</h4>
|
||||
{bookmark.urlReferences.map((url, index) => (
|
||||
<a key={index} href={url} target="_blank" rel="noopener noreferrer" className="bookmark-url">
|
||||
{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bookmark.individualBookmarks && bookmark.individualBookmarks.length > 0 && (
|
||||
<div className="individual-bookmarks">
|
||||
<h4>Individual Bookmarks ({bookmark.individualBookmarks.length}):</h4>
|
||||
<div className="bookmarks-grid">
|
||||
{bookmark.individualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem key={index} bookmark={individualBookmark} index={index} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.eventReferences && bookmark.eventReferences.length > 0 && bookmark.individualBookmarks?.length === 0 && (
|
||||
<div className="bookmark-events">
|
||||
<h4>Event References ({bookmark.eventReferences.length}):</h4>
|
||||
<div className="event-ids">
|
||||
{bookmark.eventReferences.slice(0, 3).map((eventId, index) => (
|
||||
<span key={index} className="event-id">
|
||||
{eventId.slice(0, 8)}...{eventId.slice(-8)}
|
||||
</span>
|
||||
))}
|
||||
{bookmark.eventReferences.length > 3 && (
|
||||
<span className="more-events">... and {bookmark.eventReferences.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<p className="bookmark-content">{bookmark.content}</p>
|
||||
)}
|
||||
<div className="bookmark-meta">
|
||||
<span>Created: {formatDate(bookmark.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,37 +3,9 @@ import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { completeOnEose } from 'applesauce-relay'
|
||||
import { getParsedContent } from 'applesauce-content/text'
|
||||
import { NostrEvent, Filter } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
|
||||
interface ParsedNode {
|
||||
type: string
|
||||
value?: string
|
||||
url?: string
|
||||
encoded?: string
|
||||
children?: ParsedNode[]
|
||||
}
|
||||
|
||||
interface ParsedContent {
|
||||
type: string
|
||||
children: ParsedNode[]
|
||||
}
|
||||
|
||||
interface Bookmark {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
bookmarkCount?: number
|
||||
eventReferences?: string[]
|
||||
articleReferences?: string[]
|
||||
urlReferences?: string[]
|
||||
parsedContent?: ParsedContent
|
||||
}
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
|
||||
interface BookmarksProps {
|
||||
relayPool: RelayPool | null
|
||||
@@ -44,6 +16,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
// Use ProfileModel to get user profile information
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
@@ -54,170 +27,31 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
console.log('activeAccount:', !!activeAccount)
|
||||
if (relayPool && activeAccount) {
|
||||
console.log('Starting to fetch bookmarks...')
|
||||
fetchBookmarks()
|
||||
handleFetchBookmarks()
|
||||
} else {
|
||||
console.log('Not fetching bookmarks - missing dependencies')
|
||||
}
|
||||
}, [relayPool, activeAccount])
|
||||
}, [relayPool, activeAccount?.pubkey]) // Only depend on pubkey, not the entire activeAccount object
|
||||
|
||||
const fetchBookmarks = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const handleFetchBookmarks = async () => {
|
||||
console.log('🔍 fetchBookmarks called, loading:', loading)
|
||||
if (!relayPool || !activeAccount) {
|
||||
console.log('🔍 fetchBookmarks early return - relayPool:', !!relayPool, 'activeAccount:', !!activeAccount)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
console.log('Fetching bookmarks for pubkey:', activeAccount.pubkey)
|
||||
console.log('Starting bookmark fetch for:', activeAccount.pubkey.slice(0, 8) + '...')
|
||||
|
||||
// Use applesauce relay pool to fetch bookmark events (kind 10003)
|
||||
// This follows the proper applesauce pattern from the documentation
|
||||
|
||||
// Create a filter for bookmark events (kind 10003) for the specific pubkey
|
||||
const filter: Filter = {
|
||||
kinds: [10003],
|
||||
authors: [activeAccount.pubkey]
|
||||
}
|
||||
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
console.log('Querying relay pool with filter:', filter)
|
||||
console.log('Using relays:', relayUrls)
|
||||
|
||||
// Use the proper applesauce pattern with req() method
|
||||
const events = await lastValueFrom(
|
||||
relayPool.req(relayUrls, filter).pipe(
|
||||
// Complete when EOSE is received
|
||||
completeOnEose(),
|
||||
// Timeout after 10 seconds
|
||||
takeUntil(timer(10000)),
|
||||
// Collect all events into an array
|
||||
toArray(),
|
||||
)
|
||||
)
|
||||
|
||||
console.log('Received events:', events.length)
|
||||
|
||||
// Parse the events into bookmarks
|
||||
const bookmarkList: Bookmark[] = []
|
||||
for (const event of events) {
|
||||
console.log('Processing bookmark event:', event)
|
||||
const bookmarkData = parseBookmarkEvent(event)
|
||||
if (bookmarkData) {
|
||||
bookmarkList.push(bookmarkData)
|
||||
console.log('Parsed bookmark:', bookmarkData)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Bookmark fetch complete. Found:', bookmarkList.length, 'bookmarks')
|
||||
setBookmarks(bookmarkList)
|
||||
// Set a timeout to ensure loading state gets reset
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log('⏰ Timeout reached, resetting loading state')
|
||||
setLoading(false)
|
||||
}, 15000) // 15 second timeout
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bookmarks:', error)
|
||||
setLoading(false)
|
||||
}
|
||||
// Get the full account object with extension capabilities
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, setLoading, timeoutId)
|
||||
}
|
||||
|
||||
const parseBookmarkEvent = (event: NostrEvent): Bookmark | null => {
|
||||
try {
|
||||
// According to NIP-51, bookmark lists (kind 10003) contain:
|
||||
// - "e" tags for event references (the actual bookmarks)
|
||||
// - "a" tags for article references
|
||||
// - "r" tags for URL references
|
||||
|
||||
const eventTags = event.tags.filter((tag: string[]) => tag[0] === 'e')
|
||||
const articleTags = event.tags.filter((tag: string[]) => tag[0] === 'a')
|
||||
const urlTags = event.tags.filter((tag: string[]) => tag[0] === 'r')
|
||||
|
||||
// Use applesauce-content to parse the content properly
|
||||
const parsedContent = event.content ? getParsedContent(event.content) as ParsedContent : undefined
|
||||
|
||||
// Get the title from content or use a default
|
||||
const title = event.content || `Bookmark List (${eventTags.length + articleTags.length + urlTags.length} items)`
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
title: title,
|
||||
url: '', // Bookmark lists don't have a single URL
|
||||
content: event.content,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
parsedContent: parsedContent,
|
||||
// Add metadata about the bookmark list
|
||||
bookmarkCount: eventTags.length + articleTags.length + urlTags.length,
|
||||
eventReferences: eventTags.map(tag => tag[1]),
|
||||
articleReferences: articleTags.map(tag => tag[1]),
|
||||
urlReferences: urlTags.map(tag => tag[1])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing bookmark event:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
}
|
||||
|
||||
// Component to render parsed content using applesauce-content
|
||||
const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
if (!parsedContent || !parsedContent.children) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderNode = (node: ParsedNode, index: number): React.ReactNode => {
|
||||
if (node.type === 'text') {
|
||||
return <span key={index}>{node.value}</span>
|
||||
}
|
||||
|
||||
if (node.type === 'mention') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`nostr:${node.encoded}`}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{node.encoded}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'link') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={node.url}
|
||||
className="nostr-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{node.url}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
return (
|
||||
<span key={index}>
|
||||
{node.children.map((child: ParsedNode, childIndex: number) =>
|
||||
renderNode(child, childIndex)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="parsed-content">
|
||||
{parsedContent.children.map((node: ParsedNode, index: number) =>
|
||||
renderNode(node, index)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatUserDisplay = () => {
|
||||
if (!activeAccount) return 'Unknown User'
|
||||
@@ -257,74 +91,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bookmarks-container">
|
||||
<div className="bookmarks-header">
|
||||
<div>
|
||||
<h2>Your Bookmarks ({bookmarks.length})</h2>
|
||||
{activeAccount && (
|
||||
<p className="user-info">Logged in as: {formatUserDisplay()}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onLogout} className="logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{bookmarks.map((bookmark) => (
|
||||
<div key={bookmark.id} className="bookmark-item">
|
||||
<h3>{bookmark.title}</h3>
|
||||
{bookmark.bookmarkCount && (
|
||||
<p className="bookmark-count">
|
||||
{bookmark.bookmarkCount} bookmarks in this list
|
||||
</p>
|
||||
)}
|
||||
{bookmark.urlReferences && bookmark.urlReferences.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
<h4>URLs:</h4>
|
||||
{bookmark.urlReferences.map((url, index) => (
|
||||
<a key={index} href={url} target="_blank" rel="noopener noreferrer" className="bookmark-url">
|
||||
{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bookmark.eventReferences && bookmark.eventReferences.length > 0 && (
|
||||
<div className="bookmark-events">
|
||||
<h4>Event References ({bookmark.eventReferences.length}):</h4>
|
||||
<div className="event-ids">
|
||||
{bookmark.eventReferences.slice(0, 3).map((eventId, index) => (
|
||||
<span key={index} className="event-id">
|
||||
{eventId.slice(0, 8)}...{eventId.slice(-8)}
|
||||
</span>
|
||||
))}
|
||||
{bookmark.eventReferences.length > 3 && (
|
||||
<span className="more-events">... and {bookmark.eventReferences.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<p className="bookmark-content">{bookmark.content}</p>
|
||||
)}
|
||||
<div className="bookmark-meta">
|
||||
<span>Created: {formatDate(bookmark.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BookmarkList
|
||||
bookmarks={bookmarks}
|
||||
activeAccount={activeAccount || null}
|
||||
onLogout={onLogout}
|
||||
formatUserDisplay={formatUserDisplay}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
148
src/index.css
148
src/index.css
@@ -242,20 +242,26 @@ body {
|
||||
}
|
||||
|
||||
.bookmarks-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
background: #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333;
|
||||
transition: border-color 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: #646cff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.bookmark-item h3 {
|
||||
@@ -288,6 +294,109 @@ body {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Individual Bookmarks Styles */
|
||||
.individual-bookmarks {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.individual-bookmarks h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bookmarks-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.individual-bookmark {
|
||||
background: #2a2a2a;
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.individual-bookmark:hover {
|
||||
border-color: #646cff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.bookmark-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bookmark-type {
|
||||
background: #646cff;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bookmark-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
background: #1a1a1a;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bookmark-date {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.individual-bookmark .bookmark-content {
|
||||
margin: 0.75rem 0;
|
||||
color: #ccc;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.individual-bookmark .bookmark-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.individual-bookmark .bookmark-meta span {
|
||||
background: #1a1a1a;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Private Bookmark Styles */
|
||||
.private-bookmark {
|
||||
border-left: 4px solid #ff6b6b;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
|
||||
}
|
||||
|
||||
.private-bookmark:hover {
|
||||
border-color: #ff6b6b;
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
|
||||
.private-indicator {
|
||||
margin-left: 0.5rem;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
@@ -315,4 +424,35 @@ body {
|
||||
.user-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.individual-bookmark {
|
||||
background: #f5f5f5;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.individual-bookmark:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.individual-bookmarks h4 {
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.individual-bookmark .bookmark-content {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.bookmark-id {
|
||||
background: #e9ecef;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.individual-bookmark .bookmark-meta span {
|
||||
background: #e9ecef;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.private-bookmark {
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #e9ecef 100%);
|
||||
}
|
||||
}
|
||||
|
||||
358
src/services/bookmarkService.ts
Normal file
358
src/services/bookmarkService.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { getParsedContent } from 'applesauce-content/text'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
// Import the bookmark hidden symbol for caching
|
||||
const BookmarkHiddenSymbol = Symbol.for("bookmark-hidden")
|
||||
import { Bookmark, IndividualBookmark, ParsedContent, ActiveAccount } from '../types/bookmarks'
|
||||
|
||||
interface BookmarkData {
|
||||
id?: string
|
||||
content?: string
|
||||
created_at?: number
|
||||
kind?: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
interface ApplesauceBookmarks {
|
||||
notes?: BookmarkData[]
|
||||
articles?: BookmarkData[]
|
||||
hashtags?: BookmarkData[]
|
||||
urls?: BookmarkData[]
|
||||
}
|
||||
|
||||
interface AccountWithExtension { pubkey: string; signer?: unknown; nip04?: unknown; nip44?: unknown; [key: string]: unknown }
|
||||
|
||||
function isAccountWithExtension(account: unknown): account is AccountWithExtension {
|
||||
return typeof account === 'object' && account !== null && 'pubkey' in account && typeof (account as any).pubkey === 'string'
|
||||
}
|
||||
|
||||
// Note: Using applesauce's built-in hidden content detection instead of custom logic
|
||||
// Encrypted content detection is handled by applesauce's hasHiddenContent() function
|
||||
|
||||
function isHexId(id: unknown): id is string {
|
||||
return typeof id === 'string' && /^[0-9a-f]{64}$/i.test(id)
|
||||
}
|
||||
|
||||
interface NostrEvent {
|
||||
id: string
|
||||
kind: number
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
pubkey: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
const byId = new Map<string, NostrEvent>()
|
||||
for (const e of events) { if (e?.id && !byId.has(e.id)) byId.set(e.id, e) }
|
||||
const unique = Array.from(byId.values())
|
||||
|
||||
// Get the latest bookmark list (10003/30001) - default bookmark list without 'd' tag
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
const latestBookmarkList = bookmarkLists.find(list =>
|
||||
!list.tags?.some((t: string[]) => t[0] === 'd')
|
||||
)
|
||||
|
||||
// Group bookmark sets (30003) and named bookmark lists (10003/30001 with 'd' tag) by their 'd' identifier
|
||||
const byD = new Map<string, NostrEvent>()
|
||||
for (const e of unique) {
|
||||
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
|
||||
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const prev = byD.get(d)
|
||||
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
|
||||
}
|
||||
}
|
||||
|
||||
const setsAndNamedLists = Array.from(byD.values())
|
||||
const out: NostrEvent[] = []
|
||||
|
||||
// Add the default bookmark list if it exists
|
||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||
|
||||
// Add all bookmark sets and named bookmark lists
|
||||
out.push(...setsAndNamedLists)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const processApplesauceBookmarks = (
|
||||
bookmarks: unknown,
|
||||
activeAccount: ActiveAccount,
|
||||
isPrivate: boolean
|
||||
): IndividualBookmark[] => {
|
||||
if (!bookmarks) return []
|
||||
|
||||
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
||||
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
||||
const allItems: BookmarkData[] = []
|
||||
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
return allItems.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Date.now(),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? getParsedContent(bookmark.content) as ParsedContent : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate
|
||||
}))
|
||||
}
|
||||
// Fallback: map array-like bookmarks
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Date.now(),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? getParsedContent(bookmark.content) as ParsedContent : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const fetchBookmarks = async (
|
||||
relayPool: RelayPool,
|
||||
activeAccount: unknown, // Full account object with extension capabilities
|
||||
setBookmarks: (bookmarks: Bookmark[]) => void,
|
||||
setLoading: (loading: boolean) => void,
|
||||
timeoutId: number
|
||||
) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (!isAccountWithExtension(activeAccount)) {
|
||||
throw new Error('Invalid account object provided')
|
||||
}
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
// Fetch bookmark events - NIP-51 standards and legacy formats
|
||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [10003, 30003, 30001], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Check for events with potentially encrypted content
|
||||
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
|
||||
if (eventsWithContent.length > 0) {
|
||||
console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length)
|
||||
eventsWithContent.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '')
|
||||
console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`)
|
||||
})
|
||||
}
|
||||
|
||||
rawEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
|
||||
})
|
||||
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
if (bookmarkListEvents.length === 0) {
|
||||
setBookmarks([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// Aggregate across events
|
||||
const maybeAccount = activeAccount as AccountWithExtension
|
||||
console.log('🔐 Account object:', {
|
||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
||||
hasSigner: !!maybeAccount?.signer,
|
||||
accountType: typeof maybeAccount,
|
||||
accountKeys: maybeAccount ? Object.keys(maybeAccount) : []
|
||||
})
|
||||
|
||||
// For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content
|
||||
// The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer
|
||||
let signerCandidate: any = maybeAccount
|
||||
if (signerCandidate && !(signerCandidate as any).nip04 && !(signerCandidate as any).nip44 && maybeAccount?.signer) {
|
||||
// Fallback to the raw signer if account doesn't have nip04/nip44
|
||||
signerCandidate = maybeAccount.signer
|
||||
}
|
||||
|
||||
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
||||
if (signerCandidate) {
|
||||
console.log('🔑 Signer has nip04:', !!(signerCandidate as any).nip04)
|
||||
console.log('🔑 Signer has nip44:', !!(signerCandidate as any).nip44)
|
||||
}
|
||||
const publicItemsAll: IndividualBookmark[] = []
|
||||
const privateItemsAll: IndividualBookmark[] = []
|
||||
let newestCreatedAt = 0
|
||||
let latestContent = ''
|
||||
let allTags: string[][] = []
|
||||
for (const evt of bookmarkListEvents) {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const firstFewTags = evt.tags?.slice(0, 3).map((t: string[]) => `${t[0]}:${t[1]?.slice(0, 8)}`).join(', ') || 'none'
|
||||
|
||||
console.log('📋 Processing bookmark event:', {
|
||||
id: evt.id?.slice(0, 8),
|
||||
kind: evt.kind,
|
||||
contentLength: evt.content?.length || 0,
|
||||
contentPreview: evt.content?.slice(0, 50) + (evt.content?.length > 50 ? '...' : ''),
|
||||
tagsCount: evt.tags?.length || 0,
|
||||
hasHiddenContent: Helpers.hasHiddenContent(evt),
|
||||
canHaveHiddenTags: Helpers.canHaveHiddenTags(evt.kind),
|
||||
dTag: dTag,
|
||||
firstFewTags: firstFewTags
|
||||
})
|
||||
|
||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
// public
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||
// hidden
|
||||
try {
|
||||
console.log('🔒 Event has hidden tags:', Helpers.hasHiddenTags(evt))
|
||||
console.log('🔒 Hidden tags locked:', Helpers.isHiddenTagsLocked(evt))
|
||||
console.log('🔒 Signer candidate available:', !!signerCandidate)
|
||||
console.log('🔒 Signer candidate type:', typeof signerCandidate)
|
||||
console.log('🔒 Event kind supports hidden tags:', Helpers.canHaveHiddenTags(evt.kind))
|
||||
|
||||
// Try to unlock hidden content using applesauce's standard approach first
|
||||
if (Helpers.hasHiddenTags(evt) && Helpers.isHiddenTagsLocked(evt) && signerCandidate) {
|
||||
try {
|
||||
console.log('🔓 Attempting to unlock hidden tags with signer...')
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as any)
|
||||
console.log('✅ Successfully unlocked hidden tags')
|
||||
} catch (error) {
|
||||
console.warn('❌ Failed to unlock with default method, trying NIP-44:', error)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Helpers.unlockHiddenTags(evt, signerCandidate as any, 'nip44' as any)
|
||||
console.log('✅ Successfully unlocked hidden tags with NIP-44')
|
||||
} catch (nip44Error) {
|
||||
console.error('❌ Failed to unlock with NIP-44:', nip44Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// For events that have content but aren't recognized as supporting hidden tags (like kind 30001)
|
||||
else if (evt.content && evt.content.length > 0 && signerCandidate) {
|
||||
console.log('🔓 Attempting manual decryption for event with unrecognized kind...')
|
||||
console.log('📄 Content to decrypt:', evt.content.slice(0, 100) + '...')
|
||||
|
||||
// Try NIP-44 first (common for bookmark lists), then fall back to NIP-04
|
||||
let decryptedContent: string | undefined
|
||||
try {
|
||||
if ((signerCandidate as any).nip44?.decrypt) {
|
||||
console.log('🧪 Trying NIP-44 decryption...')
|
||||
decryptedContent = await (signerCandidate as any).nip44.decrypt(evt.pubkey, evt.content)
|
||||
}
|
||||
} catch (nip44Err) {
|
||||
console.warn('❌ NIP-44 manual decryption failed, will try NIP-04:', nip44Err)
|
||||
}
|
||||
|
||||
if (!decryptedContent) {
|
||||
try {
|
||||
if ((signerCandidate as any).nip04?.decrypt) {
|
||||
console.log('🧪 Trying NIP-04 decryption...')
|
||||
decryptedContent = await (signerCandidate as any).nip04.decrypt(evt.pubkey, evt.content)
|
||||
}
|
||||
} catch (nip04Err) {
|
||||
console.warn('❌ NIP-04 manual decryption failed:', nip04Err)
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptedContent) {
|
||||
console.log('✅ Successfully decrypted content manually')
|
||||
// Parse the decrypted content as JSON (should be array of tags)
|
||||
try {
|
||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||
console.log('📋 Decrypted hidden tags:', hiddenTags.length, 'tags')
|
||||
|
||||
// Turn tags into Bookmarks using applesauce helper, then add to private list immediately
|
||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags as any)
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
|
||||
// Cache on event for any downstream consumers/debugging
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
if (!latestContent) { latestContent = decryptedContent }
|
||||
} catch (parseError) {
|
||||
console.warn('❌ Failed to parse decrypted content as JSON:', parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
console.log('🔍 Hidden bookmarks found:', priv ? Object.keys(priv).map(k => `${k}: ${priv[k as keyof typeof priv]?.length || 0}`).join(', ') : 'none')
|
||||
if (priv) {
|
||||
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('❌ Failed to process hidden bookmarks for event:', evt.id, error)
|
||||
}
|
||||
}
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const events = await lastValueFrom(
|
||||
relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
}
|
||||
}
|
||||
const hydrateItems = (items: IndividualBookmark[]): IndividualBookmark[] => items.map(item => {
|
||||
const ev = idToEvent.get(item.id)
|
||||
if (!ev) return item
|
||||
return {
|
||||
...item,
|
||||
content: ev.content || item.content || '',
|
||||
created_at: ev.created_at || item.created_at,
|
||||
kind: ev.kind || item.kind,
|
||||
tags: ev.tags || item.tags,
|
||||
parsedContent: ev.content ? getParsedContent(ev.content) as ParsedContent : item.parsedContent
|
||||
}
|
||||
})
|
||||
const allBookmarks = [...hydrateItems(publicItemsAll), ...hydrateItems(privateItemsAll)]
|
||||
|
||||
// Sort individual bookmarks by timestamp (newest first)
|
||||
const sortedBookmarks = allBookmarks.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
|
||||
const bookmark: Bookmark = {
|
||||
id: `${activeAccount.pubkey}-bookmarks`,
|
||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||
url: '',
|
||||
content: latestContent,
|
||||
created_at: newestCreatedAt || Date.now(),
|
||||
tags: allTags,
|
||||
bookmarkCount: sortedBookmarks.length,
|
||||
eventReferences: allTags.filter(tag => tag[0] === 'e').map(tag => tag[1]),
|
||||
individualBookmarks: sortedBookmarks,
|
||||
isPrivate: privateItemsAll.length > 0,
|
||||
encryptedContent: undefined
|
||||
}
|
||||
|
||||
setBookmarks([bookmark])
|
||||
clearTimeout(timeoutId)
|
||||
setLoading(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bookmarks:', error)
|
||||
clearTimeout(timeoutId)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
47
src/types/bookmarks.ts
Normal file
47
src/types/bookmarks.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface ParsedNode {
|
||||
type: string
|
||||
value?: string
|
||||
url?: string
|
||||
encoded?: string
|
||||
children?: ParsedNode[]
|
||||
}
|
||||
|
||||
export interface ParsedContent {
|
||||
type: string
|
||||
children: ParsedNode[]
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
bookmarkCount?: number
|
||||
eventReferences?: string[]
|
||||
articleReferences?: string[]
|
||||
urlReferences?: string[]
|
||||
parsedContent?: ParsedContent
|
||||
individualBookmarks?: IndividualBookmark[]
|
||||
isPrivate?: boolean
|
||||
encryptedContent?: string
|
||||
}
|
||||
|
||||
export interface IndividualBookmark {
|
||||
id: string
|
||||
content: string
|
||||
created_at: number
|
||||
pubkey: string
|
||||
kind: number
|
||||
tags: string[][]
|
||||
parsedContent?: ParsedContent
|
||||
author?: string
|
||||
type: 'event' | 'article'
|
||||
isPrivate?: boolean
|
||||
encryptedContent?: string
|
||||
}
|
||||
|
||||
export interface ActiveAccount {
|
||||
pubkey: string
|
||||
}
|
||||
67
src/utils/bookmarkUtils.tsx
Normal file
67
src/utils/bookmarkUtils.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { ParsedContent, ParsedNode } from '../types/bookmarks'
|
||||
|
||||
export const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
}
|
||||
|
||||
// Component to render parsed content using applesauce-content
|
||||
export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
if (!parsedContent || !parsedContent.children) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderNode = (node: ParsedNode, index: number): React.ReactNode => {
|
||||
if (node.type === 'text') {
|
||||
return <span key={index}>{node.value}</span>
|
||||
}
|
||||
|
||||
if (node.type === 'mention') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`nostr:${node.encoded}`}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{node.encoded}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'link') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={node.url}
|
||||
className="nostr-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{node.url}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
return (
|
||||
<span key={index}>
|
||||
{node.children.map((child: ParsedNode, childIndex: number) =>
|
||||
renderNode(child, childIndex)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="parsed-content">
|
||||
{parsedContent.children.map((node: ParsedNode, index: number) =>
|
||||
renderNode(node, index)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000
|
||||
port: 9802
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user