Compare commits

..

54 Commits

Author SHA1 Message Date
Gigi
b64fd6cedb chore(release): bump version to 0.0.3 2025-10-02 21:39:14 +02:00
Gigi
c748f173b3 feat(bookmarks): surface manually decrypted hidden tags in UI; convert JSON tags via Helpers.parseBookmarkTags and include in private items immediately 2025-10-02 21:37:37 +02:00
Gigi
51f009a489 feat(bookmarks): try NIP-44 then NIP-04 for manual decryption; cache decrypted hidden tags for display/debug 2025-10-02 21:30:12 +02:00
Gigi
18d936e222 feat: add detailed debugging for decryption process
- Add logging for what content is being sent to browser extension
- Add fallback to try string conversion if object is passed
- Add parsing of extension error responses
- Help identify exact format expected by browser extension
2025-10-02 21:17:27 +02:00
Gigi
ce7fbdbdf3 feat: implement direct decryption for unrecognized event kinds
- Add manual decryption using signer's nip04 capabilities directly
- Parse decrypted content as JSON array of bookmark tags
- Cache decrypted content properly for getHiddenBookmarks to find
- Enable decryption of legacy bookmark events (kind 30001) with encrypted content
2025-10-02 21:08:20 +02:00
Gigi
6e6d43cb25 feat: add manual decryption for unrecognized event kinds
- Add fallback decryption for events with encrypted content but unrecognized kinds
- Temporarily change event kind to 10003 to use applesauce's standard decryption
- Enables decryption of legacy bookmark events (kind 30001) that contain private bookmarks
- Maintains compatibility with standard NIP-51 bookmark events
2025-10-02 20:59:19 +02:00
Gigi
0ed7c1497f feat: sort individual bookmarks by timestamp (newest first)
- Sort allBookmarks array by created_at timestamp in descending order
- Ensures newest bookmarks appear first in the UI
- Maintains chronological order with most recent bookmarks at the top
2025-10-02 20:56:34 +02:00
Gigi
a2c31b32de feat: increase bookmark loading timeout by 2x
- Increase timeout from 10s to 20s for bookmark fetching
- Increase timeout from 10s to 20s for event hydration
- Provide more time for slow relays to respond
2025-10-02 20:54:31 +02:00
Gigi
cdce972e72 feat: enhance bookmark debugging and fetch legacy formats
- Add detailed logging for events with content (potentially encrypted)
- Fetch legacy bookmark format (kind 30001) in addition to NIP-51 standards
- Show content preview for all events to identify encrypted content
- Help identify if private bookmarks are in different event formats
2025-10-02 20:51:20 +02:00
Gigi
00638410c0 feat: add more relays and fix logging for better bookmark debugging
- Add more popular relays for better bookmark discovery
- Fix variable scoping in bookmark event logging
- Enhanced debugging to see dTag and tag content structure
2025-10-02 20:50:07 +02:00
Gigi
2e5d0e3725 feat: add detailed logging to debug bookmark event structure
- Add logging for raw events before deduplication to see all fetched events
- Add detailed tag inspection for bookmark events including dTag and first few tags
- Help identify if private bookmarks are in separate bookmark sets (30003) or main lists (10003)
2025-10-02 20:48:16 +02:00
Gigi
a66c051444 fix: correct bookmark event kinds to match NIP-51 standards
- Fix bookmark list kind from 30001 to 10003 (kinds.BookmarkList)
- Fix bookmark sets kind from 30001 to 30003 (kinds.Bookmarksets)
- Update dedupeNip51Events to handle correct NIP-51 event kinds
- Now fetching the correct bookmark events that support hidden tags
2025-10-02 20:41:53 +02:00
Gigi
eb282fcbb0 fix: fix hidden bookmark detection by using applesauce's built-in logic
- Remove custom isEncryptedContent function that was too restrictive
- Use applesauce's hasHiddenContent() and hasHiddenTags() functions instead
- These properly detect encrypted content regardless of format
- Remove failing relay.snort.social from relay list
- Add detailed logging to show hidden content detection status
2025-10-02 20:38:46 +02:00
Gigi
d54313b015 fix: correct NIP-51 bookmark event kinds and deduplication
- Fix bookmark event kinds: use 30001 (BookmarkList) and 30003 (Bookmarksets) instead of incorrect 10003
- Update dedupeNip51Events to properly handle both bookmark lists and bookmark sets
- Add detailed logging to inspect bookmark event structure and content
- Should now properly detect Amethyst private bookmarks
2025-10-02 20:26:52 +02:00
Gigi
03c6a0c9c7 feat: add wot.dergigi.com relay for improved connectivity
- Add wot.dergigi.com as additional relay option
- Now using 6 relays total for maximum bookmark data availability
2025-10-02 20:09:41 +02:00
Gigi
dc36992199 feat: add relay.dergigi.com to relay list for better connectivity
- Add relay.dergigi.com as additional relay option
- Improve chances of fetching private bookmarks from multiple sources
2025-10-02 20:09:00 +02:00
Gigi
08fc541eaa fix: properly configure browser extension signer for hidden bookmarks
- Fix signer extraction for ExtensionAccount to use nip04/nip44 capabilities
- Add debug logging to understand hidden bookmarks unlocking process
- Ensure ExtensionAccount can be used as HiddenContentSigner for decryption
- Handle both ExtensionAccount and raw signer for maximum compatibility
2025-10-02 19:46:34 +02:00
Gigi
3eca2879ef fix: resolve all linting and type checking issues
- Fix empty catch blocks in BookmarkItem and bookmarkService
- Replace any types with proper NostrEvent interface
- Add proper error handling with console.warn
- Use eslint-disable for unavoidable any types in applesauce integration
2025-10-02 11:26:12 +02:00
Gigi
de428b8719 config: change dev server port from 3000 to 9802 2025-10-02 11:25:10 +02:00
Gigi
ac7f1007a7 feat(bookmarks): fetch all NIP-51 events; dedupe 10003/30001; unlock private via applesauce; hydrate ids; trim logs 2025-10-02 11:22:07 +02:00
Gigi
4db147ddf3 feat(ui): add copy-to-clipboard icons for event id and author pubkey 2025-10-02 11:02:43 +02:00
Gigi
2eda8f3227 chore(debug): log per-event hidden/locked state; log unlock attempts and results 2025-10-02 11:01:23 +02:00
Gigi
64825175a7 fix(bookmarks): unlock based on applesauce hidden-tags state only; keep file compact 2025-10-02 10:59:06 +02:00
Gigi
5ee0f49b69 feat(bookmarks): hydrate event content for pointers; robust unlock (nip04->nip44); aggregate across events 2025-10-02 10:51:33 +02:00
Gigi
7d26372878 feat(ui): add FontAwesome globe/lock icons; render content identically for private/public 2025-10-02 10:44:06 +02:00
Gigi
ab00bd84e6 fix(bookmarks): hide encrypted ciphertext from title/preview; prefer plaintext content 2025-10-02 10:39:57 +02:00
Gigi
ec4473fc51 feat(bookmarks): aggregate list(10003) + set(30001); unlock hidden per-event; merge results 2025-10-02 10:35:07 +02:00
Gigi
0f57338866 fix(bookmarks): avoid unlock with empty ciphertext; require hidden tags + ciphertext 2025-10-02 10:32:02 +02:00
Gigi
a8cdeeaef2 feat(bookmarks): unlock hidden bookmarks via applesauce helpers and signer; reduce logs 2025-10-02 10:30:18 +02:00
Gigi
92d49468cd fix: resolve TypeScript type issues
- Update AccountWithExtension interface to be more flexible
- Add type guard for runtime type checking
- Change fetchBookmarks parameter to accept unknown type with validation
- All linting and type checks now pass
2025-10-02 10:23:01 +02:00
Gigi
a625203fe4 refactor: clean up bookmark service and use proper applesauce approach
- Remove unused imports and variables
- Keep the enhanced debugging for multiple bookmark list events
- Use native applesauce getHiddenBookmarks which should trigger browser extension
- This follows the applesauce models pattern for handling private bookmarks
2025-10-02 10:21:44 +02:00
Gigi
e3efcd4a7c debug: enhance private bookmark detection with detailed logging
- Check multiple bookmark list events for encrypted content
- Add detailed logging for signer availability and type
- Use native applesauce getHiddenBookmarks which should trigger browser extension
- This will help identify why private bookmarks aren't being detected
2025-10-02 10:20:55 +02:00
Gigi
ba76a6a9ef feat: enhance private bookmark detection
- Fetch multiple bookmark list events (limit 10) to find private bookmarks
- Check all events for encrypted content before selecting one
- Add detailed logging to identify which event has encrypted content
- This should help find your private bookmarks if they exist in a different event
2025-10-02 10:19:37 +02:00
Gigi
c5a32b911d debug: add detailed logging for bookmark content
- Check if bookmark list has encrypted content
- Log content preview to understand the structure
- This will help determine why browser extension isn't triggered
2025-10-02 10:15:34 +02:00
Gigi
610de95481 feat: improve private bookmark handling
- Use proper error handling for getHiddenBookmarks
- Add detailed logging to understand what's happening
- The browser extension should be triggered automatically when needed
- This follows the applesauce examples pattern for decryption
2025-10-02 10:14:16 +02:00
Gigi
82c63e5d18 revert: remove manual decryption approach
- Removed manual decryption implementation
- Back to using applesauce helpers directly
- The issue is likely that browser extension needs permission for decryption
- getHiddenBookmarks returns undefined because extension hasn't been triggered yet
- All linting passes
2025-10-02 10:11:13 +02:00
Gigi
b112520056 debug: enhance account signer debugging
- Added detailed logging for account signer capabilities
- Check if account has signer with decrypt method
- This will help identify if the ExtensionAccount has proper NIP-44 decryption capabilities
- Following applesauce examples pattern for signer usage
2025-10-02 10:06:37 +02:00
Gigi
06b15f3fe2 docs: update applesauce cursor rules
- Updated applesauce documentation reference
- Added guidance to use applesauce modules when possible
- Referenced examples directory for proper usage patterns
2025-10-02 10:05:11 +02:00
Gigi
4be8eff80a feat: add debugging for private bookmark decryption
- Added detailed logging to understand why getHiddenBookmarks returns undefined
- Check bookmark list content and encryption format
- Verify account has decryption capabilities
- Pass full account object with extension capabilities to applesauce helpers
- This will help diagnose the NIP-44 vs NIP-04 encryption issue
2025-10-02 10:02:50 +02:00
Gigi
559e7ee944 fix: handle applesauce bookmark structure correctly
- Added processApplesauceBookmarks function to handle applesauce return format
- Fixed processing of {notes: [], articles: [], hashtags: [], urls: []} structure
- Added ApplesauceBookmarks interface for proper typing
- Now correctly processes all bookmark types from applesauce helpers
- Should now show all 13 bookmarks (12 notes + 1 article) instead of just 1
- All linting and type checking passes
2025-10-02 09:46:04 +02:00
Gigi
7fd8e5341e chore: ignore applesauce directory in ESLint configuration
- Added 'applesauce' to ignorePatterns in package.json
- Prevents linting errors from dependency code
- Maintains focus on project-specific code quality
- ESLint now runs cleanly with 0 errors and 0 warnings
2025-10-02 09:44:57 +02:00
Gigi
211a89afbb refactor: eliminate code duplication with DRY principle
- Created processBookmarks helper function to eliminate duplication
- Reduced public/private bookmark processing from 32 lines to 3 lines
- Simplified isPrivate check logic
- Maintained all functionality while improving maintainability
- File reduced from 117 to 105 lines (10% reduction)
- All linting and type checking passes
2025-10-02 09:43:53 +02:00
Gigi
695d3509ac refactor: simplify bookmark service using applesauce helpers
- Removed custom event fetching logic in favor of applesauce helpers
- Use getPublicBookmarks and getHiddenBookmarks directly from applesauce-core
- Simplified code from 152 lines to 105 lines (31% reduction)
- Added proper TypeScript interfaces for type safety
- Enhanced logging to debug bookmark fetching
- All linting and type checking passes
2025-10-02 09:43:17 +02:00
Gigi
7bb037f12a feat: implement getHiddenBookmarks for private bookmarks
- Added getHiddenBookmarks from applesauce-core to fetch private bookmarks
- Created HiddenBookmarkData interface for proper typing
- Handle both array and object return types from getHiddenBookmarks
- Combine public and private bookmarks in the final result
- Maintain type safety with proper TypeScript interfaces
- All linting and type checking passes
2025-10-02 09:39:59 +02:00
Gigi
a7cfc802d1 fix: resolve linting and type checking issues
- Fixed TypeScript error by properly handling undefined activeAccount
- Removed unnecessary 'as any' type assertion
- All linting rules now pass with 0 warnings
- TypeScript compilation passes without errors
2025-10-02 09:38:32 +02:00
Gigi
465278742e refactor: simplify bookmark service and reduce code duplication
- Extracted fetchEvent helper function to eliminate duplication
- Simplified main fetchBookmarks function with cleaner logic
- Used Promise.all for parallel event fetching instead of sequential loops
- Consolidated private bookmark CSS styles and removed duplicates
- Reduced file from 140 to 102 lines while maintaining functionality
- Removed unused imports and simplified error handling
2025-10-02 09:37:41 +02:00
Gigi
79f83b214f feat: add private bookmarks support with NIP-51 and visual indicators
- Updated bookmark types to support private bookmarks with isPrivate and encryptedContent fields
- Enhanced bookmark service to detect encrypted content and mark bookmarks as private
- Added visual indicators for private bookmarks with lock icon and special styling
- Added CSS styles for private bookmarks with red accent border and gradient background
- Updated BookmarkItem component to show private bookmark indicators
- Maintained compatibility with existing public bookmark functionality
2025-10-02 09:36:46 +02:00
Gigi
170feb1bd7 fix: implement proper NIP-44 decryption using applesauce hidden-content helpers
- Replace direct signer method calls with applesauce hidden-content helpers
- Use unlockHiddenContent, getHiddenContent, and isHiddenContentLocked functions
- Add proper error handling for decryption failures
- This follows the correct applesauce pattern for NIP-44 decryption
2025-10-02 09:33:33 +02:00
Gigi
f37deefa36 chore: add applesauce directory to .gitignore
- Exclude applesauce/ directory from version control
- This directory likely contains examples or testing files that shouldn't be tracked
2025-10-02 09:31:40 +02:00
Gigi
ebdfa47bd8 debug: add signer method inspection for NIP-44 decryption
- Add logging to inspect available methods on the applesauce signer
- Try multiple possible method names for NIP-44 decryption
- This will help identify the correct method name for decryption
2025-10-02 09:27:28 +02:00
Gigi
6e57c6227c feat: add private bookmark fetching with NIP-44 decryption
- Update ActiveAccount interface to include signer property
- Modify fetchBookmarks to handle both kind 10003 (public) and kind 30001 (private) bookmark lists
- Implement NIP-44 decryption for private bookmarks using applesauce SimpleSigner
- Support both public tags and encrypted private content in categorized bookmarks
- Maintain backward compatibility with existing public bookmark functionality
2025-10-02 09:25:57 +02:00
Gigi
e0acd2f7e7 feat: change bookmarks display from grid to social feed list layout
- Update .bookmarks-list to use flex column layout with max-width
- Change .bookmarks-grid to flex column for individual bookmarks
- Add social media-like styling with shadows and hover effects
- Improve visual hierarchy and spacing for feed-like appearance
2025-10-02 09:21:42 +02:00
Gigi
2253172e04 refactor: extract components and utilities to keep files under 210 lines
- Extract types to src/types/bookmarks.ts
- Extract utility functions to src/utils/bookmarkUtils.tsx
- Extract BookmarkItem component to src/components/BookmarkItem.tsx
- Extract BookmarkList component to src/components/BookmarkList.tsx
- Extract bookmark fetching logic to src/services/bookmarkService.ts
- Reduce main Bookmarks component from 416 to 100 lines
- Maintain all functionality while improving code organization
- Pass all linting and type checking
2025-10-02 09:19:44 +02:00
Gigi
15d155c565 fix: resolve undefined timeoutId variable in fetchBookmarks function
- Move timeoutId declaration outside try block to make it accessible in both try and catch blocks
- Fixes ESLint no-undef error and TypeScript compilation error
2025-10-02 09:17:05 +02:00
15 changed files with 808 additions and 356 deletions

