Compare commits

...

33 Commits

Author SHA1 Message Date
Gigi
acce3ad4e2 chore(release): bump version to 0.1.0 2025-10-03 00:23:03 +02:00
Gigi
bdecb1409e refactor(ui): remove copy-to-clipboard buttons from bookmark cards
- Remove copy buttons for event id and author pubkey
- Clean up unused code and imports
2025-10-03 00:22:25 +02:00
Gigi
2ca350ee5f fix(bookmarks): show bookmarked event author instead of list signer\n\n- During hydration, set IndividualBookmark.pubkey to hydrated event.pubkey\n- Ensures author resolution uses the actual author of the bookmarked event 2025-10-03 00:21:24 +02:00
Gigi
20f37b94e1 fix(profile): enable reactive profile fetch via address loader lookup relays and improve fallback display\n\n- Configure createAddressLoader with common profile relays (purplepag.es, primal, nostr.band)\n- Avoid sticky 'Loading profile...' label; fallback to short pubkey until profile loads 2025-10-03 00:16:04 +02:00
Gigi
21890f002d chore(lint): fix hooks rule error by separating content resolver component and helpers
- Move shared helpers into src/utils/helpers.ts
- Add ContentWithResolvedProfiles component file to avoid hooks rule violation
- Use strong IconDefinition type in icon map
- Resolve linter warnings and errors
2025-10-03 00:12:40 +02:00
Gigi
7a5dd2f444 fix(applesauce): attach address/replaceable loaders so ProfileModel resolves reactively
- Use createAddressLoader from applesauce-loaders
- Set eventStore.addressableLoader and replaceableLoader
- Enables reactive profile fetching for logged-in user and mentions
2025-10-03 00:08:10 +02:00
Gigi
5495890204 feat(ui): improve logged-in user profile resolution
- Add debug logging to track profile loading
- Show 'Loading profile...' while profile data is being fetched
- Better handling of profile loading states
- Ensures user sees proper name instead of pubkey when available
2025-10-03 00:04:11 +02:00
Gigi
6d585dcef6 feat(ui): resolve nprofile strings to human-readable names
- Add extractNprofilePubkeys utility to parse nprofile strings from content
- Create ContentWithResolvedProfiles component using applesauce ProfileModel
- Replace nprofile strings with @displayName in bookmark content
- Update BookmarkItem to use resolved content rendering
- Improves readability by showing names instead of long nprofile strings
2025-10-03 00:02:22 +02:00
Gigi
0bae6674ce feat(ui): resolve author names using applesauce ProfileModel
- Import useEventModel and Models from applesauce-react
- Use ProfileModel to fetch author profile data for each bookmark
- Display author name/display_name/nip05 instead of raw pubkey
- Fallback to short pubkey if profile not available
- Improves readability by showing human-readable author names
2025-10-03 00:00:04 +02:00
Gigi
096509baf6 feat(ui): replace kind numbers with FontAwesome icons
- Import all kind-specific icons from FontAwesome
- Add getKindIcon mapping function based on kind-icons.txt
- Replace 'Kind: X' text with visual icon in bookmark-meta
- Add styling for kind-icon with blue accent color
- Fallback to file icon for unmapped kinds
2025-10-02 23:59:11 +02:00
Gigi
4c2626f3c4 feat(ui): add spinner to content loading state
- Replace text with FontAwesome spinner icon
- Add loading-spinner styles for proper alignment
- Improve visual feedback during content fetch
2025-10-02 23:54:19 +02:00
Gigi
70fa3bb6a8 fix(ui): left-align content and constrain images in content panel
- Force left alignment for HTML/Markdown content
- Constrain images to container width and 70vh height
- Improve readability of rendered articles
2025-10-02 23:52:12 +02:00
Gigi
719ddf3f0b feat(readability): render Markdown when proxy provides it
- Detect markdown blocks from r.jina.ai output
- Add react-markdown + remark-gfm for rendering
- Extend ContentPanel to render markdown or HTML
- Add styles for markdown content
2025-10-02 23:46:33 +02:00
Gigi
80408148fb feat: add react-markdown and remark-gfm for markdown rendering 2025-10-02 23:45:09 +02:00
Gigi
4163ffa4ba feat(ui): add READ NOW button to bookmark cards
- Shows when a bookmark has URLs
- Triggers onSelectUrl to load readability content in main panel
- Added styles for prominent call-to-action
2025-10-02 23:41:03 +02:00
Gigi
cf230623a4 chore: remove unused faLock import to satisfy linter 2025-10-02 23:39:04 +02:00
Gigi
9cd4b72f98 chore: commit pending changes 2025-10-02 23:37:13 +02:00
Gigi
eb57330915 feat(ui): add two-pane layout and styles for content panel
- Grid layout with sidebar and main pane
- Styled content panel and readable HTML area
- Sticky sidebar behavior
2025-10-02 23:34:47 +02:00
Gigi
96d93d0e17 feat(layout): add two-pane layout and content fetching pipeline
- Left: bookmark list; Right: content panel
- Selection triggers readability fetch via readerService
- ContentPanel renders title and HTML
- Wires selection from BookmarkItem -> BookmarkList -> Bookmarks
2025-10-02 23:34:21 +02:00
Gigi
1d10c10a44 feat: propagate URL selection through BookmarkList to parent 2025-10-02 23:33:42 +02:00
Gigi
dab35820b7 feat: add onSelectUrl to BookmarkItem and intercept URL clicks
- Adds optional onSelectUrl callback
- Prevents default navigation to open in main content panel
- Keeps middle-click/target blank behavior if no handler
2025-10-02 23:33:05 +02:00
Gigi
9d5e8c194b feat(ui): add ContentPanel component to render readable HTML
- Shows loading/empty states
- Renders title and HTML via dangerouslySetInnerHTML
- Minimal API for integration
2025-10-02 23:32:32 +02:00
Gigi
de32807995 feat(reader): add lightweight readability fetcher via r.jina.ai proxy
- Provide fetchReadableContent(url) returning simplified HTML
- Avoid heavy deps and CORS issues using proxy
- Extract <title> best-effort
2025-10-02 23:32:00 +02:00
Gigi
b671e0e259 feat: update bookmark icons to use fa-bookmark and fa-user-lock
- Replace faGlobe with faBookmark for public bookmarks
- Add faUserLock icon alongside faBookmark for private bookmarks
- Import faBookmark and faUserLock from FontAwesome
- Update CSS to handle icon spacing with flexbox and gap
- Private bookmarks now show both bookmark and user-lock icons
2025-10-02 23:23:57 +02:00
Gigi
e5d6fe99f3 fix: improve word-wrap for long strings and prevent overflow
- Add word-wrap, overflow-wrap, and word-break properties to content areas
- Apply to .parsed-content, .bookmark-content, .individual-bookmark
- Update .nostr-mention and .nostr-link for better long string handling
- Add overflow: hidden to .individual-bookmark container
- Ensures long nprofile strings and URLs break properly within containers
2025-10-02 23:20:09 +02:00
Gigi
9400faa00f feat: display URLs clearly in individual bookmarks
- Extract URLs from bookmark content using extractUrlsFromContent()
- Display extracted URLs in a dedicated section with proper styling
- URLs are clickable and open in new tabs
- Reuse existing CSS styles for consistent appearance
2025-10-02 23:11:22 +02:00
Gigi
5173a37b69 feat: remove 'EVENT' text from bookmark type labels
- Remove bookmark.type display next to lock/globe icons
- Keep only the visual icons for private/public indication
2025-10-02 23:10:42 +02:00
Gigi
50b32a66de feat(bookmarks): extract URLs from content into urlReferences for each item 2025-10-02 22:34:06 +02:00
Gigi
1b41b6e823 fix(timestamps): use seconds for created_at fallbacks; UI date formatting remains ms-converted 2025-10-02 22:30:31 +02:00
Gigi
2696bdb57a fix(bookmarks): dedupe individual bookmarks by id to avoid duplicates in UI 2025-10-02 22:28:47 +02:00
Gigi
e0b042b6c0 docs(readme): document Amethyst-style hidden bookmarks decryption and display flow 2025-10-02 22:05:35 +02:00
Gigi
1226124566 refactor(services): extract helpers and event processing; keep files <210 lines; lint+types clean 2025-10-02 22:03:47 +02:00
Gigi
8967963535 refactor(services): remove usages in bookmarkService, add type guards; lint clean 2025-10-02 21:42:51 +02:00
18 changed files with 1529 additions and 290 deletions

View File

@@ -0,0 +1,6 @@
---
description: when creating or modifying UI elements, especially related to icons and buttons
alwaysApply: false
---
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon.

View File

@@ -70,6 +70,34 @@ src/
└── index.css # Global styles
```
### Private (hidden) bookmarks (Amethyst-style)
We support Amethyst-style private (hidden) bookmark lists alongside public ones (NIP51):
- **Detection and unlock**
- Use `Helpers.hasHiddenTags(evt)` and `Helpers.isHiddenTagsLocked(evt)` to detect hidden tags.
- First try `Helpers.unlockHiddenTags(evt, signer)`; if that fails, try with `'nip44'`.
- For events with encrypted `content` that arent recognized as supporting hidden tags (e.g. kind 30001), manually decrypt:
- Prefer `signer.nip44.decrypt(evt.pubkey, evt.content)`, fallback to `signer.nip04.decrypt(evt.pubkey, evt.content)`.
- **Parsing and rendering**
- Decrypted `content` is JSON `string[][]` (tags). Convert with `Helpers.parseBookmarkTags(hiddenTags)`.
- Map to `IndividualBookmark[]` via our `processApplesauceBookmarks(..., isPrivate=true)` and append to the private list so they render immediately alongside public items.
- **Caching for downstream helpers**
- Cache manual results on the event with `BookmarkHiddenSymbol` and also store the decrypted blob under `EncryptedContentSymbol` to aid debugging and hydration.
- **Structure**
- `src/services/bookmarkService.ts`: orchestrates fetching, hydration, and assembling the final bookmark payload.
- `src/services/bookmarkProcessing.ts`: decryption/collection pipeline (unlock, manual decrypt, parse, merge).
- `src/services/bookmarkHelpers.ts`: shared types, guards, mapping, hydration, and symbols.
- `src/services/bookmarkEvents.ts`: event type and deduplication for NIP51 lists/sets.
- **Notes**
- We avoid `any` via narrow type guards for `nip44`/`nip04` decrypt functions.
- Files are kept small and DRY per project rules.
- Built on applesauce helpers (`Helpers.getPublicBookmarks`, `Helpers.getHiddenBookmarks`, etc.). See applesauce docs: https://hzrd149.github.io/applesauce/typedoc/modules.html
### Building for Production
```bash