View File

@@ -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
View File

@@ -116,3 +116,6 @@ temp/
.env.development.local
.env.test.local
.env.production.local
# applesauce examples
applesauce/

1
applesauce Symbolic link
View File

@@ -0,0 +1 @@
../applesauce

48
node_modules/.package-lock.json generated vendored
View File

@@ -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
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "markr",
"version": "0.0.2",
"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": [

View File

@@ -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

View 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>
)
}

View 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>
)
}

View File

@@ -3,50 +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 { 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
individualBookmarks?: IndividualBookmark[]
}
interface IndividualBookmark {
id: string
content: string
created_at: number
pubkey: string
kind: number
tags: string[][]
parsedContent?: ParsedContent
author?: string
type: 'event' | 'article'
}
import { Bookmark } from '../types/bookmarks'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
interface BookmarksProps {
relayPool: RelayPool | null
@@ -57,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)
@@ -67,232 +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?.pubkey]) // Only depend on pubkey, not the entire activeAccount object
const fetchBookmarks = async () => {
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('🚀 NEW VERSION: Fetching bookmark list for pubkey:', activeAccount.pubkey)
// 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
// Get relay URLs from the pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Step 1: Fetch the bookmark list event (kind 10003)
const bookmarkListFilter: Filter = {
kinds: [10003],
authors: [activeAccount.pubkey],
limit: 1 // Just get the most recent bookmark list
}
console.log('Fetching bookmark list with filter:', bookmarkListFilter)
const bookmarkListEvents = await lastValueFrom(
relayPool.req(relayUrls, bookmarkListFilter).pipe(
completeOnEose(),
takeUntil(timer(10000)),
toArray(),
)
)
console.log('Found bookmark list events:', bookmarkListEvents.length)
if (bookmarkListEvents.length === 0) {
console.log('No bookmark list found')
setBookmarks([])
setLoading(false)
return
}
// Step 2: Extract event IDs from the bookmark list
const bookmarkListEvent = bookmarkListEvents[0]
const eventTags = bookmarkListEvent.tags.filter(tag => tag[0] === 'e')
const eventIds = eventTags.map(tag => tag[1])
console.log('Found event IDs in bookmark list:', eventIds.length, eventIds)
if (eventIds.length === 0) {
console.log('No event references found in bookmark list')
setBookmarks([])
setLoading(false)
return
}
// Step 3: Fetch each individual event
console.log('Fetching individual events...')
const individualBookmarks: IndividualBookmark[] = []
for (const eventId of eventIds) {
try {
console.log('Fetching event:', eventId)
const eventFilter: Filter = {
ids: [eventId]
}
const events = await lastValueFrom(
relayPool.req(relayUrls, eventFilter).pipe(
completeOnEose(),
takeUntil(timer(5000)),
toArray(),
)
)
if (events.length > 0) {
const event = events[0]
const parsedContent = event.content ? getParsedContent(event.content) as ParsedContent : undefined
individualBookmarks.push({
id: event.id,
content: event.content,
created_at: event.created_at,
pubkey: event.pubkey,
kind: event.kind,
tags: event.tags,
parsedContent: parsedContent,
type: 'event'
})
console.log('Successfully fetched event:', event.id)
} else {
console.log('Event not found:', eventId)
}
} catch (error) {
console.error('Error fetching event:', eventId, error)
}
}
console.log('Fetched individual bookmarks:', individualBookmarks.length)
// Create a single bookmark entry with all individual bookmarks
const bookmark: Bookmark = {
id: bookmarkListEvent.id,
title: bookmarkListEvent.content || `Bookmark List (${individualBookmarks.length} items)`,
url: '',
content: bookmarkListEvent.content,
created_at: bookmarkListEvent.created_at,
tags: bookmarkListEvent.tags,
bookmarkCount: individualBookmarks.length,
eventReferences: eventIds,
individualBookmarks: individualBookmarks
}
setBookmarks([bookmark])
clearTimeout(timeoutId)
// 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)
clearTimeout(timeoutId)
setLoading(false)
}
// Get the full account object with extension capabilities
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, setLoading, timeoutId)
}
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>
)
}
// Component to render individual bookmarks
const renderIndividualBookmark = (bookmark: IndividualBookmark, index: number) => {
return (
<div key={`${bookmark.id}-${index}`} className="individual-bookmark">
<div className="bookmark-header">
<span className="bookmark-type">{bookmark.type}</span>
<span className="bookmark-id">{bookmark.id.slice(0, 8)}...{bookmark.id.slice(-8)}</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: {bookmark.pubkey.slice(0, 8)}...{bookmark.pubkey.slice(-8)}</span>
</div>
</div>
)
}
const formatUserDisplay = () => {
if (!activeAccount) return 'Unknown User'
@@ -332,84 +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, 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) =>
renderIndividualBookmark(individualBookmark, 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>
<BookmarkList
bookmarks={bookmarks}
activeAccount={activeAccount || null}
onLogout={onLogout}
formatUserDisplay={formatUserDisplay}
/>
)
}

View File

@@ -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 {
@@ -300,21 +306,24 @@ body {
}
.bookmarks-grid {
display: grid;
display: flex;
flex-direction: column;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.individual-bookmark {
background: #2a2a2a;
padding: 1rem;
border-radius: 6px;
padding: 1.25rem;
border-radius: 8px;
border: 1px solid #444;
transition: border-color 0.2s;
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 {
@@ -372,6 +381,22 @@ body {
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;
@@ -426,4 +451,8 @@ body {
background: #e9ecef;
color: #666;
}
.private-bookmark {
background: linear-gradient(135deg, #f5f5f5 0%, #e9ecef 100%);
}
}

View 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
View 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
}

View 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>
)
}

View File

@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000
port: 9802
}
})