657
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "markr",
"version": "0.0.2",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "markr",
"version": "0.0.2",
"version": "0.1.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
@@ -19,7 +19,9 @@
"applesauce-relay": "^3.1.0",
"nostr-tools": "^2.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -1528,9 +1530,17 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree-jsx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
"integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -1566,14 +1576,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.25",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz",
"integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1805,7 +1813,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"dev": true,
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
@@ -2463,6 +2470,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2490,6 +2507,36 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-reference-invalid": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2510,6 +2557,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2543,7 +2600,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2888,6 +2944,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-util-is-identifier-name": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3180,6 +3246,56 @@
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"license": "MIT"
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
"integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"hast-util-whitespace": "^3.0.0",
"mdast-util-mdx-expression": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"style-to-js": "^1.0.0",
"unist-util-position": "^5.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3256,6 +3372,46 @@
"dev": true,
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
"integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
"license": "MIT"
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-alphanumerical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^2.0.0",
"is-decimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3279,6 +3435,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -3484,6 +3650,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -3536,6 +3712,167 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
"integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-jsx": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
"integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"parse-entities": "^4.0.0",
"stringify-entities": "^4.0.0",
"unist-util-stringify-position": "^4.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdxjs-esm": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
"integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -3550,6 +3887,27 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-markdown": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
@@ -3663,6 +4021,127 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -4291,6 +4770,31 @@
"node": ">=6"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"character-entities-legacy": "^3.0.0",
"character-reference-invalid": "^2.0.0",
"decode-named-character-reference": "^1.0.0",
"is-alphanumerical": "^2.0.0",
"is-decimal": "^2.0.0",
"is-hexadecimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/parse-entities/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4409,6 +4913,16 @@
"node": ">= 0.8.0"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4465,6 +4979,33 @@
"react": "^18.3.1"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"@types/react": ">=18",
"react": ">=18"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4491,6 +5032,24 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -4507,6 +5066,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-rehype": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
"integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"mdast-util-to-hast": "^13.0.0",
"unified": "^11.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
@@ -4700,6 +5276,30 @@
"node": ">=0.10.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -4726,6 +5326,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-to-js": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz",
"integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==",
"license": "MIT",
"dependencies": {
"style-to-object": "1.0.9"
}
},
"node_modules/style-to-object": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz",
"integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.4"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -4759,6 +5377,16 @@
"node": ">=8.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -4860,6 +5488,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "markr",
"version": "0.0.3",
"version": "0.1.0",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {
@@ -21,7 +21,9 @@
"applesauce-relay": "^3.1.0",
"nostr-tools": "^2.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.2.43",

View File

@@ -3,6 +3,7 @@ import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
import { EventStore } from 'applesauce-core'
import { AccountManager } from 'applesauce-accounts'
import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Login from './components/Login'
import Bookmarks from './components/Bookmarks'
@@ -37,6 +38,18 @@ function App() {
console.log('Created relay group with', relayUrls.length, 'relays')
console.log('Relay URLs:', relayUrls)
// Attach address/replaceable loaders so ProfileModel can fetch profiles
const addressLoader = createAddressLoader(pool, {
eventStore: store,
lookupRelays: [
'wss://purplepag.es',
'wss://relay.primal.net',
'wss://relay.nostr.band'
]
})
store.addressableLoader = addressLoader
store.replaceableLoader = addressLoader
setEventStore(store)
setAccountManager(accounts)
setRelayPool(pool)

View File

@@ -1,60 +1,157 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faGlobe, faCopy } from '@fortawesome/free-solid-svg-icons'
import {
faBookmark,
faUserLock,
faCircleUser,
faFeather,
faRetweet,
faHeart,
faImage,
faVideo,
faFile,
faLaptopCode,
faCodePullRequest,
faBug,
faExclamationTriangle,
faBolt,
faCloudBolt,
faHighlighter,
faNewspaper,
faEyeSlash,
faThumbtack
} from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { IndividualBookmark } from '../types/bookmarks'
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
import ContentWithResolvedProfiles from './ContentWithResolvedProfiles'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string) => void
}
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)
}
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl }) => {
// removed copy-to-clipboard buttons
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
// Extract URLs from bookmark content
const extractedUrls = extractUrlsFromContent(bookmark.content)
const hasUrls = extractedUrls.length > 0
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
// Get display name for author
const getAuthorDisplayName = () => {
if (authorProfile?.name) return authorProfile.name
if (authorProfile?.display_name) return authorProfile.display_name
if (authorProfile?.nip05) return authorProfile.nip05
return short(bookmark.pubkey) // fallback to short pubkey
}
// Map kind numbers to FontAwesome icons
const getKindIcon = (kind: number) => {
const iconMap: Record<number, import('@fortawesome/fontawesome-svg-core').IconDefinition> = {
0: faCircleUser,
1: faFeather,
6: faRetweet,
7: faHeart,
20: faImage,
21: faVideo,
22: faVideo,
1063: faFile,
1337: faLaptopCode,
1617: faCodePullRequest,
1621: faBug,
1984: faExclamationTriangle,
9735: faBolt,
9321: faCloudBolt,
9802: faHighlighter,
30023: faNewspaper,
10000: faEyeSlash,
10001: faThumbtack,
10003: faBookmark
}
return iconMap[kind] || faFile // fallback to file icon
}
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
if (!hasUrls) return
const firstUrl = extractedUrls[0]
if (onSelectUrl) {
event.preventDefault()
onSelectUrl(firstUrl)
} else {
window.open(firstUrl, '_blank')
}
}
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>
{bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
</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>
{extractedUrls.length > 0 && (
<div className="bookmark-urls">
<h4>URLs:</h4>
{extractedUrls.map((url, urlIndex) => (
<a
key={urlIndex}
href={url}
target="_blank"
rel="noopener noreferrer"
className="bookmark-url"
onClick={(e) => { if (onSelectUrl) { e.preventDefault(); onSelectUrl(url) } }}
>
{url}
</a>
))}
</div>
)}
{bookmark.parsedContent ? (
<div className="bookmark-content">
{renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<p>{bookmark.content}</p>
</div>
<ContentWithResolvedProfiles content={bookmark.content} />
)}
<div className="bookmark-meta">
<span>Kind: {bookmark.kind}</span>
<span className="kind-icon">
<FontAwesomeIcon icon={getKindIcon(bookmark.kind)} />
</span>
<span>
Author: {short(bookmark.pubkey)}
<button className="copy-btn" onClick={() => copy(bookmark.pubkey)} title="Copy author pubkey">
<FontAwesomeIcon icon={faCopy} />
</button>
Author: {getAuthorDisplayName()}
</span>
</div>
{hasUrls && (
<div className="read-now">
<button className="read-now-button" onClick={handleReadNow}>
READ NOW
</button>
</div>
)}
</div>
)
}

View File

@@ -8,13 +8,15 @@ interface BookmarkListProps {
activeAccount: ActiveAccount | null
onLogout: () => void
formatUserDisplay: () => string
onSelectUrl?: (url: string) => void
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
activeAccount,
onLogout,
formatUserDisplay
formatUserDisplay,
onSelectUrl
}) => {
return (
<div className="bookmarks-container">
@@ -60,7 +62,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<h4>Individual Bookmarks ({bookmark.individualBookmarks.length}):</h4>
<div className="bookmarks-grid">
{bookmark.individualBookmarks.map((individualBookmark, index) =>
<BookmarkItem key={index} bookmark={individualBookmark} index={index} />
<BookmarkItem key={index} bookmark={individualBookmark} index={index} onSelectUrl={onSelectUrl} />
)}
</div>
</div>

View File

@@ -6,6 +6,8 @@ import { RelayPool } from 'applesauce-relay'
import { Bookmark } from '../types/bookmarks'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import ContentPanel from './ContentPanel'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
interface BookmarksProps {
relayPool: RelayPool | null
@@ -15,6 +17,9 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [loading, setLoading] = useState(true)
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
const [readerLoading, setReaderLoading] = useState(false)
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -51,11 +56,29 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, setLoading, timeoutId)
}
const handleSelectUrl = async (url: string) => {
setSelectedUrl(url)
setReaderLoading(true)
setReaderContent(undefined)
try {
const content = await fetchReadableContent(url)
setReaderContent(content)
} catch (err) {
console.warn('Failed to fetch readable content:', err)
} finally {
setReaderLoading(false)
}
}
const formatUserDisplay = () => {
if (!activeAccount) return 'Unknown User'
// Debug profile loading
console.log('Profile data:', profile)
console.log('Active account pubkey:', activeAccount.pubkey)
// Use profile data from ProfileModel if available
if (profile?.name) {
return profile.name
@@ -67,7 +90,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
return profile.nip05
}
// Fallback to formatted public key
// Fallback to formatted public key to avoid sticky loading text
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
@@ -91,12 +114,26 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
return (
<BookmarkList
bookmarks={bookmarks}
activeAccount={activeAccount || null}
onLogout={onLogout}
formatUserDisplay={formatUserDisplay}
/>
<div className="two-pane">
<div className="pane sidebar">
<BookmarkList
bookmarks={bookmarks}
activeAccount={activeAccount || null}
onLogout={onLogout}
formatUserDisplay={formatUserDisplay}
onSelectUrl={handleSelectUrl}
/>
</div>
<div className="pane main">
<ContentPanel
loading={readerLoading}
title={readerContent?.title}
html={readerContent?.html}
markdown={readerContent?.markdown}
selectedUrl={selectedUrl}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
interface ContentPanelProps {
loading: boolean
title?: string
html?: string
markdown?: string
selectedUrl?: string
}
const ContentPanel: React.FC<ContentPanelProps> = ({ loading, title, html, markdown, selectedUrl }) => {
if (!selectedUrl) {
return (
<div className="content-panel empty">
<p>Select a bookmark to preview its content.</p>
</div>
)
}
if (loading) {
return (
<div className="content-panel loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
<span>Loading content</span>
</div>
</div>
)
}
return (
<div className="content-panel">
{title && <h2 className="content-title">{title}</h2>}
{markdown ? (
<div className="content-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
) : html ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div className="content-panel empty">
<p>No readable content found for this URL.</p>
</div>
)}
</div>
)
}
export default ContentPanel

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { decode } from 'nostr-tools/nip19'
import { getPubkeyFromDecodeResult } from 'applesauce-core/helpers'
import { extractNprofilePubkeys } from '../utils/helpers'
interface Props { content: string }
const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
const matches = extractNprofilePubkeys(content)
const decoded = matches
.map((m) => {
try { return decode(m) } catch { return undefined }
})
.filter(Boolean)
const lookups = decoded.map((res) => getPubkeyFromDecodeResult(res as any)).filter(Boolean) as string[]
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
let rendered = content
matches.forEach((m, i) => {
const pk = getPubkeyFromDecodeResult(decoded[i] as any)
const found = profiles.find((p) => p.pubkey === pk)
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
if (name) rendered = rendered.replace(m, `@${name}`)
})
return <div className="bookmark-content">{rendered}</div>
}
export default ContentWithResolvedProfiles

View File

@@ -184,6 +184,9 @@ body {
.parsed-content {
margin: 1rem 0;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.nostr-mention {
@@ -194,6 +197,9 @@ body {
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9rem;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.nostr-mention:hover {
@@ -204,7 +210,9 @@ body {
.nostr-link {
color: #007bff;
text-decoration: none;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.nostr-link:hover {
@@ -249,6 +257,121 @@ body {
margin: 0 auto;
}
/* Two-pane layout */
.two-pane {
display: grid;
grid-template-columns: 360px 1fr;
gap: 1rem;
align-items: start;
}
.pane.sidebar {
position: sticky;
top: 1rem;
align-self: start;
}
.pane.main {
min-height: 60vh;
}
.content-panel {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 1rem;
text-align: left;
}
.content-panel.empty {
color: #888;
}
.loading-spinner {
display: flex;
align-items: center;
gap: 0.5rem;
color: #888;
}
.loading-spinner svg {
font-size: 1.2rem;
}
.content-title {
margin: 0 0 1rem 0;
}
.content-html {
color: #ddd;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.content-markdown {
color: #ddd;
line-height: 1.7;
}
/* Ensure content is left-aligned even if source markup uses center */
.content-panel .content-html *,
.content-panel .content-markdown * {
text-align: left !important;
}
.content-panel center,
.content-panel [align="center"] {
text-align: left !important;
}
/* Tame images from external content */
.content-panel .content-html img,
.content-panel .content-markdown img {
max-width: 100%;
max-height: 70vh;
height: auto;
width: auto;
display: block;
margin: 0.75rem 0;
border-radius: 6px;
}
.content-markdown h1,
.content-markdown h2,
.content-markdown h3,
.content-markdown h4 {
margin-top: 1.2rem;
}
.content-markdown p {
margin: 0.5rem 0;
}
.content-markdown a {
color: #8ab4f8;
text-decoration: none;
}
.content-markdown a:hover { text-decoration: underline; }
.content-markdown pre,
.content-markdown code {
background: #111;
border: 1px solid #333;
border-radius: 6px;
}
.content-markdown pre {
padding: 0.75rem;
overflow: auto;
}
.content-markdown code {
padding: 0.1rem 0.3rem;
}
.bookmark-item {
background: #1a1a1a;
padding: 1.5rem;
@@ -286,6 +409,9 @@ body {
color: #ccc;
margin: 0.5rem 0;
line-height: 1.4;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.bookmark-meta {
@@ -318,6 +444,10 @@ body {
border: 1px solid #444;
transition: all 0.2s ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
overflow: hidden;
}
.individual-bookmark:hover {
@@ -343,6 +473,9 @@ body {
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 0.25rem;
}
.bookmark-id {
@@ -363,6 +496,9 @@ body {
margin: 0.75rem 0;
color: #ccc;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.individual-bookmark .bookmark-meta {
@@ -381,6 +517,46 @@ body {
font-family: monospace;
}
.kind-icon {
background: #1a1a1a;
padding: 0.25rem 0.5rem;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.kind-icon svg {
font-size: 0.9rem;
color: #646cff;
}
.read-now {
margin-top: 0.75rem;
display: flex;
justify-content: flex-end;
}
.read-now-button {
background: #28a745;
color: white;
border: none;
padding: 0.6rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
letter-spacing: 0.5px;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.read-now-button:hover {
background: #218838;
}
.read-now-button:active {
transform: translateY(1px);
}
/* Private Bookmark Styles */
.private-bookmark {
border-left: 4px solid #ff6b6b;

View File

@@ -0,0 +1,39 @@
export interface NostrEvent {
id: string
kind: number
created_at: number
tags: string[][]
content: string
pubkey: string
sig: string
}
export 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())
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'))
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[] = []
if (latestBookmarkList) out.push(latestBookmarkList)
out.push(...setsAndNamedLists)
return out
}

View File

@@ -0,0 +1,145 @@
import { getParsedContent } from 'applesauce-content/text'
import { ActiveAccount, IndividualBookmark, ParsedContent } from '../types/bookmarks'
import type { NostrEvent } from './bookmarkEvents'
// Global symbol for caching hidden bookmark content on events
export const BookmarkHiddenSymbol = Symbol.for('bookmark-hidden')
export interface BookmarkData {
id?: string
content?: string
created_at?: number
kind?: number
tags?: string[][]
}
export interface ApplesauceBookmarks {
notes?: BookmarkData[]
articles?: BookmarkData[]
hashtags?: BookmarkData[]
urls?: BookmarkData[]
}
export interface AccountWithExtension {
pubkey: string
signer?: unknown
nip04?: unknown
nip44?: unknown
[key: string]: unknown
}
export function isAccountWithExtension(account: unknown): account is AccountWithExtension {
return (
typeof account === 'object' &&
account !== null &&
'pubkey' in account &&
typeof (account as { pubkey?: unknown }).pubkey === 'string'
)
}
export function isHexId(id: unknown): id is string {
return typeof id === 'string' && /^[0-9a-f]{64}$/i.test(id)
}
export type { NostrEvent } from './bookmarkEvents'
export { dedupeNip51Events } from './bookmarkEvents'
export 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 || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate
}))
}
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 || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate
}))
}
// Types and guards around signer/decryption APIs
export function hydrateItems(
items: IndividualBookmark[],
idToEvent: Map<string, NostrEvent>
): IndividualBookmark[] {
return items.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
return {
...item,
pubkey: ev.pubkey || item.pubkey,
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
}
})
}
// Note: event decryption/collection lives in `bookmarkProcessing.ts`
export type DecryptFn = (pubkey: string, content: string) => Promise<string>
export type UnlockSigner = unknown
export type UnlockMode = unknown
export function hasNip44Decrypt(obj: unknown): obj is { nip44: { decrypt: DecryptFn } } {
const nip44 = (obj as { nip44?: unknown })?.nip44 as { decrypt?: unknown } | undefined
return typeof nip44?.decrypt === 'function'
}
export function hasNip04Decrypt(obj: unknown): obj is { nip04: { decrypt: DecryptFn } } {
const nip04 = (obj as { nip04?: unknown })?.nip04 as { decrypt?: unknown } | undefined
return typeof nip04?.decrypt === 'function'
}
export function dedupeBookmarksById(bookmarks: IndividualBookmark[]): IndividualBookmark[] {
const seen = new Set<string>()
const result: IndividualBookmark[] = []
for (const b of bookmarks) {
if (!seen.has(b.id)) {
seen.add(b.id)
result.push(b)
}
}
return result
}
export function extractUrlsFromContent(content: string): string[] {
if (!content) return []
// Basic URL regex covering http(s) schemes
const urlRegex = /https?:\/\/[\w.-]+(?:\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?/gi
const matches = content.match(urlRegex)
if (!matches) return []
// Normalize by trimming trailing punctuation
return Array.from(new Set(matches.map(u => u.replace(/[),.;]+$/, ''))))
}

View File

@@ -0,0 +1,104 @@
import { Helpers } from 'applesauce-core'
import {
ActiveAccount,
IndividualBookmark
} from '../types/bookmarks'
import { BookmarkHiddenSymbol, hasNip04Decrypt, hasNip44Decrypt, processApplesauceBookmarks } from './bookmarkHelpers'
import type { NostrEvent } from './bookmarkHelpers'
type DecryptFn = (pubkey: string, content: string) => Promise<string>
type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
export async function collectBookmarksFromEvents(
bookmarkListEvents: NostrEvent[],
activeAccount: ActiveAccount,
signerCandidate?: unknown
): Promise<{
publicItemsAll: IndividualBookmark[]
privateItemsAll: IndividualBookmark[]
newestCreatedAt: number
latestContent: string
allTags: string[][]
}> {
const publicItemsAll: IndividualBookmark[] = []
const privateItemsAll: IndividualBookmark[] = []
let newestCreatedAt = 0
let latestContent = ''
let allTags: string[][] = []
for (const evt of bookmarkListEvents) {
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)
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
try {
if (Helpers.hasHiddenTags(evt) && Helpers.isHiddenTagsLocked(evt) && signerCandidate) {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
} catch {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
} catch {
// ignore
}
}
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
let decryptedContent: string | undefined
try {
if (hasNip44Decrypt(signerCandidate)) {
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
evt.pubkey,
evt.content
)
}
} catch {
// ignore
}
if (!decryptedContent) {
try {
if (hasNip04Decrypt(signerCandidate)) {
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
evt.pubkey,
evt.content
)
}
} catch {
// ignore
}
}
if (decryptedContent) {
try {
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
if (!latestContent) {
latestContent = decryptedContent
}
} catch {
// ignore
}
}
}
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
}
} catch {
// ignore individual event failures
}
}
return { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }
}

View File

@@ -1,124 +1,19 @@
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
}))
}
import {
AccountWithExtension,
NostrEvent,
dedupeNip51Events,
hydrateItems,
isAccountWithExtension,
isHexId,
hasNip04Decrypt,
hasNip44Decrypt,
dedupeBookmarksById,
extractUrlsFromContent
} from './bookmarkHelpers'
import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
@@ -181,126 +76,24 @@ export const fetchBookmarks = async (
// 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) {
let signerCandidate: unknown = maybeAccount
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && 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)
}
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
}
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
bookmarkListEvents,
activeAccount,
signerCandidate
)
const allItems = [...publicItemsAll, ...privateItemsAll]
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
@@ -315,32 +108,30 @@ export const fetchBookmarks = async (
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)]
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
// Sort individual bookmarks by timestamp (newest first)
const sortedBookmarks = allBookmarks.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.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(),
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter(tag => tag[0] === 'e').map(tag => tag[1]),
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
individualBookmarks: sortedBookmarks,
isPrivate: privateItemsAll.length > 0,
encryptedContent: undefined

View File

@@ -0,0 +1,49 @@
// Lightweight readability-style fetcher using r.jina.ai proxy
// Returns simplified HTML for a given URL. This avoids CORS and heavy deps.
export interface ReadableContent {
url: string
title?: string
html?: string
markdown?: string
}
function toProxyUrl(url: string): string {
// Ensure the target URL has a protocol and build the proxy URL
const normalized = /^https?:\/\//i.test(url) ? url : `https://${url}`
return `https://r.jina.ai/http://${normalized.replace(/^https?:\/\//, '')}`
}
export async function fetchReadableContent(targetUrl: string): Promise<ReadableContent> {
const proxyUrl = toProxyUrl(targetUrl)
const res = await fetch(proxyUrl)
if (!res.ok) {
throw new Error(`Failed to fetch readable content (${res.status})`)
}
const text = await res.text()
// Detect if the proxy delivered Markdown or HTML. r.jina.ai often returns a
// block starting with "Title:" and "Markdown Content:". We handle both.
const hasMarkdownBlock = /Markdown Content:\s/i.test(text)
if (hasMarkdownBlock) {
// Try to split out Title and the Markdown payload
const titleMatch = text.match(/Title:\s*(.*?)(?:\s+URL Source:|\s+Markdown Content:)/i)
const mdMatch = text.match(/Markdown Content:\s*([\s\S]*)$/i)
return {
url: targetUrl,
title: titleMatch?.[1]?.trim(),
markdown: mdMatch?.[1]?.trim()
}
}
const html = text
// Best-effort title extraction from HTML
const match = html.match(/<title[^>]*>(.*?)<\/title>/i)
return {
url: targetUrl,
title: match?.[1],
html
}
}

View File

@@ -1,10 +1,14 @@
import React from 'react'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import { ContentWithResolvedProfiles } from '../components/ContentWithResolvedProfiles'
export const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString()
}
// Component to render content with resolved nprofile names
export { default as ContentWithResolvedProfiles } from '../components/ContentWithResolvedProfiles'
// Component to render parsed content using applesauce-content
export const renderParsedContent = (parsedContent: ParsedContent) => {
if (!parsedContent || !parsedContent.children) {

13
src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,13 @@
export const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString()
}
// Extract pubkeys from nprofile strings in content
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || []
const unique = new Set<string>(matches)
return Array.from(unique)
}