Browse Source

Merge master into feature/text-entry, keeping unified publisher approach

- Resolved conflicts in ZettelEditor.svelte: kept unified AsciiDoc publisher
- Fixed asciidoc_metadata.ts: restored stripSectionHeader and systemAttributes
- Updated compose page to use publishSingleEvent for direct event publishing
- Fixed Svelte 5 syntax (onclick instead of on:click)
- Removed duplicate publish button from compose page

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 7 months ago
parent
commit
d536de63f1
  1. 5
      Dockerfile
  2. 40
      README.md
  3. 10
      deno.json
  4. 504
      deno.lock
  5. 25
      import_map.json
  6. 645
      package-lock.json
  7. 1
      package.json
  8. 7
      playwright.config.ts
  9. 82
      src/app.css
  10. 8
      src/app.d.ts
  11. 10
      src/app.html
  12. 128
      src/lib/components/CommentBox.svelte
  13. 887
      src/lib/components/CommentViewer.svelte
  14. 288
      src/lib/components/EventDetails.svelte
  15. 809
      src/lib/components/EventInput.svelte
  16. 708
      src/lib/components/EventSearch.svelte
  17. 5
      src/lib/components/LoginModal.svelte
  18. 9
      src/lib/components/Navigation.svelte
  19. 1154
      src/lib/components/Notifications.svelte
  20. 9
      src/lib/components/Preview.svelte
  21. 40
      src/lib/components/RelayActions.svelte
  22. 9
      src/lib/components/RelayDisplay.svelte
  23. 92
      src/lib/components/RelayInfoDisplay.svelte
  24. 143
      src/lib/components/RelayInfoList.svelte
  25. 6
      src/lib/components/RelayStatus.svelte
  26. 8
      src/lib/components/ZettelEditor.svelte
  27. 2
      src/lib/components/cards/BlogHeader.svelte
  28. 126
      src/lib/components/cards/ProfileHeader.svelte
  29. 738
      src/lib/components/embedded_events/EmbeddedEvent.svelte
  30. 311
      src/lib/components/embedded_events/EmbeddedSnippets.svelte
  31. 162
      src/lib/components/event_input/EventForm.svelte
  32. 172
      src/lib/components/event_input/EventPreview.svelte
  33. 342
      src/lib/components/event_input/TagManager.svelte
  34. 277
      src/lib/components/event_input/eventServices.ts
  35. 63
      src/lib/components/event_input/types.ts
  36. 90
      src/lib/components/event_input/validation.ts
  37. 88
      src/lib/components/publications/Publication.svelte
  38. 336
      src/lib/components/publications/PublicationFeed.svelte
  39. 2
      src/lib/components/publications/PublicationHeader.svelte
  40. 7
      src/lib/components/publications/PublicationSection.svelte
  41. 7
      src/lib/components/publications/TableOfContents.svelte
  42. 37
      src/lib/components/publications/table_of_contents.svelte.ts
  43. 28
      src/lib/components/util/ArticleNav.svelte
  44. 6
      src/lib/components/util/CardActions.svelte
  45. 6
      src/lib/components/util/ContainingIndexes.svelte
  46. 4
      src/lib/components/util/Details.svelte
  47. 7
      src/lib/components/util/Interactions.svelte
  48. 37
      src/lib/components/util/Profile.svelte
  49. 11
      src/lib/consts.ts
  50. 236
      src/lib/data_structures/docs/relay_selector_design.md
  51. 391
      src/lib/data_structures/publication_tree.ts
  52. 68
      src/lib/data_structures/websocket_pool.ts
  53. 1
      src/lib/models/search_type.d.ts
  54. 12
      src/lib/models/user_profile.d.ts
  55. 22
      src/lib/navigator/EventNetwork/Legend.svelte
  56. 46
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  57. 88
      src/lib/navigator/EventNetwork/index.svelte
  58. 51
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  59. 35
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  60. 86
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts
  61. 51
      src/lib/navigator/EventNetwork/utils/starForceSimulation.ts
  62. 88
      src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts
  63. 19
      src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
  64. 430
      src/lib/ndk.ts
  65. 4
      src/lib/parser.ts
  66. 90
      src/lib/services/event_search_service.ts
  67. 68
      src/lib/services/publisher.ts
  68. 70
      src/lib/services/search_state_manager.ts
  69. 17
      src/lib/snippets/UserSnippets.svelte
  70. 6
      src/lib/state.ts
  71. 11
      src/lib/stores/authStore.Svelte.ts
  72. 22
      src/lib/stores/networkStore.ts
  73. 137
      src/lib/stores/userStore.ts
  74. 27
      src/lib/stores/visualizationConfig.ts
  75. 23
      src/lib/utils.ts
  76. 384
      src/lib/utils/asciidoc_metadata.ts
  77. 85
      src/lib/utils/cache_manager.ts
  78. 33
      src/lib/utils/displayLimits.ts
  79. 95
      src/lib/utils/eventColors.ts
  80. 94
      src/lib/utils/eventDeduplication.ts
  81. 61
      src/lib/utils/event_input_utils.ts
  82. 55
      src/lib/utils/event_kind_utils.ts
  83. 146
      src/lib/utils/event_search.ts
  84. 4
      src/lib/utils/image_utils.ts
  85. 147
      src/lib/utils/kind24_utils.ts
  86. 58
      src/lib/utils/markup/MarkupInfo.md
  87. 3
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  88. 381
      src/lib/utils/markup/advancedMarkupParser.ts
  89. 90
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  90. 259
      src/lib/utils/markup/basicMarkupParser.ts
  91. 277
      src/lib/utils/markup/embeddedMarkupParser.ts
  92. 321
      src/lib/utils/markup/markupServices.ts
  93. 4
      src/lib/utils/markup/tikzRenderer.ts
  94. 86
      src/lib/utils/network_detection.ts
  95. 47
      src/lib/utils/nostrEventService.ts
  96. 215
      src/lib/utils/nostrUtils.ts
  97. 24
      src/lib/utils/nostr_identifiers.ts
  98. 391
      src/lib/utils/npubCache.ts
  99. 252
      src/lib/utils/profileCache.ts
  100. 137
      src/lib/utils/profile_search.ts
  101. Some files were not shown because too many files have changed in this diff Show More

5
Dockerfile

@ -1,6 +1,7 @@
FROM denoland/deno:alpine AS build FROM denoland/deno:alpine-2.4.2 AS build
WORKDIR /app/src WORKDIR /app/src
COPY . . COPY . .
RUN deno install RUN deno install
RUN deno task build RUN deno task build
@ -14,4 +15,4 @@ ENV ORIGIN=http://localhost:3000
RUN deno cache --import-map=import_map.json ./build/index.js RUN deno cache --import-map=import_map.json ./build/index.js
EXPOSE 3000 EXPOSE 3000
CMD [ "deno", "run", "--allow-env", "--allow-read", "--allow-net", "--import-map=import_map.json", "./build/index.js" ] CMD [ "deno", "run", "--allow-env", "--allow-read", "--allow-net", "--allow-sys", "--import-map=import_map.json", "./build/index.js" ]

40
README.md

@ -3,19 +3,31 @@
# Alexandria # Alexandria
Alexandria is a reader and writer for curated publications, including e-books. Alexandria is a reader and writer for curated publications, including e-books.
For a thorough introduction, please refer to our [project documention](https://next-alexandria.gitcitadel.eu/publication?d=gitcitadel-project-documentation-by-stella-v-1), viewable on Alexandria, or to the Alexandria [About page](https://next-alexandria.gitcitadel.eu/about). For a thorough introduction, please refer to our
[project documention](https://next-alexandria.gitcitadel.eu/publication?d=gitcitadel-project-documentation-by-stella-v-1),
viewable on Alexandria, or to the Alexandria
[About page](https://next-alexandria.gitcitadel.eu/about).
It also contains a [universal event viewer](https://next-alexandria.gitcitadel.eu/events), with which you can search our relays, some aggregator relays, and your own relay list, to find and view event data. It also contains a
[universal event viewer](https://next-alexandria.gitcitadel.eu/events), with
which you can search our relays, some aggregator relays, and your own relay
list, to find and view event data.
## Issues and Patches ## Issues and Patches
If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact). If you would like to suggest a feature or report a bug, please use the
[Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact).
You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly. You can also contact us
[on Nostr](https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg),
directly.
## Developing ## Developing
Make sure that you have [Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or [Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2) installed. Make sure that you have
[Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or
[Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2)
installed.
Once you've cloned this repo, install dependencies with NPM: Once you've cloned this repo, install dependencies with NPM:
@ -43,7 +55,8 @@ deno task dev
## Building ## Building
Alexandria is configured to run on a Node server. The [Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well. Alexandria is configured to run on a Node server. The
[Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well.
To build a production version of your app with Node, use: To build a production version of your app with Node, use:
@ -71,7 +84,8 @@ deno task preview
## Docker + Deno ## Docker + Deno
This application is configured to use the Deno runtime. A Docker container is provided to handle builds and deployments. This application is configured to use the Deno runtime. A Docker container is
provided to handle builds and deployments.
To build the app for local development: To build the app for local development:
@ -87,9 +101,11 @@ docker run -d -p 3000:3000 local-alexandria
## Testing ## Testing
_These tests are under development, but will run. They will later be added to the container._ _These tests are under development, but will run. They will later be added to
the container._
To run the Vitest suite we've built, install the program locally and run the tests. To run the Vitest suite we've built, install the program locally and run the
tests.
```bash ```bash
npm run test npm run test
@ -103,4 +119,8 @@ npx playwright test
## Markup Support ## Markup Support
Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md). Alexandria supports both Markdown and AsciiDoc markup for different content
types. For a detailed list of supported tags and features in the basic and
advanced markdown parsers, as well as information about AsciiDoc usage for
publications and wikis, see
[MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md).

10
deno.json

@ -2,5 +2,15 @@
"importMap": "./import_map.json", "importMap": "./import_map.json",
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"] "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
},
"tasks": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"test": "vitest"
} }
} }

504
deno.lock

@ -4,10 +4,14 @@
"npm:@noble/curves@^1.9.4": "1.9.4", "npm:@noble/curves@^1.9.4": "1.9.4",
"npm:@noble/hashes@^1.8.0": "1.8.0", "npm:@noble/hashes@^1.8.0": "1.8.0",
"npm:@nostr-dev-kit/ndk-cache-dexie@2.6": "2.6.33_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3", "npm:@nostr-dev-kit/ndk-cache-dexie@2.6": "2.6.33_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@nostr-dev-kit/ndk-cache-dexie@^2.6.33": "2.6.33_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@nostr-dev-kit/ndk@^2.14.32": "2.14.32_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3", "npm:@nostr-dev-kit/ndk@^2.14.32": "2.14.32_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@playwright/test@^1.54.1": "1.54.1", "npm:@playwright/test@^1.54.1": "1.54.1",
"npm:@popperjs/core@2.11": "2.11.8", "npm:@popperjs/core@2.11": "2.11.8",
"npm:@sveltejs/adapter-auto@^6.0.1": "6.0.1_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@6.3.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@sveltejs/adapter-node@^5.2.13": "5.2.13_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@6.3.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_rollup@4.45.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@sveltejs/adapter-static@3": "3.0.8_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@6.3.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@sveltejs/kit@^2.25.0": "2.25.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_acorn@8.15.0_@types+node@24.0.15",
"npm:@sveltejs/vite-plugin-svelte@^6.1.0": "6.1.0_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.6", "npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.6",
"npm:@tailwindcss/typography@0.5": "0.5.16_tailwindcss@3.4.17__postcss@8.5.6", "npm:@tailwindcss/typography@0.5": "0.5.16_tailwindcss@3.4.17__postcss@8.5.6",
"npm:@types/d3@^7.4.3": "7.4.3", "npm:@types/d3@^7.4.3": "7.4.3",
@ -18,20 +22,15 @@
"npm:asciidoctor@3.0": "3.0.4_@asciidoctor+core@3.0.4", "npm:asciidoctor@3.0": "3.0.4_@asciidoctor+core@3.0.4",
"npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.6", "npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.6",
"npm:bech32@2": "2.0.0", "npm:bech32@2": "2.0.0",
"npm:d3@7.9": "7.9.0_d3-selection@3.0.0",
"npm:d3@^7.9.0": "7.9.0_d3-selection@3.0.0", "npm:d3@^7.9.0": "7.9.0_d3-selection@3.0.0",
"npm:eslint-plugin-svelte@^3.11.0": "3.11.0_eslint@9.31.0_svelte@5.36.8__acorn@8.15.0_postcss@8.5.6", "npm:eslint-plugin-svelte@^3.11.0": "3.11.0_eslint@9.31.0_svelte@5.36.8__acorn@8.15.0_postcss@8.5.6",
"npm:flowbite-svelte-icons@2.1": "2.1.1_svelte@5.36.8__acorn@8.15.0_tailwind-merge@3.3.1", "npm:flowbite-svelte-icons@2.1": "2.1.1_svelte@5.36.8__acorn@8.15.0_tailwind-merge@3.3.1",
"npm:flowbite-svelte-icons@^2.2.1": "2.2.1_svelte@5.36.8__acorn@8.15.0",
"npm:flowbite-svelte@0.48": "0.48.6_svelte@5.36.8__acorn@8.15.0", "npm:flowbite-svelte@0.48": "0.48.6_svelte@5.36.8__acorn@8.15.0",
"npm:flowbite-svelte@^1.10.10": "1.10.10_svelte@5.36.8__acorn@8.15.0_tailwindcss@3.4.17__postcss@8.5.6",
"npm:flowbite@2": "2.5.2", "npm:flowbite@2": "2.5.2",
"npm:flowbite@^3.1.2": "3.1.2",
"npm:he@1.2": "1.2.0", "npm:he@1.2": "1.2.0",
"npm:highlight.js@^11.11.1": "11.11.1", "npm:highlight.js@^11.11.1": "11.11.1",
"npm:node-emoji@^2.2.0": "2.2.0", "npm:node-emoji@^2.2.0": "2.2.0",
"npm:nostr-tools@2.15": "2.15.1_typescript@5.8.3", "npm:nostr-tools@2.15": "2.15.1_typescript@5.8.3",
"npm:nostr-tools@^2.15.1": "2.15.1_typescript@5.8.3",
"npm:plantuml-encoder@^1.4.0": "1.4.0", "npm:plantuml-encoder@^1.4.0": "1.4.0",
"npm:playwright@^1.50.1": "1.54.1", "npm:playwright@^1.50.1": "1.54.1",
"npm:playwright@^1.54.1": "1.54.1", "npm:playwright@^1.54.1": "1.54.1",
@ -45,7 +44,9 @@
"npm:tailwind-merge@^3.3.1": "3.3.1", "npm:tailwind-merge@^3.3.1": "3.3.1",
"npm:tailwindcss@^3.4.17": "3.4.17_postcss@8.5.6", "npm:tailwindcss@^3.4.17": "3.4.17_postcss@8.5.6",
"npm:tslib@2.8": "2.8.1", "npm:tslib@2.8": "2.8.1",
"npm:typescript@^5.8.3": "5.8.3" "npm:typescript@^5.8.3": "5.8.3",
"npm:vite@^6.3.5": "6.3.5_@types+node@24.0.15_picomatch@4.0.3",
"npm:vitest@^3.1.3": "3.2.4_@types+node@24.0.15_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3"
}, },
"npm": { "npm": {
"@alloc/quick-lru@5.2.0": { "@alloc/quick-lru@5.2.0": {
@ -434,9 +435,38 @@
], ],
"bin": true "bin": true
}, },
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
"@popperjs/core@2.11.8": { "@popperjs/core@2.11.8": {
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
}, },
"@rollup/plugin-commonjs@28.0.6_rollup@4.45.1_picomatch@4.0.3": {
"integrity": "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==",
"dependencies": [
"@rollup/pluginutils",
"commondir",
"estree-walker@2.0.2",
"fdir",
"is-reference@1.2.1",
"magic-string",
"picomatch@4.0.3",
"rollup"
],
"optionalPeers": [
"rollup"
]
},
"@rollup/plugin-json@6.1.0_rollup@4.45.1": {
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dependencies": [
"@rollup/pluginutils",
"rollup"
],
"optionalPeers": [
"rollup"
]
},
"@rollup/plugin-node-resolve@15.3.1": { "@rollup/plugin-node-resolve@15.3.1": {
"integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
"dependencies": [ "dependencies": [
@ -447,11 +477,25 @@
"resolve" "resolve"
] ]
}, },
"@rollup/plugin-node-resolve@16.0.1_rollup@4.45.1": {
"integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==",
"dependencies": [
"@rollup/pluginutils",
"@types/resolve",
"deepmerge",
"is-module",
"resolve",
"rollup"
],
"optionalPeers": [
"rollup"
]
},
"@rollup/pluginutils@5.2.0_rollup@4.45.1": { "@rollup/pluginutils@5.2.0_rollup@4.45.1": {
"integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==",
"dependencies": [ "dependencies": [
"@types/estree", "@types/estree",
"estree-walker", "estree-walker@2.0.2",
"picomatch@4.0.3", "picomatch@4.0.3",
"rollup" "rollup"
], ],
@ -589,32 +633,69 @@
"acorn@8.15.0" "acorn@8.15.0"
] ]
}, },
"@svgdotjs/svg.draggable.js@3.0.6_@svgdotjs+svg.js@3.2.4": { "@sveltejs/adapter-auto@6.0.1_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@6.3.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==", "integrity": "sha512-mcWud3pYGPWM2Pphdj8G9Qiq24nZ8L4LB7coCUckUEy5Y7wOWGJ/enaZ4AtJTcSm5dNK1rIkBRoqt+ae4zlxcQ==",
"dependencies": [ "dependencies": [
"@svgdotjs/svg.js" "@sveltejs/kit"
] ]
}, },
"@svgdotjs/svg.filter.js@3.0.9": { "@sveltejs/adapter-node@5.2.13_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@6.3.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_rollup@4.45.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==", "integrity": "sha512-yS2TVFmIrxjGhYaV5/iIUrJ3mJl6zjaYn0lBD70vTLnYvJeqf3cjvLXeXCUCuYinhSBoyF4DpfGla49BnIy7sQ==",
"dependencies": [ "dependencies": [
"@svgdotjs/svg.js" "@rollup/plugin-commonjs",
"@rollup/plugin-json",
"@rollup/plugin-node-resolve@16.0.1_rollup@4.45.1",
"@sveltejs/kit",
"rollup"
] ]
}, },
"@svgdotjs/svg.js@3.2.4": { "@sveltejs/adapter-static@3.0.8_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@6.3.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==" "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"dependencies": [
"@sveltejs/kit"
]
}, },
"@svgdotjs/svg.resize.js@2.0.5_@svgdotjs+svg.js@3.2.4_@svgdotjs+svg.select.js@4.0.3__@svgdotjs+svg.js@3.2.4": { "@sveltejs/kit@2.25.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_acorn@8.15.0_@types+node@24.0.15": {
"integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==", "integrity": "sha512-8H+fxDEp7Xq6tLFdrGdS5fLu6ONDQQ9DgyjboXpChubuFdfH9QoFX09ypssBpyNkJNZFt9eW3yLmXIc9CesPCA==",
"dependencies": [ "dependencies": [
"@svgdotjs/svg.js", "@sveltejs/acorn-typescript",
"@svgdotjs/svg.select.js" "@sveltejs/vite-plugin-svelte",
"@types/cookie",
"acorn@8.15.0",
"cookie",
"devalue",
"esm-env",
"kleur",
"magic-string",
"mrmime",
"sade",
"set-cookie-parser",
"sirv",
"svelte",
"vite"
],
"bin": true
},
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@6.3.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
"debug",
"svelte",
"vite"
] ]
}, },
"@svgdotjs/svg.select.js@4.0.3_@svgdotjs+svg.js@3.2.4": { "@sveltejs/vite-plugin-svelte@6.1.0_svelte@5.36.8__acorn@8.15.0_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "integrity": "sha512-+U6lz1wvGEG/BvQyL4z/flyNdQ9xDNv5vrh+vWBWTHaebqT0c9RNggpZTo/XSPoHsSCWBlYaTlRX8pZ9GATXCw==",
"dependencies": [ "dependencies": [
"@svgdotjs/svg.js" "@sveltejs/vite-plugin-svelte-inspector",
"debug",
"deepmerge",
"kleur",
"magic-string",
"svelte",
"vite",
"vitefu"
] ]
}, },
"@tailwindcss/forms@0.5.10_tailwindcss@3.4.17__postcss@8.5.6": { "@tailwindcss/forms@0.5.10_tailwindcss@3.4.17__postcss@8.5.6": {
@ -634,6 +715,15 @@
"tailwindcss" "tailwindcss"
] ]
}, },
"@types/chai@5.2.2": {
"integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
"dependencies": [
"@types/deep-eql"
]
},
"@types/cookie@0.6.0": {
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"@types/d3-array@3.2.1": { "@types/d3-array@3.2.1": {
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
}, },
@ -794,6 +884,9 @@
"@types/d3-zoom" "@types/d3-zoom"
] ]
}, },
"@types/deep-eql@4.0.2": {
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
},
"@types/estree@1.0.8": { "@types/estree@1.0.8": {
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
}, },
@ -830,6 +923,64 @@
"@types/resolve@1.20.2": { "@types/resolve@1.20.2": {
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
}, },
"@vitest/expect@3.2.4": {
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dependencies": [
"@types/chai",
"@vitest/spy",
"@vitest/utils",
"chai",
"tinyrainbow"
]
},
"@vitest/mocker@3.2.4_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dependencies": [
"@vitest/spy",
"estree-walker@3.0.3",
"magic-string",
"vite"
],
"optionalPeers": [
"vite"
]
},
"@vitest/pretty-format@3.2.4": {
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dependencies": [
"tinyrainbow"
]
},
"@vitest/runner@3.2.4": {
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dependencies": [
"@vitest/utils",
"pathe",
"strip-literal"
]
},
"@vitest/snapshot@3.2.4": {
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dependencies": [
"@vitest/pretty-format",
"magic-string",
"pathe"
]
},
"@vitest/spy@3.2.4": {
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dependencies": [
"tinyspy"
]
},
"@vitest/utils@3.2.4": {
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dependencies": [
"@vitest/pretty-format",
"loupe",
"tinyrainbow"
]
},
"@yr/monotone-cubic-spline@1.0.3": { "@yr/monotone-cubic-spline@1.0.3": {
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==" "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
}, },
@ -896,17 +1047,6 @@
"svg.select.js@3.0.1" "svg.select.js@3.0.1"
] ]
}, },
"apexcharts@4.7.0_@svgdotjs+svg.js@3.2.4_@svgdotjs+svg.select.js@4.0.3__@svgdotjs+svg.js@3.2.4": {
"integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==",
"dependencies": [
"@svgdotjs/svg.draggable.js",
"@svgdotjs/svg.filter.js",
"@svgdotjs/svg.js",
"@svgdotjs/svg.resize.js",
"@svgdotjs/svg.select.js",
"@yr/monotone-cubic-spline"
]
},
"arg@5.0.2": { "arg@5.0.2": {
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
}, },
@ -934,6 +1074,9 @@
"assert-never@1.4.0": { "assert-never@1.4.0": {
"integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==" "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA=="
}, },
"assertion-error@2.0.1": {
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
},
"async@3.2.6": { "async@3.2.6": {
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
}, },
@ -997,6 +1140,9 @@
], ],
"bin": true "bin": true
}, },
"cac@6.7.14": {
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
},
"call-bind-apply-helpers@1.0.2": { "call-bind-apply-helpers@1.0.2": {
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": [ "dependencies": [
@ -1023,6 +1169,16 @@
"caniuse-lite@1.0.30001727": { "caniuse-lite@1.0.30001727": {
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==" "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="
}, },
"chai@5.2.1": {
"integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==",
"dependencies": [
"assertion-error",
"check-error",
"deep-eql",
"loupe",
"pathval"
]
},
"chalk@4.1.2": { "chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [ "dependencies": [
@ -1039,6 +1195,9 @@
"is-regex" "is-regex"
] ]
}, },
"check-error@2.1.1": {
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
},
"chokidar@3.6.0": { "chokidar@3.6.0": {
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": [ "dependencies": [
@ -1097,6 +1256,9 @@
"commander@7.2.0": { "commander@7.2.0": {
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
}, },
"commondir@1.0.1": {
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="
},
"concat-map@0.0.1": { "concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
}, },
@ -1107,6 +1269,9 @@
"@babel/types" "@babel/types"
] ]
}, },
"cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"cross-spawn@7.0.6": { "cross-spawn@7.0.6": {
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": [ "dependencies": [
@ -1322,9 +1487,6 @@
"d3-zoom" "d3-zoom"
] ]
}, },
"date-fns@4.1.0": {
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"debug@4.4.1": { "debug@4.4.1": {
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": [ "dependencies": [
@ -1334,6 +1496,9 @@
"decamelize@1.2.0": { "decamelize@1.2.0": {
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="
}, },
"deep-eql@5.0.2": {
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="
},
"deep-is@0.1.4": { "deep-is@0.1.4": {
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
}, },
@ -1346,6 +1511,9 @@
"robust-predicates" "robust-predicates"
] ]
}, },
"devalue@5.1.1": {
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="
},
"dexie@4.0.11": { "dexie@4.0.11": {
"integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==" "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="
}, },
@ -1397,12 +1565,48 @@
"es-errors@1.3.0": { "es-errors@1.3.0": {
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
}, },
"es-module-lexer@1.7.0": {
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="
},
"es-object-atoms@1.1.1": { "es-object-atoms@1.1.1": {
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": [ "dependencies": [
"es-errors" "es-errors"
] ]
}, },
"esbuild@0.25.7": {
"integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/openharmony-arm64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
],
"scripts": true,
"bin": true
},
"escalade@3.2.0": { "escalade@3.2.0": {
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
}, },
@ -1518,9 +1722,18 @@
"estree-walker@2.0.2": { "estree-walker@2.0.2": {
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
}, },
"estree-walker@3.0.3": {
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dependencies": [
"@types/estree"
]
},
"esutils@2.0.3": { "esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
}, },
"expect-type@1.2.2": {
"integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="
},
"fast-deep-equal@3.1.3": { "fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
}, },
@ -1600,7 +1813,7 @@
"flowbite-datepicker@1.3.2": { "flowbite-datepicker@1.3.2": {
"integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==", "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==",
"dependencies": [ "dependencies": [
"@rollup/plugin-node-resolve", "@rollup/plugin-node-resolve@15.3.1",
"flowbite@2.5.2" "flowbite@2.5.2"
] ]
}, },
@ -1608,40 +1821,17 @@
"integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==", "integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==",
"dependencies": [ "dependencies": [
"svelte", "svelte",
"tailwind-merge@3.3.1" "tailwind-merge"
]
},
"flowbite-svelte-icons@2.2.1_svelte@5.36.8__acorn@8.15.0": {
"integrity": "sha512-SH59319zN4TFpmvFMD7+0ETyDxez4Wyw3mgz7hkjhvrx8HawNAS3Fp7au84pZEs1gniX4hvXIg54U+4YybV2rA==",
"dependencies": [
"clsx",
"svelte",
"tailwind-merge@3.3.1"
] ]
}, },
"flowbite-svelte@0.48.6_svelte@5.36.8__acorn@8.15.0": { "flowbite-svelte@0.48.6_svelte@5.36.8__acorn@8.15.0": {
"integrity": "sha512-/PmeR3ipHHvda8vVY9MZlymaRoJsk8VddEeoLzIygfYwJV68ey8gHuQPC1dq9J6NDCTE5+xOPtBiYUtVjCfvZw==", "integrity": "sha512-/PmeR3ipHHvda8vVY9MZlymaRoJsk8VddEeoLzIygfYwJV68ey8gHuQPC1dq9J6NDCTE5+xOPtBiYUtVjCfvZw==",
"dependencies": [ "dependencies": [
"@floating-ui/dom", "@floating-ui/dom",
"apexcharts@3.54.1", "apexcharts",
"flowbite@3.1.2", "flowbite@3.1.2",
"svelte", "svelte",
"tailwind-merge@3.3.1" "tailwind-merge"
]
},
"flowbite-svelte@1.10.10_svelte@5.36.8__acorn@8.15.0_tailwindcss@3.4.17__postcss@8.5.6": {
"integrity": "sha512-9YCB3EqQKlu7in9pxE46eeA+zt98vhUK1nb0eR2o5wpRfsWj60u9v43lMtfhpxSTsh2Jebh+wVLNYyyrYa0UGA==",
"dependencies": [
"@floating-ui/dom",
"@floating-ui/utils",
"apexcharts@4.7.0_@svgdotjs+svg.js@3.2.4_@svgdotjs+svg.select.js@4.0.3__@svgdotjs+svg.js@3.2.4",
"clsx",
"date-fns",
"flowbite@3.1.2",
"svelte",
"tailwind-merge@3.3.1",
"tailwind-variants",
"tailwindcss"
] ]
}, },
"flowbite@2.5.2": { "flowbite@2.5.2": {
@ -1867,6 +2057,12 @@
"is-promise@2.2.2": { "is-promise@2.2.2": {
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="
}, },
"is-reference@1.2.1": {
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dependencies": [
"@types/estree"
]
},
"is-reference@3.0.3": { "is-reference@3.0.3": {
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dependencies": [ "dependencies": [
@ -1911,6 +2107,9 @@
"js-stringify@1.0.2": { "js-stringify@1.0.2": {
"integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="
}, },
"js-tokens@9.0.1": {
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="
},
"js-yaml@4.1.0": { "js-yaml@4.1.0": {
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": [ "dependencies": [
@ -1940,6 +2139,9 @@
"json-buffer" "json-buffer"
] ]
}, },
"kleur@4.1.5": {
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
},
"known-css-properties@0.37.0": { "known-css-properties@0.37.0": {
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==" "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="
}, },
@ -1989,6 +2191,9 @@
"lodash.merge@4.6.2": { "lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
}, },
"loupe@3.1.4": {
"integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg=="
},
"lru-cache@10.4.3": { "lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
}, },
@ -2042,6 +2247,9 @@
"mri@1.2.0": { "mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
}, },
"mrmime@2.0.1": {
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="
},
"ms@2.1.3": { "ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
@ -2184,6 +2392,12 @@
"minipass" "minipass"
] ]
}, },
"pathe@2.0.3": {
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
"pathval@2.0.1": {
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="
},
"picocolors@1.1.1": { "picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
@ -2535,6 +2749,9 @@
"set-blocking@2.0.0": { "set-blocking@2.0.0": {
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
}, },
"set-cookie-parser@2.7.1": {
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
},
"shebang-command@2.0.0": { "shebang-command@2.0.0": {
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dependencies": [ "dependencies": [
@ -2544,9 +2761,20 @@
"shebang-regex@3.0.0": { "shebang-regex@3.0.0": {
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
}, },
"siginfo@2.0.0": {
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
},
"signal-exit@4.1.0": { "signal-exit@4.1.0": {
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
}, },
"sirv@3.0.1": {
"integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
"dependencies": [
"@polka/url",
"mrmime",
"totalist"
]
},
"skin-tone@2.0.0": { "skin-tone@2.0.0": {
"integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
"dependencies": [ "dependencies": [
@ -2559,6 +2787,12 @@
"source-map@0.6.1": { "source-map@0.6.1": {
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}, },
"stackback@0.0.2": {
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
},
"std-env@3.9.0": {
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="
},
"string-width@4.2.3": { "string-width@4.2.3": {
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": [ "dependencies": [
@ -2590,6 +2824,12 @@
"strip-json-comments@3.1.1": { "strip-json-comments@3.1.1": {
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
}, },
"strip-literal@3.0.0": {
"integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
"dependencies": [
"js-tokens"
]
},
"sucrase@3.35.0": { "sucrase@3.35.0": {
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dependencies": [ "dependencies": [
@ -2653,7 +2893,7 @@
"clsx", "clsx",
"esm-env", "esm-env",
"esrap", "esrap",
"is-reference", "is-reference@3.0.3",
"locate-character", "locate-character",
"magic-string", "magic-string",
"zimmerframe" "zimmerframe"
@ -2705,19 +2945,9 @@
"svg.js" "svg.js"
] ]
}, },
"tailwind-merge@3.0.2": {
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="
},
"tailwind-merge@3.3.1": { "tailwind-merge@3.3.1": {
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==" "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="
}, },
"tailwind-variants@1.0.0_tailwindcss@3.4.17__postcss@8.5.6": {
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"dependencies": [
"tailwind-merge@3.0.2",
"tailwindcss"
]
},
"tailwindcss@3.4.17_postcss@8.5.6": { "tailwindcss@3.4.17_postcss@8.5.6": {
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dependencies": [ "dependencies": [
@ -2758,6 +2988,28 @@
"any-promise" "any-promise"
] ]
}, },
"tinybench@2.9.0": {
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
},
"tinyexec@0.3.2": {
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
},
"tinyglobby@0.2.14_picomatch@4.0.3": {
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [
"fdir",
"picomatch@4.0.3"
]
},
"tinypool@1.1.1": {
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="
},
"tinyrainbow@2.0.0": {
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="
},
"tinyspy@4.0.3": {
"integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="
},
"to-regex-range@5.0.1": { "to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [ "dependencies": [
@ -2767,6 +3019,9 @@
"token-stream@1.0.0": { "token-stream@1.0.0": {
"integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg=="
}, },
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
"ts-interface-checker@0.1.13": { "ts-interface-checker@0.1.13": {
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
}, },
@ -2823,6 +3078,78 @@
"util-deprecate@1.0.2": { "util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}, },
"vite-node@3.2.4_@types+node@24.0.15": {
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dependencies": [
"cac",
"debug",
"es-module-lexer",
"pathe",
"vite"
],
"bin": true
},
"vite@6.3.5_@types+node@24.0.15_picomatch@4.0.3": {
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dependencies": [
"@types/node@24.0.15",
"esbuild",
"fdir",
"picomatch@4.0.3",
"postcss",
"rollup",
"tinyglobby"
],
"optionalDependencies": [
"fsevents@2.3.3"
],
"optionalPeers": [
"@types/node@24.0.15"
],
"bin": true
},
"vitefu@1.1.1_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dependencies": [
"vite"
],
"optionalPeers": [
"vite"
]
},
"vitest@3.2.4_@types+node@24.0.15_vite@6.3.5__@types+node@24.0.15__picomatch@4.0.3": {
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dependencies": [
"@types/chai",
"@types/node@24.0.15",
"@vitest/expect",
"@vitest/mocker",
"@vitest/pretty-format",
"@vitest/runner",
"@vitest/snapshot",
"@vitest/spy",
"@vitest/utils",
"chai",
"debug",
"expect-type",
"magic-string",
"pathe",
"picomatch@4.0.3",
"std-env",
"tinybench",
"tinyexec",
"tinyglobby",
"tinypool",
"tinyrainbow",
"vite",
"vite-node",
"why-is-node-running"
],
"optionalPeers": [
"@types/node@24.0.15"
],
"bin": true
},
"void-elements@3.1.0": { "void-elements@3.1.0": {
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="
}, },
@ -2836,6 +3163,14 @@
], ],
"bin": true "bin": true
}, },
"why-is-node-running@2.3.0": {
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dependencies": [
"siginfo",
"stackback"
],
"bin": true
},
"with@7.0.2": { "with@7.0.2": {
"integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==",
"dependencies": [ "dependencies": [
@ -2945,18 +3280,25 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@nostr-dev-kit/ndk-cache-dexie@^2.6.33", "npm:@noble/curves@^1.9.4",
"npm:@noble/hashes@^1.8.0",
"npm:@nostr-dev-kit/ndk-cache-dexie@2.6",
"npm:@nostr-dev-kit/ndk@^2.14.32", "npm:@nostr-dev-kit/ndk@^2.14.32",
"npm:@popperjs/core@2.11", "npm:@popperjs/core@2.11",
"npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5", "npm:@tailwindcss/typography@0.5",
"npm:asciidoctor@3.0", "npm:asciidoctor@3.0",
"npm:d3@7.9", "npm:bech32@2",
"npm:flowbite-svelte-icons@^2.2.1", "npm:d3@^7.9.0",
"npm:flowbite-svelte@^1.10.10", "npm:flowbite-svelte-icons@2.1",
"npm:flowbite@^3.1.2", "npm:flowbite-svelte@0.48",
"npm:flowbite@2",
"npm:he@1.2", "npm:he@1.2",
"npm:nostr-tools@^2.15.1", "npm:highlight.js@^11.11.1",
"npm:node-emoji@^2.2.0",
"npm:nostr-tools@2.15",
"npm:plantuml-encoder@^1.4.0",
"npm:qrcode@^1.5.4",
"npm:svelte@^5.36.8", "npm:svelte@^5.36.8",
"npm:tailwind-merge@^3.3.1" "npm:tailwind-merge@^3.3.1"
], ],

25
import_map.json

@ -2,18 +2,29 @@
"imports": { "imports": {
"he": "npm:he@1.2.x", "he": "npm:he@1.2.x",
"@nostr-dev-kit/ndk": "npm:@nostr-dev-kit/ndk@^2.14.32", "@nostr-dev-kit/ndk": "npm:@nostr-dev-kit/ndk@^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "npm:@nostr-dev-kit/ndk-cache-dexie@^2.6.33", "@nostr-dev-kit/ndk-cache-dexie": "npm:@nostr-dev-kit/ndk-cache-dexie@2.6.x",
"@popperjs/core": "npm:@popperjs/core@2.11.x", "@popperjs/core": "npm:@popperjs/core@2.11.x",
"@tailwindcss/forms": "npm:@tailwindcss/forms@0.5.x", "@tailwindcss/forms": "npm:@tailwindcss/forms@0.5.x",
"@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.x", "@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.x",
"asciidoctor": "npm:asciidoctor@3.0.x", "asciidoctor": "npm:asciidoctor@3.0.x",
"d3": "npm:d3@7.9.x", "d3": "npm:d3@^7.9.0",
"nostr-tools": "npm:nostr-tools@^2.15.1", "nostr-tools": "npm:nostr-tools@2.15.x",
"tailwind-merge": "npm:tailwind-merge@^3.3.1", "tailwind-merge": "npm:tailwind-merge@^3.3.1",
"svelte": "npm:svelte@^5.36.8", "svelte": "npm:svelte@^5.36.8",
"flowbite": "npm:flowbite@^3.1.2", "flowbite": "npm:flowbite@2.x",
"flowbite-svelte": "npm:flowbite-svelte@^1.10.10", "flowbite-svelte": "npm:flowbite-svelte@0.48.x",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@^2.2.1", "flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x",
"child_process": "node:child_process" "@noble/curves": "npm:@noble/curves@^1.9.4",
"@noble/curves/secp256k1": "npm:@noble/curves@^1.9.4/secp256k1",
"@noble/hashes": "npm:@noble/hashes@^1.8.0",
"@noble/hashes/sha2.js": "npm:@noble/hashes@^1.8.0/sha2.js",
"@noble/hashes/utils": "npm:@noble/hashes@^1.8.0/utils",
"bech32": "npm:bech32@^2.0.0",
"highlight.js": "npm:highlight.js@^11.11.1",
"node-emoji": "npm:node-emoji@^2.2.0",
"plantuml-encoder": "npm:plantuml-encoder@^1.4.0",
"qrcode": "npm:qrcode@^1.5.4",
"child_process": "node:child_process",
"process": "node:process"
} }
} }

645
package-lock.json generated

File diff suppressed because it is too large Load Diff

1
package.json

@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"dev:debug": "DEBUG_RELAYS=true vite dev",
"dev:node": "node --version && vite dev", "dev:node": "node --version && vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",

7
playwright.config.ts

@ -27,7 +27,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173', baseURL: "http://localhost:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry", trace: "on-first-retry",
@ -49,7 +49,6 @@ export default defineConfig({
name: "webkit", name: "webkit",
use: { ...devices["Desktop Safari"] }, use: { ...devices["Desktop Safari"] },
}, },
/* Test against mobile viewports. */ /* Test against mobile viewports. */
// { // {
// name: 'Mobile Chrome', // name: 'Mobile Chrome',
@ -73,8 +72,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'npm run dev', command: "npm run dev",
url: 'http://localhost:5173', url: "http://localhost:5173",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

82
src/app.css

@ -2,7 +2,6 @@
@import "./styles/scrollbar.css"; @import "./styles/scrollbar.css";
@import "./styles/publications.css"; @import "./styles/publications.css";
@import "./styles/visualize.css"; @import "./styles/visualize.css";
@import "./styles/events.css";
@import "./styles/asciidoc.css"; @import "./styles/asciidoc.css";
/* Custom styles */ /* Custom styles */
@ -28,7 +27,9 @@
} }
div[role="tooltip"] button.btn-leather { div[role="tooltip"] button.btn-leather {
@apply hover:text-primary-600 dark:hover:text-primary-400 hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200 dark:hover:bg-gray-700; @apply hover:text-primary-600 dark:hover:text-primary-400
hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200
dark:hover:bg-gray-700;
} }
.image-border { .image-border {
@ -36,8 +37,10 @@
} }
div.card-leather { div.card-leather {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; @apply shadow-none text-primary-1000 border-s-4 bg-highlight
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
} }
div.card-leather h1, div.card-leather h1,
@ -46,11 +49,13 @@
div.card-leather h4, div.card-leather h4,
div.card-leather h5, div.card-leather h5,
div.card-leather h6 { div.card-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400; @apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
} }
div.card-leather .font-thin { div.card-leather .font-thin {
@apply text-gray-900 hover:text-primary-700 dark:text-gray-100 dark:hover:text-primary-300; @apply text-gray-900 hover:text-primary-700 dark:text-gray-100
dark:hover:text-primary-300;
} }
main { main {
@ -74,7 +79,8 @@
div.note-leather, div.note-leather,
p.note-leather, p.note-leather,
section.note-leather { section.note-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 p-2 rounded; @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
p-2 rounded;
} }
.edit div.note-leather:hover:not(:has(.note-leather:hover)), .edit div.note-leather:hover:not(:has(.note-leather:hover)),
@ -117,7 +123,8 @@
} }
div.modal-leather > div { div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600; @apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100
dark:border-primary-600;
} }
div.modal-leather > div > h1, div.modal-leather > div > h1,
@ -126,11 +133,14 @@
div.modal-leather > div > h4, div.modal-leather > div > h4,
div.modal-leather > div > h5, div.modal-leather > div > h5,
div.modal-leather > div > h6 { div.modal-leather > div > h6 {
@apply text-gray-900 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-100; @apply text-gray-900 hover:text-gray-900 dark:text-gray-100
dark:hover:text-gray-100;
} }
div.modal-leather button { div.modal-leather button {
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400; @apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950
dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600
dark:text-gray-100 dark:hover:text-primary-400;
} }
/* Navbar */ /* Navbar */
@ -143,7 +153,8 @@
} }
nav.navbar-leather svg { nav.navbar-leather svg {
@apply fill-gray-900 hover:fill-primary-600 dark:fill-gray-100 dark:hover:fill-primary-400; @apply fill-gray-900 hover:fill-primary-600 dark:fill-gray-100
dark:hover:fill-primary-400;
} }
nav.navbar-leather h1, nav.navbar-leather h1,
@ -152,7 +163,8 @@
nav.navbar-leather h4, nav.navbar-leather h4,
nav.navbar-leather h5, nav.navbar-leather h5,
nav.navbar-leather h6 { nav.navbar-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400; @apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
} }
div.skeleton-leather div { div.skeleton-leather div {
@ -272,11 +284,13 @@
/* Lists */ /* Lists */
.ol-leather li a, .ol-leather li a,
.ul-leather li a { .ul-leather li a {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400; @apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400;
} }
.link { .link {
@apply underline cursor-pointer hover:text-primary-600 dark:hover:text-primary-400; @apply underline cursor-pointer hover:text-primary-600
dark:hover:text-primary-400;
} }
/* Card with transition */ /* Card with transition */
@ -290,11 +304,14 @@
} }
.tags span { .tags span {
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-primary-900 dark:text-primary-200; @apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5
rounded-sm dark:bg-primary-900 dark:text-primary-200;
} }
.npub-badge { .npub-badge {
@apply inline-flex space-x-1 items-center text-primary-600 dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border border-primary-600 dark:border-primary-500; @apply inline-flex space-x-1 items-center text-primary-600
dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border
border-primary-600 dark:border-primary-500;
svg { svg {
@apply fill-primary-600 dark:fill-primary-500; @apply fill-primary-600 dark:fill-primary-500;
@ -303,16 +320,28 @@
} }
@layer components { @layer components {
canvas.qr-code {
@apply block mx-auto my-4;
}
/* Legend */ /* Legend */
.leather-legend { .leather-legend {
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded; @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2
@apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; rounded;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; @apply shadow-none text-primary-1000 border border-s-4 bg-highlight
border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
max-width: 300px;
min-width: 200px;
overflow: hidden;
} }
/* Tooltip */ /* Tooltip */
.tooltip-leather { .tooltip-leather {
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 transition-colors duration-200; @apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000
text-gray-900 dark:text-gray-100 border border-gray-200
dark:border-gray-700 transition-colors duration-200;
max-width: 400px; max-width: 400px;
z-index: 1000; z-index: 1000;
} }
@ -536,17 +565,26 @@
input[type="tel"], input[type="tel"],
input[type="url"], input[type="url"],
textarea { textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none px-4 py-2; @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply focus:border-primary-600 dark:focus:border-primary-400; @apply focus:border-primary-600 dark:focus:border-primary-400;
} }
/* Table of Contents highlighting */ /* Table of Contents highlighting */
.toc-highlight { .toc-highlight {
@apply bg-primary-200 dark:bg-primary-700 border-l-4 border-primary-600 dark:border-primary-400 font-medium; @apply bg-primary-200 dark:bg-primary-700 border-l-4 border-primary-600
dark:border-primary-400 font-medium;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
.toc-highlight:hover { .toc-highlight:hover {
@apply bg-primary-300 dark:bg-primary-600; @apply bg-primary-300 dark:bg-primary-600;
} }
/* Override prose first-line bold styling */
.prose p:first-line,
.prose-sm p:first-line,
.prose-invert p:first-line {
font-weight: normal !important;
}
} }

8
src/app.d.ts vendored

@ -13,6 +13,10 @@ declare global {
publicationType?: string; publicationType?: string;
indexEvent?: NDKEvent; indexEvent?: NDKEvent;
url?: URL; url?: URL;
identifierInfo?: {
type: string;
identifier: string;
};
} }
// interface Platform {} // interface Platform {}
} }
@ -23,7 +27,9 @@ declare global {
var MathJax: any; var MathJax: any;
var nostr: NDKNip07Signer & { var nostr: NDKNip07Signer & {
getRelays: () => Promise<Record<string, Record<string, boolean | undefined>>>; getRelays: () => Promise<
Record<string, Record<string, boolean | undefined>>
>;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
signEvent: (event: any) => Promise<any>; signEvent: (event: any) => Promise<any>;
}; };

10
src/app.html

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -26,14 +26,18 @@
}, },
}; };
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> <script
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
></script>
<!-- highlight.js for code highlighting --> <!-- highlight.js for code highlighting -->
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
/> />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
></script>
%sveltekit.head% %sveltekit.head%
</head> </head>

128
src/lib/components/CommentBox.svelte

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte"; import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
import { UserOutline } from "flowbite-svelte-icons";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
@ -9,7 +10,8 @@
ProfileSearchResult, ProfileSearchResult,
} from "$lib/utils/search_utility"; } from "$lib/utils/search_utility";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
extractRootEventInfo, extractRootEventInfo,
@ -21,13 +23,15 @@
} from "$lib/utils/nostrEventService"; } from "$lib/utils/nostrEventService";
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
userRelayPreference: boolean; userRelayPreference: boolean;
}>(); }>();
const ndk = getNdkContext();
let content = $state(""); let content = $state("");
let preview = $state(""); let preview = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
@ -67,17 +71,12 @@
} }
}); });
// Get user profile from userStore
$effect(() => { $effect(() => {
const trimmedPubkey = $userPubkey?.trim(); const currentUser = $userStore;
const npub = toNpub(trimmedPubkey); if (currentUser?.signedIn && currentUser.profile) {
if (npub) { userProfile = currentUser.profile;
// Call an async function, but don't make the effect itself async error = null;
getUserMetadata(npub).then((metadata) => {
userProfile = metadata;
});
} else if (trimmedPubkey) {
userProfile = null;
error = "Invalid public key: must be a 64-character hex string.";
} else { } else {
userProfile = null; userProfile = null;
error = null; error = null;
@ -177,7 +176,7 @@
success = null; success = null;
try { try {
const pk = $userPubkey || ""; const pk = $userStore.pubkey || "";
const npub = toNpub(pk); const npub = toNpub(pk);
if (!npub) { if (!npub) {
@ -219,7 +218,7 @@
relays = $activeOutboxRelays.slice(0, 3); // Use first 3 outbox relays relays = $activeOutboxRelays.slice(0, 3); // Use first 3 outbox relays
} }
const successfulRelays = await publishEvent(signedEvent, relays); const successfulRelays = await publishEvent(signedEvent, relays, ndk);
success = { success = {
relay: successfulRelays[0] || "Unknown relay", relay: successfulRelays[0] || "Unknown relay",
@ -265,6 +264,30 @@
let communityStatus: Record<string, boolean> = $state({}); let communityStatus: Record<string, boolean> = $state({});
let isSearching = $state(false); let isSearching = $state(false);
// Reactive search with debouncing
$effect(() => {
// Clear existing timeout
if (mentionSearchTimeout) {
clearTimeout(mentionSearchTimeout);
}
// If search is empty, clear results immediately
if (!mentionSearch.trim()) {
mentionResults = [];
communityStatus = {};
mentionLoading = false;
return;
}
// Set loading state immediately for better UX
mentionLoading = true;
// Debounce the search with 300ms delay
mentionSearchTimeout = setTimeout(() => {
searchMentions();
}, 300);
});
async function searchMentions() { async function searchMentions() {
if (!mentionSearch.trim()) { if (!mentionSearch.trim()) {
mentionResults = []; mentionResults = [];
@ -285,7 +308,7 @@
try { try {
console.log("Search promise created, waiting for result..."); console.log("Search promise created, waiting for result...");
const result = await searchProfiles(mentionSearch.trim()); const result = await searchProfiles(mentionSearch.trim(), ndk);
console.log("Search completed, found profiles:", result.profiles.length); console.log("Search completed, found profiles:", result.profiles.length);
console.log("Profile details:", result.profiles); console.log("Profile details:", result.profiles);
console.log("Community status:", result.Status); console.log("Community status:", result.Status);
@ -324,15 +347,15 @@
try { try {
const npub = toNpub(profile.pubkey); const npub = toNpub(profile.pubkey);
if (npub) { if (npub) {
mention = `nostr:${npub}`; mention = `${npub}`;
} else { } else {
// If toNpub fails, fallback to pubkey // If toNpub fails, fallback to pubkey
mention = `nostr:${profile.pubkey}`; mention = `${profile.pubkey}`;
} }
} catch (e) { } catch (e) {
console.error("Error in toNpub:", e); console.error("Error in toNpub:", e);
// Fallback to pubkey if conversion fails // Fallback to pubkey if conversion fails
mention = `nostr:${profile.pubkey}`; mention = `${profile.pubkey}`;
} }
} else { } else {
console.warn("No pubkey in profile, falling back to display name"); console.warn("No pubkey in profile, falling back to display name");
@ -368,12 +391,12 @@
<div class="w-full space-y-4"> <div class="w-full space-y-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each markupButtons as button} {#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button> <Button size="xs" onclick={button.action}>{button.label}</Button>
{/each} {/each}
<Button size="xs" color="alternative" on:click={removeFormatting} <Button size="xs" color="alternative" onclick={removeFormatting}
>Remove Formatting</Button >Remove Formatting</Button
> >
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button> <Button size="xs" color="alternative" onclick={clearForm}>Clear</Button>
</div> </div>
<!-- Mention Modal --> <!-- Mention Modal -->
@ -393,8 +416,9 @@
bind:value={mentionSearch} bind:value={mentionSearch}
bind:this={mentionSearchInput} bind:this={mentionSearchInput}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === "Enter" && mentionSearch.trim() && !isSearching) { if (e.key === "Enter" && mentionSearch.trim()) {
searchMentions(); // The reactive effect will handle the search automatically
e.preventDefault();
} }
}} }}
class="flex-1 rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5" class="flex-1 rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5"
@ -405,9 +429,9 @@
onclick={(e: Event) => { onclick={(e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
searchMentions(); // The reactive effect will handle the search automatically
}} }}
disabled={isSearching || !mentionSearch.trim()} disabled={!mentionSearch.trim()}
> >
{#if isSearching} {#if isSearching}
Searching... Searching...
@ -433,7 +457,22 @@
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3" class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3"
onclick={() => selectMention(profile)} onclick={() => selectMention(profile)}
> >
{#if profile.pubkey && communityStatus[profile.pubkey]} {#if profile.isInUserLists}
<div
class="flex-shrink-0 w-6 h-6 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists"
>
<svg
class="w-4 h-4 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if profile.pubkey && communityStatus[profile.pubkey]}
<div <div
class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community" title="Has posted to the community"
@ -458,13 +497,13 @@
class="w-8 h-8 rounded-full object-cover flex-shrink-0" class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
{:else} {:else}
<div <div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0 flex items-center justify-center">
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" <UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
></div> </div>
{/if} {/if}
<div class="flex flex-col text-left min-w-0 flex-1"> <div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate"> <span class="font-semibold truncate">
{profile.displayName || profile.name || mentionSearch} {profile.displayName || profile.name || "anon"}
</span> </span>
{#if profile.nip05} {#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1"> <span class="text-xs text-gray-500 flex items-center gap-1">
@ -523,12 +562,12 @@
class="mb-4" class="mb-4"
/> />
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button <Button size="xs" color="primary" onclick={insertWikilink}>Insert</Button
> >
<Button <Button
size="xs" size="xs"
color="alternative" color="alternative"
on:click={() => { onclick={() => {
showWikilinkModal = false; showWikilinkModal = false;
}}>Cancel</Button }}>Cancel</Button
> >
@ -556,7 +595,7 @@
<Alert color="red" dismissable> <Alert color="red" dismissable>
{error} {error}
{#if showOtherRelays} {#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)} <Button size="xs" class="mt-2" onclick={() => handleSubmit(true)}
>Try Other Relays</Button >Try Other Relays</Button
> >
{/if} {/if}
@ -564,7 +603,7 @@
<Button <Button
size="xs" size="xs"
class="mt-2" class="mt-2"
on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button onclick={() => handleSubmit(false, true)}>Try Fallback Relays</Button
> >
{/if} {/if}
</Alert> </Alert>
@ -590,26 +629,27 @@
<img <img
src={userProfile.picture} src={userProfile.picture}
alt={userProfile.name || "Profile"} alt={userProfile.name || "Profile"}
class="w-8 h-8 rounded-full" class="w-8 h-8 rounded-full object-cover"
onerror={(e) => { onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
const img = e.target as HTMLImageElement;
img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`;
}}
/> />
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
</div>
{/if} {/if}
<span class="text-gray-900 dark:text-gray-100"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || {userProfile.displayName ||
userProfile.name || userProfile.name ||
nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."} "anon"}
</span> </span>
</div> </div>
{/if} {/if}
<Button <Button
on:click={() => handleSubmit()} onclick={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$userPubkey} disabled={isSubmitting || !content.trim() || !$userStore.pubkey}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !$userPubkey} {#if !$userStore.pubkey}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -619,7 +659,7 @@
</Button> </Button>
</div> </div>
{#if !$userPubkey} {#if !$userStore.pubkey}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your Please sign in to post comments. Your comments will be signed with your
current account. current account.

887
src/lib/components/CommentViewer.svelte

@ -0,0 +1,887 @@
<script lang="ts">
import { Button, P, Heading } from "flowbite-svelte";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { neventEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte";
const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
// AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation
// This component fetches and displays threaded comments with proper hierarchy
// Uses simple, reliable profile fetching and efficient state management
// AI-NOTE: 2025-01-24 - Added support for kind 9802 highlights (NIP-84)
// Highlights are displayed with special styling and include source attribution
// State management
let comments: NDKEvent[] = $state([]);
let loading = $state(false);
let error = $state<string | null>(null);
let profiles = $state(new Map<string, any>());
let activeSub: any = null;
interface CommentNode {
event: NDKEvent;
children: CommentNode[];
level: number;
}
// Simple profile fetching
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Force fetch to ensure we get the latest profile data
const profile = await getUserMetadata(npub, ndk, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
console.log(`[CommentViewer] Fetched profile for ${pubkey}:`, profile);
} catch (err) {
console.warn(`Failed to fetch profile for ${pubkey}:`, err);
// Set a fallback profile to avoid repeated failed requests
const fallbackProfile = {
name: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`,
displayName: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
}
// Fetch comments once when component mounts
async function fetchComments() {
if (!event?.id) return;
loading = true;
error = null;
comments = [];
console.log(`[CommentViewer] Fetching comments for event: ${event.id}`);
console.log(`[CommentViewer] Event kind: ${event.kind}`);
console.log(`[CommentViewer] Event pubkey: ${event.pubkey}`);
console.log(`[CommentViewer] Available relays: ${$activeInboxRelays.length}`);
// Wait for relays to be available
let attempts = 0;
while ($activeInboxRelays.length === 0 && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
if ($activeInboxRelays.length === 0) {
error = "No relays available";
loading = false;
return;
}
try {
// Build address for NIP-22 search if this is a replaceable event
let eventAddress: string | null = null;
if (event.kind && event.pubkey) {
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
}
}
console.log(`[CommentViewer] Event address for NIP-22: ${eventAddress}`);
// Use more targeted filters to reduce noise
const filters = [
// Primary filter: events that explicitly reference our target via e-tags
{
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 50,
}
];
// Add NIP-22 filter only if we have a valid event address
if (eventAddress) {
filters.push({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
} as any);
}
console.log(`[CommentViewer] Setting up subscription with ${filters.length} filters:`, filters);
// Debug: Check if the provided event would match our filters
console.log(`[CommentViewer] Debug: Checking if event b9a15298f2b203d42ba6d0c56c43def87efc887697460c0febb9542515d5a00b would match our filters`);
console.log(`[CommentViewer] Debug: Target event ID: ${event.id}`);
console.log(`[CommentViewer] Debug: Event address: ${eventAddress}`);
// Get all available relays for a more comprehensive search
// Use the full NDK pool relays instead of just active relays
const ndkPoolRelays = Array.from(ndk.pool.relays.values()).map(relay => relay.url);
console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays);
// Try all filters to find comments with full relay set
activeSub = ndk.subscribe(filters);
// Also try a direct search for the specific comment we're looking for
console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`);
const specificCommentSub = ndk.subscribe({
ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"]
});
specificCommentSub.on("event", (specificEvent: NDKEvent) => {
console.log(`[CommentViewer] Found specific comment via direct search:`, specificEvent.id);
console.log(`[CommentViewer] Specific comment tags:`, specificEvent.tags);
// Check if this specific comment references our target
const eTags = specificEvent.getMatchingTags("e");
const aTags = specificEvent.getMatchingTags("a");
console.log(`[CommentViewer] Specific comment e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Specific comment a-tags:`, aTags.map(t => t[1]));
const hasETag = eTags.some(tag => tag[1] === event.id);
const hasATag = eventAddress ? aTags.some(tag => tag[1] === eventAddress) : false;
console.log(`[CommentViewer] Specific comment has matching e-tag: ${hasETag}`);
console.log(`[CommentViewer] Specific comment has matching a-tag: ${hasATag}`);
});
specificCommentSub.on("eose", () => {
console.log(`[CommentViewer] Specific comment search EOSE`);
specificCommentSub.stop();
});
const timeout = setTimeout(() => {
console.log(`[CommentViewer] Subscription timeout - no comments found`);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
}, 10000);
activeSub.on("event", (commentEvent: NDKEvent) => {
console.log(`[CommentViewer] Received comment: ${commentEvent.id}`);
console.log(`[CommentViewer] Comment kind: ${commentEvent.kind}`);
console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`);
console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`);
// Special debug for the specific comment we're looking for
if (commentEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Comment tags:`, commentEvent.tags);
}
// Check if this event actually references our target event
let referencesTarget = false;
let referenceMethod = "";
// Check e-tags (standard format)
const eTags = commentEvent.getMatchingTags("e");
console.log(`[CommentViewer] Checking e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Target event ID: ${event.id}`);
const hasETag = eTags.some(tag => tag[1] === event.id);
console.log(`[CommentViewer] Has matching e-tag: ${hasETag}`);
if (hasETag) {
referencesTarget = true;
referenceMethod = "e-tag";
}
// Check a-tags (NIP-22 format) if not found via e-tags
if (!referencesTarget && eventAddress) {
const aTags = commentEvent.getMatchingTags("a");
console.log(`[CommentViewer] Checking a-tags:`, aTags.map(t => t[1]));
console.log(`[CommentViewer] Expected a-tag: ${eventAddress}`);
const hasATag = aTags.some(tag => tag[1] === eventAddress);
console.log(`[CommentViewer] Has matching a-tag: ${hasATag}`);
if (hasATag) {
referencesTarget = true;
referenceMethod = "a-tag";
}
}
if (referencesTarget) {
console.log(`[CommentViewer] Comment references target event via ${referenceMethod} - adding to comments`);
comments = [...comments, commentEvent];
fetchProfile(commentEvent.pubkey);
// Fetch nested replies for this comment
fetchNestedReplies(commentEvent.id);
} else {
console.log(`[CommentViewer] Comment does not reference target event - skipping`);
console.log(`[CommentViewer] e-tags:`, eTags.map(t => t[1]));
if (eventAddress) {
console.log(`[CommentViewer] a-tags:`, commentEvent.getMatchingTags("a").map(t => t[1]));
console.log(`[CommentViewer] expected a-tag:`, eventAddress);
}
}
});
activeSub.on("eose", () => {
console.log(`[CommentViewer] EOSE received, found ${comments.length} comments`);
clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
// Pre-fetch all profiles after comments are loaded
preFetchAllProfiles();
// AI-NOTE: 2025-01-24 - Fetch nested replies for all found comments
comments.forEach(comment => {
fetchNestedReplies(comment.id);
});
// AI-NOTE: 2025-01-24 - Test for comments if none were found
if (comments.length === 0) {
testForComments();
}
});
activeSub.on("error", (err: any) => {
console.error(`[CommentViewer] Subscription error:`, err);
clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
error = "Error fetching comments";
loading = false;
});
} catch (err) {
console.error(`[CommentViewer] Error setting up subscription:`, err);
error = "Error setting up subscription";
loading = false;
}
}
// Pre-fetch all profiles for comments
async function preFetchAllProfiles() {
const uniquePubkeys = new Set<string>();
comments.forEach(comment => {
if (comment.pubkey && !profiles.has(comment.pubkey)) {
uniquePubkeys.add(comment.pubkey);
}
});
console.log(`[CommentViewer] Pre-fetching ${uniquePubkeys.size} profiles`);
// Fetch profiles in parallel
const profilePromises = Array.from(uniquePubkeys).map(pubkey => fetchProfile(pubkey));
await Promise.allSettled(profilePromises);
console.log(`[CommentViewer] Pre-fetching complete`);
}
// AI-NOTE: 2025-01-24 - Function to manually test for comments
async function testForComments() {
if (!event?.id) return;
console.log(`[CommentViewer] Testing for comments on event: ${event.id}`);
try {
// Try a broader search to see if there are any events that might be comments
const testSub = ndk.subscribe({
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 10,
});
let testComments = 0;
testSub.on("event", (testEvent: NDKEvent) => {
testComments++;
console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}, content: ${testEvent.content?.slice(0, 50)}...`);
// Special debug for the specific comment we're looking for
if (testEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Test found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Test comment tags:`, testEvent.tags);
}
// Show the e-tags to help debug
const eTags = testEvent.getMatchingTags("e");
console.log(`[CommentViewer] Test event e-tags:`, eTags.map(t => t[1]));
});
testSub.on("eose", () => {
console.log(`[CommentViewer] Test search found ${testComments} potential comments`);
testSub.stop();
});
// Stop the test after 5 seconds
setTimeout(() => {
testSub.stop();
}, 5000);
} catch (err) {
console.error(`[CommentViewer] Test search error:`, err);
}
}
// Build threaded comment structure
function buildCommentThread(events: NDKEvent[]): CommentNode[] {
if (events.length === 0) return [];
const eventMap = new Map<string, NDKEvent>();
const commentMap = new Map<string, CommentNode>();
const rootComments: CommentNode[] = [];
// Create nodes for all events
events.forEach(event => {
eventMap.set(event.id, event);
commentMap.set(event.id, {
event,
children: [],
level: 0
});
});
// Build parent-child relationships
events.forEach(event => {
const node = commentMap.get(event.id);
if (!node) return;
let parentId: string | null = null;
const eTags = event.getMatchingTags("e");
if (event.kind === 1) {
// Kind 1: Look for the last e-tag that references another comment
for (let i = eTags.length - 1; i >= 0; i--) {
const tag = eTags[i];
const referencedId = tag[1];
if (eventMap.has(referencedId) && referencedId !== event.id) {
parentId = referencedId;
break;
}
}
} else if (event.kind === 1111) {
// Kind 1111: Use NIP-22 threading format
// First try to find parent using 'a' tags (NIP-22 parent scope)
const aTags = event.getMatchingTags("a");
for (const tag of aTags) {
const address = tag[1];
// Extract event ID from address if it's a coordinate
const parts = address.split(":");
if (parts.length >= 3) {
const [kind, pubkey, dTag] = parts;
// Look for the parent event with this address
for (const [eventId, parentEvent] of eventMap) {
if (parentEvent.kind === parseInt(kind) &&
parentEvent.pubkey === pubkey &&
parentEvent.getMatchingTags("d")[0]?.[1] === dTag) {
parentId = eventId;
break;
}
}
if (parentId) break;
}
}
// Fallback to 'e' tags if no parent found via 'a' tags
if (!parentId) {
for (const tag of eTags) {
const referencedId = tag[1];
if (eventMap.has(referencedId) && referencedId !== event.id) {
parentId = referencedId;
break;
}
}
}
}
// Add to parent or root
if (parentId && commentMap.has(parentId)) {
const parent = commentMap.get(parentId);
if (parent) {
parent.children.push(node);
node.level = parent.level + 1;
}
} else {
rootComments.push(node);
}
});
// Sort by creation time (newest first)
function sortComments(nodes: CommentNode[]): CommentNode[] {
return nodes.sort((a, b) => (b.event.created_at || 0) - (a.event.created_at || 0));
}
function sortRecursive(nodes: CommentNode[]): CommentNode[] {
const sorted = sortComments(nodes);
sorted.forEach(node => {
node.children = sortRecursive(node.children);
});
return sorted;
}
return sortRecursive(rootComments);
}
// Derived value for threaded comments
let threadedComments = $derived(buildCommentThread(comments));
// Fetch comments when event changes
$effect(() => {
if (event?.id) {
console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
fetchComments();
}
});
// AI-NOTE: 2025-01-24 - Add recursive comment fetching for nested replies
let isFetchingNestedReplies = $state(false);
let nestedReplyIds = $state<Set<string>>(new Set());
// Function to fetch nested replies for a given event
async function fetchNestedReplies(eventId: string) {
if (isFetchingNestedReplies || nestedReplyIds.has(eventId)) {
console.log(`[CommentViewer] Skipping nested reply fetch for ${eventId} - already fetching or processed`);
return;
}
console.log(`[CommentViewer] Starting nested reply fetch for event: ${eventId}`);
isFetchingNestedReplies = true;
nestedReplyIds.add(eventId);
try {
console.log(`[CommentViewer] Fetching nested replies for event:`, eventId);
// Search for replies to this specific event
const nestedSub = ndk.subscribe({
kinds: [1, 1111, 9802],
"#e": [eventId],
limit: 50,
});
let nestedCount = 0;
nestedSub.on("event", (nestedEvent: NDKEvent) => {
console.log(`[CommentViewer] Found nested reply:`, nestedEvent.id, `kind:`, nestedEvent.kind);
// Check if this event actually references the target event
const eTags = nestedEvent.getMatchingTags("e");
const referencesTarget = eTags.some(tag => tag[1] === eventId);
console.log(`[CommentViewer] Nested reply references target:`, referencesTarget, `eTags:`, eTags);
if (referencesTarget && !comments.some(c => c.id === nestedEvent.id)) {
console.log(`[CommentViewer] Adding nested reply to comments`);
comments = [...comments, nestedEvent];
fetchProfile(nestedEvent.pubkey);
// Recursively fetch replies to this nested reply
fetchNestedReplies(nestedEvent.id);
} else if (!referencesTarget) {
console.log(`[CommentViewer] Nested reply does not reference target, skipping`);
} else {
console.log(`[CommentViewer] Nested reply already exists in comments`);
}
});
nestedSub.on("eose", () => {
console.log(`[CommentViewer] Nested replies EOSE, found ${nestedCount} replies`);
nestedSub.stop();
isFetchingNestedReplies = false;
});
// Also search for NIP-22 format nested replies
const event = comments.find(c => c.id === eventId);
if (event && event.kind && event.pubkey) {
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
const eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
const nip22Sub = ndk.subscribe({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
});
nip22Sub.on("event", (nip22Event: NDKEvent) => {
console.log(`[CommentViewer] Found NIP-22 nested reply:`, nip22Event.id, `kind:`, nip22Event.kind);
const aTags = nip22Event.getMatchingTags("a");
const referencesTarget = aTags.some(tag => tag[1] === eventAddress);
console.log(`[CommentViewer] NIP-22 nested reply references target:`, referencesTarget, `aTags:`, aTags, `eventAddress:`, eventAddress);
if (referencesTarget && !comments.some(c => c.id === nip22Event.id)) {
console.log(`[CommentViewer] Adding NIP-22 nested reply to comments`);
comments = [...comments, nip22Event];
fetchProfile(nip22Event.pubkey);
// Recursively fetch replies to this nested reply
fetchNestedReplies(nip22Event.id);
} else if (!referencesTarget) {
console.log(`[CommentViewer] NIP-22 nested reply does not reference target, skipping`);
} else {
console.log(`[CommentViewer] NIP-22 nested reply already exists in comments`);
}
});
nip22Sub.on("eose", () => {
console.log(`[CommentViewer] NIP-22 nested replies EOSE`);
nip22Sub.stop();
});
}
}
} catch (err) {
console.error(`[CommentViewer] Error fetching nested replies:`, err);
isFetchingNestedReplies = false;
}
}
// Cleanup on unmount
onMount(() => {
return () => {
if (activeSub) {
activeSub.stop();
activeSub = null;
}
};
});
// Navigation functions
function getNeventUrl(commentEvent: NDKEvent): string {
try {
console.log(`[CommentViewer] Generating nevent for:`, commentEvent.id, `kind:`, commentEvent.kind);
const nevent = neventEncode(commentEvent, $activeInboxRelays);
console.log(`[CommentViewer] Generated nevent:`, nevent);
return nevent;
} catch (error) {
console.error(`[CommentViewer] Error generating nevent:`, error);
// Fallback to just the event ID
return commentEvent.id;
}
}
// AI-NOTE: 2025-01-24 - View button functionality is working correctly
// This function navigates to the specific event as the main event, allowing
// users to view replies as the primary content
function navigateToComment(commentEvent: NDKEvent) {
try {
const nevent = getNeventUrl(commentEvent);
console.log(`[CommentViewer] Navigating to comment:`, nevent);
goto(`/events?id=${encodeURIComponent(nevent)}`);
} catch (error) {
console.error(`[CommentViewer] Error navigating to comment:`, error);
// Fallback to event ID
goto(`/events?id=${commentEvent.id}`);
}
}
// Utility functions
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString();
}
function formatRelativeDate(timestamp: number): string {
const now = Date.now();
const date = timestamp * 1000;
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds} seconds ago`;
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
}
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 4) {
return `${diffInWeeks} week${diffInWeeks !== 1 ? 's' : ''} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} month${diffInMonths !== 1 ? 's' : ''} ago`;
}
const diffInYears = Math.floor(diffInDays / 365);
return `${diffInYears} year${diffInYears !== 1 ? 's' : ''} ago`;
}
function shortenNevent(nevent: string): string {
if (nevent.length <= 20) return nevent;
return nevent.slice(0, 10) + "…" + nevent.slice(-10);
}
function getAuthorName(pubkey: string): string {
const profile = profiles.get(pubkey);
if (profile?.displayName) return profile.displayName;
if (profile?.name) return profile.name;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
function getAuthorPicture(pubkey: string): string | null {
const profile = profiles.get(pubkey);
return profile?.picture || null;
}
function getIndentation(level: number): string {
const maxLevel = 5;
const actualLevel = Math.min(level, maxLevel);
return `${actualLevel * 16}px`;
}
// AI-NOTE: 2025-01-24 - Get highlight source information
function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null {
// Check for e-tags (nostr events)
const eTags = highlightEvent.getMatchingTags("e");
if (eTags.length > 0) {
return { type: "nostr_event", value: eTags[0][1] };
}
// Check for r-tags (URLs)
const rTags = highlightEvent.getMatchingTags("r");
if (rTags.length > 0) {
return { type: "url", value: rTags[0][1], url: rTags[0][1] };
}
return null;
}
// AI-NOTE: 2025-01-24 - Get highlight attribution
function getHighlightAttribution(highlightEvent: NDKEvent): Array<{ pubkey: string; role?: string }> {
const pTags = highlightEvent.getMatchingTags("p");
return pTags.map(tag => ({
pubkey: tag[1],
role: tag[3] || undefined
}));
}
// AI-NOTE: 2025-01-24 - Check if highlight has comment
function hasHighlightComment(highlightEvent: NDKEvent): boolean {
return highlightEvent.getMatchingTags("comment").length > 0;
}
</script>
<!-- Recursive Comment Item Component -->
{#snippet CommentItem(node: CommentNode)}
<div class="mb-4">
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 break-words"
style="margin-left: {getIndentation(node.level)};"
>
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2">
<button
class="cursor-pointer"
onclick={() => goto(`/events?n=${toNpub(node.event.pubkey)}`)}
>
{#if getAuthorPicture(node.event.pubkey)}
<img
src={getAuthorPicture(node.event.pubkey)}
alt={getAuthorName(node.event.pubkey)}
class="w-8 h-8 rounded-full object-cover hover:opacity-80 transition-opacity"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center hover:opacity-80 transition-opacity">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{getAuthorName(node.event.pubkey).charAt(0).toUpperCase()}
</span>
</div>
{/if}
</button>
<div class="flex flex-col min-w-0">
<button
class="font-medium text-gray-900 dark:text-white truncate hover:underline cursor-pointer text-left"
onclick={() => goto(`/events?n=${toNpub(node.event.pubkey)}`)}
>
{getAuthorName(node.event.pubkey)}
</button>
<span
class="text-sm text-gray-500 cursor-help"
title={formatDate(node.event.created_at || 0)}
>
{formatRelativeDate(node.event.created_at || 0)} • Kind: {node.event.kind}
</span>
</div>
</div>
<div class="flex items-center space-x-2 flex-shrink-0">
<span class="text-sm text-gray-600 dark:text-gray-300 truncate max-w-32">
{shortenNevent(getNeventUrl(node.event))}
</span>
<Button
size="xs"
color="light"
onclick={() => navigateToComment(node.event)}
>
View
</Button>
</div>
</div>
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words overflow-hidden">
{#if node.event.kind === 9802}
<!-- Highlight rendering -->
<div class="highlight-container bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-3 rounded-r">
{#if hasHighlightComment(node.event)}
<!-- Quote highlight with comment -->
<div class="highlight-quote bg-gray-50 dark:bg-gray-800 p-3 rounded mb-3 border-l-4 border-blue-400">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Highlighted content:</span>
</div>
{#if node.event.getMatchingTags("context")[0]?.[1]}
<div class="highlight-context">
{@html node.event.getMatchingTags("context")[0]?.[1]}
</div>
{:else}
<div class="highlight-content text-gray-800 dark:text-gray-200">
{node.event.content || ""}
</div>
{/if}
{#if getHighlightSource(node.event)}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'}
</div>
{/if}
</div>
<div class="highlight-comment">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Comment:</span>
</div>
<EmbeddedEvent nostrIdentifier={node.event.getMatchingTags("comment")[0]?.[1]} nestingLevel={0} />
</div>
{:else}
<!-- Simple highlight -->
{#if node.event.getMatchingTags("context")[0]?.[1]}
<div class="highlight-context">
{@html node.event.getMatchingTags("context")[0]?.[1]}
</div>
{:else}
<div class="highlight-content">
{node.event.content || ""}
</div>
{/if}
{#if getHighlightSource(node.event)}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'}
</div>
{/if}
{/if}
{#if getHighlightAttribution(node.event).length > 0}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span class="font-medium">Attribution:</span>
{#each getHighlightAttribution(node.event) as attribution}
<button
class="ml-1 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
onclick={() => goto(`/events?n=${toNpub(attribution.pubkey)}`)}
>
{getAuthorName(attribution.pubkey)}
{#if attribution.role}
<span class="text-gray-400">({attribution.role})</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Regular comment content -->
<EmbeddedEvent nostrIdentifier={node.event.id} nestingLevel={0} />
{/if}
</div>
</div>
{#if node.children.length > 0}
<div class="space-y-4">
{#each node.children as childNode, index (childNode.event.id + '-' + index)}
{@render CommentItem(childNode)}
{/each}
</div>
{/if}
</div>
{/snippet}
<div class="mt-6">
<Heading tag="h3" class="h-leather mb-4">
Comments & Highlights ({threadedComments.length})
</Heading>
{#if loading}
<div class="text-center py-4">
<P>Loading comments...</P>
</div>
{:else if error}
<div class="text-center py-4">
<P class="text-red-600">{error}</P>
</div>
{:else if threadedComments.length === 0}
<div class="text-center py-4">
<P class="text-gray-500">No comments or highlights yet. Be the first to engage!</P>
</div>
{:else}
<div class="space-y-4">
{#each threadedComments as node, index (node.event.id + '-root-' + index)}
{@render CommentItem(node)}
{/each}
</div>
{/if}
</div>
<style>
/* Highlight styles */
.highlight-container {
position: relative;
}
.highlight-content {
font-style: italic;
background: linear-gradient(transparent 0%, transparent 40%, rgba(255, 255, 0, 0.3) 40%, rgba(255, 255, 0, 0.3) 100%);
}
.highlight-quote {
position: relative;
}
.highlight-quote::before {
content: '"';
position: absolute;
top: -5px;
left: -10px;
font-size: 2rem;
color: #3b82f6;
opacity: 0.5;
}
.highlight-context {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
</style>

288
src/lib/components/EventDetails.svelte

@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { getMimeTags } from "$lib/utils/mime"; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte"; import ProfileHeader from "$components/cards/ProfileHeader.svelte";
@ -14,30 +13,21 @@
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { navigateToEvent } from "$lib/utils/nostrEventService"; import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte";
import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte";
import type { UserProfile } from "$lib/models/user_profile";
const { const {
event, event,
profile = null, profile = null,
searchValue = null,
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
profile?: { profile?: UserProfile | null;
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null;
searchValue?: string | null;
}>(); }>();
let showFullContent = $state(false);
let parsedContent = $state("");
let contentPreview = $state("");
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
let showFullContent = $state(false);
let shouldTruncate = $derived(event.content.length > 250 && !showFullContent);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag // First try to get title from title tag
@ -79,109 +69,11 @@
return getMatchingTags(event, "summary")[0]?.[1] || ""; return getMatchingTags(event, "summary")[0]?.[1] || "";
} }
function getEventHashtags(event: NDKEvent): string[] {
return getMatchingTags(event, "t").map((tag: string[]) => tag[1]);
}
function getEventTypeDisplay(event: NDKEvent): string { function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0); const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`; return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
} }
function renderTag(tag: string[]): string {
if (tag[0] === "a" && tag.length > 1) {
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode naddr for a tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn("Invalid pubkey in a tag in renderTag:", pubkey);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn("Invalid a tag format in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode nevent for e tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else {
console.warn("Invalid event ID in e tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode nevent for note tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else {
console.warn("Invalid event ID in note tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return `<a href='/events?d=${encodeURIComponent(tag[1])}' class='underline text-primary-700'>d:${tag[1]}</a>`;
} else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`;
}
}
function getTagButtonInfo(tag: string[]): { function getTagButtonInfo(tag: string[]): {
text: string; text: string;
gotoValue?: string; gotoValue?: string;
@ -290,33 +182,13 @@
return { text: `${tag[0]}:${tag[1]}` }; return { text: `${tag[0]}:${tag[1]}` };
} }
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function getNprofileUrl(pubkey: string): string {
return nprofileEncode(pubkey, $activeInboxRelays);
}
$effect(() => {
if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then((html) => {
parsedContent = html;
contentPreview = html.slice(0, 250);
});
}
});
$effect(() => { $effect(() => {
if (!event?.pubkey) { if (!event?.pubkey) {
authorDisplayName = undefined; authorDisplayName = undefined;
return; return;
} }
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
getUserMetadata(toNpub(event.pubkey) as string, undefined).then((profile) => {
authorDisplayName = authorDisplayName =
profile.displayName || profile.displayName ||
(profile as any).display_name || (profile as any).display_name ||
@ -364,19 +236,12 @@
const naddr = naddrEncode(event, $activeInboxRelays); const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id // hex id - make it a clickable link to search for the event ID
ids.push({ label: "id", value: event.id }); ids.push({ label: "id", value: event.id, link: `/events?id=${event.id}` });
} }
return ids; return ids;
} }
function isCurrentSearch(value: string): boolean {
if (!searchValue) return false;
// Compare ignoring case and possible nostr: prefix
const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase();
return norm(value) === norm(searchValue);
}
onMount(() => { onMount(() => {
function handleInternalLinkClick(event: MouseEvent) { function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@ -393,55 +258,45 @@
}); });
</script> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4 min-w-0">
{#if event.kind !== 0 && getEventTitle(event)} {#if event.kind !== 0 && getEventTitle(event)}
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100"> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words">
{getEventTitle(event)} {getEventTitle(event)}
</h2> </h2>
{/if} {/if}
<div class="flex items-center space-x-2"> <!-- Notifications (for profile events) -->
{#if event.kind === 0}
<Notifications {event} />
{/if}
<div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400" <span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile?.display_name || event.pubkey, profile?.display_name || undefined,
)}</span )}</span
> >
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400" <span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span >Author: {profile?.display_name || event.pubkey}</span
> >
{/if} {/if}
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Kind:</span> <span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono">{event.kind}</span> <span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300" <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span >({getEventTypeDisplay(event)})</span
> >
</div> </div>
{#if getEventSummary(event)} {#if getEventSummary(event)}
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span> <span class="text-gray-700 dark:text-gray-300">Summary:</span>
<p class="text-gray-900 dark:text-gray-100">{getEventSummary(event)}</p> <p class="text-gray-900 dark:text-gray-100 break-words">{getEventSummary(event)}</p>
</div>
{/if}
{#if getEventHashtags(event).length}
<div class="flex flex-col space-y-1">
<span class="text-gray-700 dark:text-gray-300">Tags:</span>
<div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag}
<button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium hover:bg-primary-200 cursor-pointer"
>#{tag}</button
>
{/each}
</div>
</div> </div>
{/if} {/if}
@ -449,35 +304,79 @@
<ContainingIndexes {event} /> <ContainingIndexes {event} />
<!-- Content --> <!-- Content -->
<div class="flex flex-col space-y-1">
{#if event.kind !== 0} {#if event.kind !== 0}
<span class="text-gray-700 dark:text-gray-300">Content:</span> <div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden">
<div class="prose dark:prose-invert max-w-none"> <div class="flex flex-col space-y-1 min-w-0">
{@html showFullContent ? parsedContent : contentPreview} <span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
{#if !showFullContent && parsedContent.length > 250} <div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<EmbeddedEvent nostrIdentifier={event.id} nestingLevel={0} />
</div>
{#if shouldTruncate}
<button <button
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200" class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
onclick={() => (showFullContent = true)}>Show more</button onclick={() => (showFullContent = true)}>Show more</button
> >
{/if} {/if}
</div> </div>
{/if}
</div> </div>
</div>
{/if}
<!-- If event is profile --> <!-- If event is profile -->
{#if event.kind === 0} {#if event.kind === 0}
<ProfileHeader <ProfileHeader
{event} {event}
{profile} {profile}
identifiers={getIdentifiers(event, profile)}
/> />
{/if} {/if}
<!-- Tags Array --> <!-- Raw Event JSON -->
<details
class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4 overflow-hidden"
>
<summary
class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"
>
Show details
</summary>
<!-- Identifiers Section -->
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Identifiers:</h4>
<div class="flex flex-col gap-2 min-w-0">
{#each getIdentifiers(event, profile) as identifier}
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-600 dark:text-gray-400 flex-shrink-0">{identifier.label}:</span>
<div class="flex-1 min-w-0 flex items-center gap-2">
{#if identifier.link}
<a
href={identifier.link}
class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer"
title={identifier.value}
>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)}
</a>
{:else}
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all" title={identifier.value}>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)}
</span>
{/if}
<CopyToClipboard
displayText=""
copyText={identifier.value}
/>
</div>
</div>
{/each}
</div>
</div>
<!-- Event Tags Section -->
{#if event.tags && event.tags.length} {#if event.tags && event.tags.length}
<div class="flex flex-col space-y-1"> <div class="mb-4 max-w-full overflow-hidden">
<span class="text-gray-700 dark:text-gray-300">Event Tags:</span> <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Event Tags:</h4>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2 break-words min-w-0">
{#each event.tags as tag} {#each event.tags as tag}
{@const tagInfo = getTagButtonInfo(tag)} {@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue} {#if tagInfo.text && tagInfo.gotoValue}
@ -512,7 +411,7 @@
goto(`/events?id=${tagInfo.gotoValue!}`); goto(`/events?id=${tagInfo.gotoValue!}`);
} }
}} }}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100" class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100 break-all max-w-full"
> >
{tagInfo.text} {tagInfo.text}
</button> </button>
@ -522,25 +421,22 @@
</div> </div>
{/if} {/if}
<!-- Raw Event JSON --> <!-- Raw Event JSON Section -->
<details <div class="mb-4 max-w-full overflow-hidden">
class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4" <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Raw Event JSON:</h4>
> <div class="relative min-w-0">
<summary <div class="absolute top-0 right-0 z-10">
class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"
>
Show Raw Event JSON
</summary>
<div class="absolute top-4 right-4">
<CopyToClipboard <CopyToClipboard
displayText="" displayText=""
copyText={JSON.stringify(event.rawEvent(), null, 2)} copyText={JSON.stringify(event.rawEvent(), null, 2)}
/> />
</div> </div>
<pre <pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono" class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono break-words whitespace-pre-wrap min-w-0"
style="line-height: 1.7; font-size: 1rem;"> style="line-height: 1.7; font-size: 1rem;">
{JSON.stringify(event.rawEvent(), null, 2)} {JSON.stringify(event.rawEvent(), null, 2)}
</pre> </pre>
</div>
</div>
</details> </details>
</div> </div>

809
src/lib/components/EventInput.svelte

@ -1,54 +1,48 @@
<script lang="ts"> <script lang="ts">
import {
getTitleTagForEvent,
getDTagForEvent,
requiresDTag,
validateNotAsciidoc,
validateAsciiDoc,
build30040EventSet,
titleToDTag,
validate30040EventSet,
get30040EventDescription,
analyze30040Event,
get30040FixGuidance,
} from "$lib/utils/event_input_utils";
import {
extractDocumentMetadata,
extractSmartMetadata,
metadataToTags,
removeMetadataFromContent
} from "$lib/utils/asciidoc_metadata";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { Button } from "flowbite-svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { Button } from "flowbite-svelte";
import EventForm from "./event_input/EventForm.svelte";
import TagManager from "./event_input/TagManager.svelte";
import EventPreview from "./event_input/EventPreview.svelte";
import type { EventData, TagData } from "./event_input/types";
import { publishEvent, loadEvent } from "./event_input/eventServices";
import { getNdkContext } from "$lib/ndk";
// AI-NOTE: 2025-01-24 - Main EventInput component refactored for better separation of concerns
// This component now serves as a container that orchestrates the form, tags, preview, and publishing
// Get NDK context at component level (can only be called during initialization)
const ndk = getNdkContext();
// Main event state
let eventData = $state<EventData>({
kind: 1,
content: "",
createdAt: Math.floor(Date.now() / 1000),
});
// Tag state
let tags = $state<TagData[]>([]);
let kind = $state<number>(30040); // UI state
let tags = $state<[string, string][]>([]);
let content = $state("");
let createdAt = $state<number>(Math.floor(Date.now() / 1000));
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let success = $state<string | null>(null); let success = $state<string | null>(null);
let publishedRelays = $state<string[]>([]); let showJsonPreview = $state(false);
let title = $state(""); // Publishing state
let dTag = $state(""); let publishedRelays = $state<string[]>([]);
let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false);
let dTagError = $state("");
let lastPublishedEventId = $state<string | null>(null); let lastPublishedEventId = $state<string | null>(null);
let showWarning = $state(false);
let warningMessage = $state(""); // Event loading state
let pendingPublish = $state(false); let eventIdSearch = $state("");
let extractedMetadata = $state<[string, string][]>([]); let eventJsonInput = $state("");
let loadingEvent = $state(false);
let loadMethod = $state<'hex' | 'json'>('hex');
// Session storage loading
let hasLoadedFromStorage = $state(false); let hasLoadedFromStorage = $state(false);
// Load content from sessionStorage if available (from ZettelEditor) // Load content from sessionStorage if available (from ZettelEditor)
@ -59,595 +53,325 @@
const storedSource = sessionStorage.getItem('zettelEditorSource'); const storedSource = sessionStorage.getItem('zettelEditorSource');
if (storedContent && storedSource === 'publication-format') { if (storedContent && storedSource === 'publication-format') {
content = storedContent; eventData.content = storedContent;
hasLoadedFromStorage = true; hasLoadedFromStorage = true;
// Clear the stored content after loading // Clear the stored content after loading
sessionStorage.removeItem('zettelEditorContent'); sessionStorage.removeItem('zettelEditorContent');
sessionStorage.removeItem('zettelEditorSource'); sessionStorage.removeItem('zettelEditorSource');
// Extract title and metadata using the standardized parser
const { metadata } = extractSmartMetadata(content);
if (metadata.title) {
title = metadata.title;
titleManuallyEdited = false;
dTagManuallyEdited = false;
}
// Extract metadata for 30040 and 30041 events
if (kind === 30040 || kind === 30041) {
extractedMetadata = metadataToTags(metadata);
}
} }
}); });
/** /**
* Extracts the first Markdown/AsciiDoc header as the title using the standardized parser. * Handles form validation
*/ */
function extractTitleFromContent(content: string): string { function handleValidate(isValid: boolean, error?: string, warning?: string) {
const { metadata } = extractSmartMetadata(content); if (!isValid && error) {
return metadata.title || ""; // Validation failed - error is already shown in EventForm
}
function handleContentInput(e: Event) {
content = (e.target as HTMLTextAreaElement).value;
// Extract title and metadata using the standardized parser
const { metadata } = extractSmartMetadata(content);
if (!titleManuallyEdited) {
console.log("Content input - extracted title:", metadata.title);
title = metadata.title || "";
// Reset dTagManuallyEdited when title changes so d-tag can be auto-generated
dTagManuallyEdited = false;
}
// Extract metadata from AsciiDoc content for 30040 and 30041 events
if (kind === 30040 || kind === 30041) {
extractedMetadata = metadataToTags(metadata);
} else {
extractedMetadata = [];
}
}
function handleTitleInput(e: Event) {
title = (e.target as HTMLInputElement).value;
titleManuallyEdited = true;
}
function handleDTagInput(e: Event) {
dTag = (e.target as HTMLInputElement).value;
dTagManuallyEdited = true;
}
$effect(() => {
console.log(
"Effect running - title:",
title,
"dTagManuallyEdited:",
dTagManuallyEdited,
);
if (!dTagManuallyEdited) {
const newDTag = titleToDTag(title);
console.log("Setting dTag to:", newDTag);
dTag = newDTag;
}
});
function updateTag(index: number, key: string, value: string): void {
tags = tags.map((t, i) => (i === index ? [key, value] : t));
}
function addTag(): void {
tags = [...tags, ["", ""]];
}
function removeTag(index: number): void {
tags = tags.filter((_, i) => i !== index);
}
function addExtractedTag(key: string, value: string): void {
// Check if tag already exists
const existingIndex = tags.findIndex(([k]) => k === key);
if (existingIndex >= 0) {
// Update existing tag
tags = tags.map((t, i) => (i === existingIndex ? [key, value] : t));
} else {
// Add new tag
tags = [...tags, [key, value]];
}
}
function isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
function validate(): { valid: boolean; reason?: string; warning?: string } {
const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore);
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!pubkey) return { valid: false, reason: "Not logged in." };
if (!content.trim()) return { valid: false, reason: "Content required." };
if (kind === 30023) {
const v = validateNotAsciidoc(content);
if (!v.valid) return v;
}
if (kind === 30040) {
const v = validate30040EventSet(content);
if (!v.valid) return v;
if (v.warning) return { valid: true, warning: v.warning };
}
if (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content);
if (!v.valid) return v;
}
return { valid: true };
}
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = "";
error = null; // Clear any previous errors
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) {
dTagError = "A d-tag is required.";
return;
}
const validation = validate();
if (!validation.valid) {
error = validation.reason || "Validation failed.";
return; return;
} }
if (validation.warning) { if (warning) {
warningMessage = validation.warning; // Validation passed with warning - user can proceed
showWarning = true; console.log("Validation warning:", warning);
pendingPublish = true;
return;
} }
handlePublish(); // Validation passed - form is ready for publishing
console.log("Form validation passed");
} }
async function handlePublish(): Promise<void> { /**
* Handles the publishing process
*/
async function handlePublish() {
loading = true;
error = null; error = null;
success = null; success = null;
publishedRelays = []; publishedRelays = [];
loading = true;
createdAt = Math.floor(Date.now() / 1000);
try { try {
const ndk = get(ndkInstance); const result = await publishEvent(ndk, eventData, tags);
const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore);
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!ndk || !pubkey) {
error = "NDK or pubkey missing.";
loading = false;
return;
}
const pubkeyString = String(pubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { if (result.success) {
error = "Invalid public key: must be a 64-character hex string."; publishedRelays = result.relays || [];
lastPublishedEventId = result.eventId || null;
success = `Published to ${result.relays?.length || 0} relay(s).`;
} else {
error = result.error || "Failed to publish event.";
}
} catch (err) {
console.error("Error in handlePublish:", err);
error = `Publishing failed: ${err instanceof Error ? err.message : "Unknown error"}`;
} finally {
loading = false; loading = false;
return; }
} }
// Validate before proceeding /**
const validation = validate(); * Loads an event by its hex ID for editing
if (!validation.valid) { */
error = validation.reason || "Validation failed."; async function loadEventById(): Promise<void> {
loading = false; if (!eventIdSearch.trim()) {
error = "Please enter an event ID.";
return; return;
} }
const baseEvent = { pubkey: pubkeyString, created_at: createdAt }; const eventId = eventIdSearch.trim();
let events: NDKEvent[] = [];
console.log("Publishing event with kind:", kind); // Validate hex format
console.log("Content length:", content.length); if (!/^[a-fA-F0-9]{64}$/.test(eventId)) {
console.log("Content preview:", content.substring(0, 100)); error = "Invalid event ID format. Must be a 64-character hex string.";
console.log("Tags:", tags);
console.log("Title:", title);
console.log("DTag:", dTag);
if (Number(kind) === 30040) {
console.log("=== 30040 EVENT CREATION START ===");
console.log("Creating 30040 event set with content:", content);
try {
const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
console.log("Index event:", indexEvent);
console.log("Section events:", sectionEvents);
// Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent];
console.log("Total events to publish:", events.length);
// Debug the index event to ensure it's correct
const indexEventData = {
content: indexEvent.content,
tags: indexEvent.tags.map(
(tag) => [tag[0], tag[1]] as [string, string],
),
kind: indexEvent.kind || 30040,
};
const analysis = debug30040Event(indexEventData);
if (!analysis.valid) {
console.warn("30040 index event has issues:", analysis.issues);
}
console.log("=== 30040 EVENT CREATION END ===");
} catch (error) {
console.error("Error in build30040EventSet:", error);
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`;
loading = false;
return; return;
} }
} else {
let eventTags = [...tags];
// Ensure d-tag exists and has a value for addressable events loadingEvent = true;
if (requiresDTag(kind)) { error = null;
const dTagIndex = eventTags.findIndex(([k]) => k === "d");
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, ""); try {
const loadedEvent = await loadEvent(ndk, eventId);
if (dTagValue) { if (loadedEvent) {
if (dTagIndex >= 0) { eventData = loadedEvent.eventData;
// Update existing d-tag tags = loadedEvent.tags;
eventTags[dTagIndex] = ["d", dTagValue]; success = `Loaded event ${eventId.substring(0, 8)}...`;
} else { } else {
// Add new d-tag error = `Event ${eventId} not found on any relay.`;
eventTags = [...eventTags, ["d", dTagValue]];
}
} }
} catch (err) {
console.error("Error loading event:", err);
error = `Failed to load event: ${err instanceof Error ? err.message : "Unknown error"}`;
} finally {
loadingEvent = false;
} }
// Add title tag if we have a title
const titleValue = title.trim() || getTitleTagForEvent(kind, content);
if (titleValue) {
eventTags = [...eventTags, ["title", titleValue]];
} }
// For AsciiDoc events, remove metadata from content /**
let finalContent = content; * Loads an event from JSON string for editing
if (kind === 30040 || kind === 30041) { */
finalContent = removeMetadataFromContent(content); function loadEventFromJson(): void {
} if (!eventJsonInput.trim()) {
error = "Please enter event JSON.";
// Prefix Nostr addresses before publishing return;
const prefixedContent = prefixNostrAddresses(finalContent);
// Create event with proper serialization
const eventData = {
kind,
content: prefixedContent,
tags: eventTags,
pubkey: pubkeyString,
created_at: createdAt,
};
events = [new NDKEventClass(ndk, eventData)];
} }
let atLeastOne = false;
let relaysPublished: string[] = [];
for (let i = 0; i < events.length; i++) {
const event = events[i];
try { try {
console.log("Publishing event:", { const eventJson = JSON.parse(eventJsonInput.trim());
kind: event.kind,
content: event.content,
tags: event.tags,
hasContent: event.content && event.content.length > 0,
});
// Always sign with a plain object if window.nostr is available // Validate required fields
// Create a completely plain object to avoid proxy cloning issues if (typeof eventJson.kind !== 'number') {
const plainEvent = { error = "Invalid event JSON: missing or invalid 'kind' field.";
kind: Number(event.kind), return;
pubkey: String(event.pubkey),
created_at: Number(
event.created_at ?? Math.floor(Date.now() / 1000),
),
tags: event.tags.map((tag) => [String(tag[0]), String(tag[1])]),
content: String(event.content),
};
if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig;
if ("id" in signed) {
event.id = signed.id as string;
}
} else {
await event.sign();
}
// Use direct WebSocket publishing like CommentBox does
const signedEvent = {
...plainEvent,
id: event.id,
sig: event.sig,
};
// Try to publish to relays directly
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
...$activeOutboxRelays,
...$activeInboxRelays,
];
let published = false;
for (const relayUrl of relays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
relaysPublished.push(relayUrl);
WebSocketPool.instance.release(ws);
resolve();
} else {
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
} }
};
// Send the event to the relay if (typeof eventJson.content !== 'string') {
ws.send(JSON.stringify(["EVENT", signedEvent])); error = "Invalid event JSON: missing or invalid 'content' field.";
}); return;
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
} }
if (published) { if (!Array.isArray(eventJson.tags)) {
atLeastOne = true; error = "Invalid event JSON: missing or invalid 'tags' field.";
// For 30040, set lastPublishedEventId to the index event (last in array)
if (Number(kind) === 30040) {
if (i === events.length - 1) {
lastPublishedEventId = event.id;
}
} else {
lastPublishedEventId = event.id;
}
}
} catch (signError) {
console.error("Error signing/publishing event:", signError);
error = `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`;
loading = false;
return; return;
} }
}
loading = false; // Extract event data (drop fields that need to be regenerated)
if (atLeastOne) { eventData = {
publishedRelays = relaysPublished; kind: eventJson.kind,
success = `Published to ${relaysPublished.length} relay(s).`; content: eventJson.content,
} else { createdAt: Math.floor(Date.now() / 1000), // Use current time
error = "Failed to publish to any relay."; };
}
// Convert tags from NDK format to our format
tags = eventJson.tags.map((tag: string[]) => ({
key: tag[0] || "",
values: tag.slice(1)
}));
success = "Loaded event from JSON successfully.";
error = null;
} catch (err) { } catch (err) {
console.error("Error in handlePublish:", err); console.error("Error parsing event JSON:", err);
error = `Publishing failed: ${err instanceof Error ? err.message : "Unknown error"}`; error = `Failed to parse event JSON: ${err instanceof Error ? err.message : "Invalid JSON format"}`;
loading = false;
} }
} }
/** /**
* Debug function to analyze a 30040 event and provide guidance. * Clears all form fields and resets to initial state
*/ */
function debug30040Event(eventData: { function clearForm(): void {
content: string; eventData = {
tags: [string, string][]; kind: 1,
kind: number; content: "",
}) { createdAt: Math.floor(Date.now() / 1000),
const analysis = analyze30040Event(eventData); };
console.log("30040 Event Analysis:", analysis); tags = [];
if (!analysis.valid) { error = null;
console.log("Guidance:", get30040FixGuidance()); success = null;
} publishedRelays = [];
return analysis; lastPublishedEventId = null;
eventIdSearch = "";
eventJsonInput = "";
showJsonPreview = false;
} }
/**
* Navigate to view the published event
*/
function viewPublishedEvent() { function viewPublishedEvent() {
if (lastPublishedEventId) { if (lastPublishedEventId) {
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`); goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
} }
} }
function confirmWarning() {
showWarning = false;
pendingPublish = false;
handlePublish();
}
function cancelWarning() {
showWarning = false;
pendingPublish = false;
warningMessage = "";
}
</script> </script>
<div <div class="w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg">
class="w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg" <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Publish Nostr Event</h2>
<div class="flex gap-2">
<button
type="button"
class="btn btn-outline btn-secondary btn-sm"
onclick={clearForm}
> >
<h2 class="text-xl font-bold mb-4">Publish Nostr Event</h2> Clear Form
<form class="space-y-4" onsubmit={handleSubmit}> </button>
<div> <button
<label class="block font-medium mb-1" for="event-kind">Kind</label> type="button"
<input class="btn btn-primary btn-sm border border-primary-600"
id="event-kind" onclick={() => {
type="text" // Trigger validation by submitting the form
class="input input-bordered w-full" const form = document.querySelector('form');
bind:value={kind} if (form) {
required form.dispatchEvent(new Event('submit', { bubbles: true }));
/> }
{#if !isValidKind(kind)} }}
<div class="text-red-600 text-sm mt-1">
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if Number(kind) === 30040}
<div
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-50 dark:text-blue-800 p-2 rounded whitespace-pre-wrap"
> >
<strong>30040 - Publication Index:</strong> Validate Form
{get30040EventDescription()} </button>
</div> </div>
{/if}
</div> </div>
<div>
<label class="block font-medium mb-1" for="tags-container">Tags</label> <!-- Event ID Search Section -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600">
<!-- Extracted Metadata Section --> <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Load Existing Event</h3>
{#if extractedMetadata.length > 0}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> <!-- Load Method Tabs -->
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2"> <div class="flex gap-1 mb-3">
Extracted Metadata (from AsciiDoc header)
</h4>
<div class="space-y-2">
{#each extractedMetadata as [key, value], i}
<div class="flex gap-2 items-center">
<span class="text-xs text-blue-600 dark:text-blue-400 min-w-[60px]">{key}:</span>
<input
type="text"
class="input input-bordered input-sm flex-1 text-sm"
value={value}
readonly
/>
<button <button
type="button" type="button"
class="btn btn-sm btn-outline btn-primary" class="px-3 py-1 text-sm rounded-l-lg border border-gray-300 dark:border-gray-600 {loadMethod === 'hex' ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'}"
onclick={() => addExtractedTag(key, value)} onclick={() => loadMethod = 'hex'}
> >
Add to Tags Hex ID
</button>
<button
type="button"
class="px-3 py-1 text-sm rounded-r-lg border border-gray-300 dark:border-gray-600 {loadMethod === 'json' ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'}"
onclick={() => loadMethod = 'json'}
>
JSON
</button> </button>
</div> </div>
{/each}
</div>
</div>
{/if}
<div id="tags-container" class="space-y-2"> {#if loadMethod === 'hex'}
{#each tags as [key, value], i} <!-- Hex ID Input -->
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
class="input input-bordered flex-1" class="input input-bordered flex-1"
placeholder="tag" placeholder="Enter 64-character hex event ID"
bind:value={tags[i][0]} bind:value={eventIdSearch}
oninput={(e) => maxlength="64"
updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} onkeydown={(e) => {
/> if (e.key === 'Enter' && !loadingEvent && eventIdSearch.trim()) {
<input e.preventDefault();
type="text" loadEventById();
class="input input-bordered flex-1" }
placeholder="value" }}
bind:value={tags[i][1]}
oninput={(e) =>
updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)}
/> />
<button <button
type="button" type="button"
class="btn btn-error btn-sm" class="btn btn-secondary"
onclick={() => removeTag(i)} onclick={loadEventById}
disabled={tags.length === 1}</button disabled={loadingEvent || !eventIdSearch.trim()}
> >
{loadingEvent ? 'Loading...' : 'Load Event'}
</button>
</div> </div>
{/each} <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div class="flex justify-end"> Load an existing event from relays by its hex ID.
</p>
{:else}
<!-- JSON Input -->
<div class="space-y-2">
<textarea
class="textarea textarea-bordered w-full h-32 font-mono text-sm"
placeholder="Paste event JSON here (content, kind, tags fields required)"
bind:value={eventJsonInput}
></textarea>
<div class="flex gap-2">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm border border-primary-600 px-3 py-1" class="btn btn-secondary"
onclick={addTag}>Add Tag</button onclick={loadEventFromJson}
disabled={!eventJsonInput.trim()}
> >
Load from JSON
</button>
<button
type="button"
class="btn btn-outline btn-secondary btn-sm"
onclick={() => eventJsonInput = ""}
>
Clear JSON
</button>
</div> </div>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Paste a complete event JSON to load it into the form. Fields like id, pubkey, created_at, and sig will be regenerated.
</p>
{/if}
</div> </div>
<div>
<label class="block font-medium mb-1" for="event-content">Content</label> <!-- Main Form -->
<textarea <EventForm
id="event-content" bind:eventData
bind:value={content} {tags}
oninput={handleContentInput} onvalidate={handleValidate}
placeholder="Content (start with a header for the title)"
class="textarea textarea-bordered w-full h-40"
required
></textarea>
</div>
<div>
<label class="block font-medium mb-1" for="event-title">Title</label>
<input
type="text"
id="event-title"
bind:value={title}
oninput={handleTitleInput}
placeholder="Title (auto-filled from header)"
class="input input-bordered w-full"
/> />
</div>
<div> <!-- Tag Management -->
<label class="block font-medium mb-1" for="event-d-tag">d-tag</label> <TagManager
<input bind:tags
type="text" kind={eventData.kind}
id="event-d-tag" content={eventData.content}
bind:value={dTag}
oninput={handleDTagInput}
placeholder="d-tag (auto-generated from title)"
class="input input-bordered w-full"
required={requiresDTag(kind)}
/> />
{#if dTagError}
<div class="text-red-600 text-sm mt-1">{dTagError}</div> <!-- Action Buttons -->
{/if} <div class="flex justify-end gap-2 mt-4">
</div>
<div class="flex justify-end">
<button <button
type="submit" type="button"
class="btn btn-primary border border-primary-600 px-4 py-2" class="btn btn-primary border border-primary-600 px-4 py-2"
disabled={loading}>Publish</button onclick={handlePublish}
disabled={loading}
> >
Publish
</button>
</div> </div>
<!-- Status Messages -->
{#if loading} {#if loading}
<span class="ml-2 text-gray-500">Publishing...</span> <div class="mt-2 text-gray-500 dark:text-gray-400">Publishing...</div>
{/if} {/if}
{#if error} {#if error}
<div class="mt-2 text-red-600">{error}</div> <div class="mt-2 text-red-600 dark:text-red-400">{error}</div>
{/if} {/if}
{#if success} {#if success}
<div class="mt-2 text-green-600">{success}</div> <div class="mt-2 text-green-600 dark:text-green-400">{success}</div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500 dark:text-gray-400">
Relays: {publishedRelays.join(", ")} Relays: {publishedRelays.join(", ")}
</div> </div>
{#if lastPublishedEventId} {#if lastPublishedEventId}
<div class="mt-2 text-green-700"> <div class="mt-2 text-green-700 dark:text-green-300">
Event ID: <span class="font-mono">{lastPublishedEventId}</span> Event ID: <span class="font-mono">{lastPublishedEventId}</span>
<Button <Button
onclick={viewPublishedEvent} onclick={viewPublishedEvent}
@ -658,30 +382,13 @@
</div> </div>
{/if} {/if}
{/if} {/if}
</form>
</div>
{#if showWarning} <!-- Event Preview -->
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <EventPreview
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg max-w-md mx-4"> {ndk}
<h3 class="text-lg font-bold mb-4">Warning</h3> {eventData}
<p class="mb-4">{warningMessage}</p> {tags}
<div class="flex justify-end space-x-2"> {showJsonPreview}
<button onTogglePreview={() => showJsonPreview = !showJsonPreview}
type="button" />
class="btn btn-secondary"
onclick={cancelWarning}
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
onclick={confirmWarning}
>
Continue
</button>
</div>
</div>
</div> </div>
{/if}

708
src/lib/components/EventSearch.svelte

File diff suppressed because it is too large Load Diff

5
src/lib/components/LoginModal.svelte

@ -2,6 +2,7 @@
import { Button, Modal } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { loginWithExtension } from "$lib/stores/userStore"; import { loginWithExtension } from "$lib/stores/userStore";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { getNdkContext } from "$lib/ndk";
const { const {
show = false, show = false,
@ -13,6 +14,8 @@
onLoginSuccess?: () => void; onLoginSuccess?: () => void;
}>(); }>();
const ndk = getNdkContext();
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>(""); let errorMessage = $state<string>("");
let user = $state($userStore); let user = $state($userStore);
@ -42,7 +45,7 @@
signInFailed = false; signInFailed = false;
errorMessage = ""; errorMessage = "";
await loginWithExtension(); await loginWithExtension(ndk);
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
signInFailed = true; signInFailed = true;

9
src/lib/components/Navigation.svelte

@ -18,11 +18,14 @@
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}> <Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
<div class="flex flex-grow justify-between"> <div class="flex flex-grow justify-between">
<NavBrand href="/"> <NavBrand href="/">
<h1>Alexandria</h1> <div class="flex flex-col">
<h1 class="text-2xl font-bold">Alexandria</h1>
<p class="text-xs font-semibold tracking-wide">READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.</p>
</div>
</NavBrand> </NavBrand>
</div> </div>
<div class="flex md:order-2"> <div class="flex md:order-2">
<Profile isNav={true} pubkey={userState.npub || undefined} /> <Profile isNav={true} />
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">
@ -31,7 +34,9 @@
<NavLi href="/visualize">Visualize</NavLi> <NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi> <NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi> <NavLi href="/events">Events</NavLi>
{#if userState.signedIn}
<NavLi href="/my-notes">My Notes</NavLi> <NavLi href="/my-notes">My Notes</NavLi>
{/if}
<NavLi href="/about">About</NavLi> <NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi> <NavLi href="/contact">Contact</NavLi>
<NavLi> <NavLi>

1154
src/lib/components/Notifications.svelte

File diff suppressed because it is too large Load Diff

9
src/lib/components/Preview.svelte

@ -22,6 +22,7 @@
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { onMount } from "svelte"; import { onMount } from "svelte";
import LazyImage from "$components/util/LazyImage.svelte";
// TODO: Fix move between parents. // TODO: Fix move between parents.
@ -250,8 +251,14 @@
{#snippet coverImage(rootId: string, index: number, depth: number)} {#snippet coverImage(rootId: string, index: number, depth: number)}
{#if hasCoverImage(rootId, index)} {#if hasCoverImage(rootId, index)}
{@const event = blogEntries[index][1]}
<div class="coverImage depth-{depth}"> <div class="coverImage depth-{depth}">
<img src={hasCoverImage(rootId, index)} alt={title} /> <LazyImage
src={hasCoverImage(rootId, index)}
alt={title || "Cover image"}
eventId={event?.id || rootId}
className="w-full h-full object-cover"
/>
</div> </div>
{/if} {/if}
{/snippet} {/snippet}

40
src/lib/components/RelayActions.svelte

@ -1,35 +1,26 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal } from "flowbite-svelte"; import { Modal } from "flowbite-svelte";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { get } from "svelte/store";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
createRelaySetFromUrls, createRelaySetFromUrls,
createNDKEvent,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import RelayDisplay, { import RelayDisplay, {
getConnectedRelays,
getEventRelays, getEventRelays,
} from "./RelayDisplay.svelte"; } from "./RelayDisplay.svelte";
import { communityRelays, secondaryRelays } from "$lib/consts";
const { event } = $props<{ const { event } = $props<{
event: NDKEvent; event: NDKEvent;
}>(); }>();
let searchingRelays = $state(false); const ndk = getNdkContext();
let foundRelays = $state<string[]>([]);
let showRelayModal = $state(false); let showRelayModal = $state(false);
let relaySearchResults = $state< let relaySearchResults = $state<
Record<string, "pending" | "found" | "notfound"> Record<string, "pending" | "found" | "notfound">
>({}); >({});
let allRelays = $state<string[]>([]); let allRelays = $state<string[]>([]);
// Magnifying glass icon SVG
const searchIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>`;
function openRelayModal() { function openRelayModal() {
showRelayModal = true; showRelayModal = true;
relaySearchResults = {}; relaySearchResults = {};
@ -39,7 +30,6 @@
async function searchAllRelaysLive() { async function searchAllRelaysLive() {
if (!event) return; if (!event) return;
relaySearchResults = {}; relaySearchResults = {};
const ndk = get(ndkInstance);
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map( const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(
(r) => r.url, (r) => r.url,
); );
@ -66,30 +56,8 @@
}), }),
); );
} }
function closeRelayModal() {
showRelayModal = false;
}
</script> </script>
<div class="mt-4 flex flex-wrap gap-2">
<Button on:click={openRelayModal} class="flex items-center">
{@html searchIcon}
Where can I find this event?
</Button>
</div>
{#if foundRelays.length > 0}
<div class="mt-2">
<span class="font-semibold">Found on {foundRelays.length} relay(s):</span>
<div class="flex flex-wrap gap-2 mt-1">
{#each foundRelays as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
<div class="mt-2"> <div class="mt-2">
<span class="font-semibold">Found on:</span> <span class="font-semibold">Found on:</span>
<div class="flex flex-wrap gap-2 mt-1"> <div class="flex flex-wrap gap-2 mt-1">

9
src/lib/components/RelayDisplay.svelte

@ -1,7 +1,7 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { activeInboxRelays, ndkInstance } from "$lib/ndk"; import { activeInboxRelays } from "$lib/ndk";
// Get relays from event (prefer event.relay or event.relays, fallback to active inbox relays) // Get relays from event (prefer event.relay or event.relays, fallback to active inbox relays)
export function getEventRelays(event: NDKEvent): string[] { export function getEventRelays(event: NDKEvent): string[] {
@ -17,13 +17,6 @@
// Use active inbox relays as fallback // Use active inbox relays as fallback
return get(activeInboxRelays); return get(activeInboxRelays);
} }
export function getConnectedRelays(): string[] {
const ndk = get(ndkInstance);
return Array.from(ndk?.pool?.relays.values() || [])
.filter((r) => r.status === 1) // Only use connected relays
.map((r) => r.url);
}
</script> </script>
<script lang="ts"> <script lang="ts">

92
src/lib/components/RelayInfoDisplay.svelte

@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchRelayInfo, getRelayTypeLabel, getRelayIcon, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service';
const { relay, showIcon = true, showType = true, showName = true, size = 'sm' } = $props<{
relay: string;
showIcon?: boolean;
showType?: boolean;
showName?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
}>();
let relayInfo = $state<RelayInfoWithMetadata | undefined>(undefined);
let isLoading = $state(true);
let error = $state<string | null>(null);
// Size classes
const sizeClasses: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
};
const iconSizeClasses: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
};
async function loadRelayInfo() {
isLoading = true;
error = null;
try {
relayInfo = await fetchRelayInfo(relay);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load relay info';
console.warn(`[RelayInfoDisplay] Error loading info for ${relay}:`, err);
} finally {
isLoading = false;
}
}
onMount(() => {
loadRelayInfo();
});
// Get relay type and label
const relayType = $derived(getRelayTypeLabel(relay, relayInfo));
const relayIcon = $derived(getRelayIcon(relayInfo, relay));
const displayName = $derived(relayInfo?.name || relayInfo?.shortUrl || relay);
</script>
<div class="inline-flex items-center gap-2 flex-1">
{#if showIcon && relayIcon}
<img
src={relayIcon}
alt="Relay icon"
class="{iconSizeClasses[size as keyof typeof iconSizeClasses]} rounded object-contain"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else if showIcon}
<!-- Fallback icon -->
<div class="{iconSizeClasses[size as keyof typeof iconSizeClasses]} bg-gray-300 dark:bg-gray-600 rounded flex items-center justify-center">
<svg class="w-2/3 h-2/3 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V4z" clip-rule="evenodd" />
</svg>
</div>
{/if}
<div class="flex flex-col min-w-0 flex-1">
{#if showName}
<span class="{sizeClasses[size as keyof typeof sizeClasses]} font-medium text-gray-900 dark:text-gray-100 leading-tight truncate">
{isLoading ? 'Loading...' : displayName}
</span>
{/if}
{#if showType}
<span class="text-xs text-gray-500 dark:text-gray-400 leading-tight truncate">
{relayType}
</span>
{/if}
</div>
{#if error}
<span class="text-xs text-red-500 dark:text-red-400 flex-shrink-0" title={error}>
</span>
{/if}
</div>

143
src/lib/components/RelayInfoList.svelte

@ -0,0 +1,143 @@
<script lang="ts">
import RelayInfoDisplay from './RelayInfoDisplay.svelte';
import { fetchRelayInfos, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service';
const {
relays,
inboxRelays = [],
outboxRelays = [],
showLabels = true,
compact = false
} = $props<{
relays: string[];
inboxRelays?: string[];
outboxRelays?: string[];
showLabels?: boolean;
compact?: boolean;
}>();
let relayInfos = $state<RelayInfoWithMetadata[]>([]);
let isLoading = $state(true);
type CategorizedRelay = {
relay: string;
category: 'both' | 'inbox' | 'outbox' | 'other';
label: string;
};
// Categorize relays by their function (inbox/outbox/both)
const categorizedRelays = $derived(() => {
const inbox = new Set(inboxRelays);
const outbox = new Set(outboxRelays);
const relayCategories = new Map<string, CategorizedRelay>();
// Process inbox relays (up to top 3)
const topInboxRelays = inboxRelays.slice(0, 3);
topInboxRelays.forEach((relay: string) => {
const isOutbox = outbox.has(relay);
if (isOutbox) {
relayCategories.set(relay, { relay, category: 'both', label: 'Inbox & Outbox' });
} else {
relayCategories.set(relay, { relay, category: 'inbox', label: 'Recipient Inbox' });
}
});
// Process outbox relays (up to top 3)
const topOutboxRelays = outboxRelays.slice(0, 3);
topOutboxRelays.forEach((relay: string) => {
if (!relayCategories.has(relay)) {
relayCategories.set(relay, { relay, category: 'outbox', label: 'Sender Outbox' });
}
});
return Array.from(relayCategories.values());
});
// Group by category for display
const groupedRelays = $derived(() => {
const categorized = categorizedRelays();
return {
both: categorized.filter((r: CategorizedRelay) => r.category === 'both'),
inbox: categorized.filter((r: CategorizedRelay) => r.category === 'inbox'),
outbox: categorized.filter((r: CategorizedRelay) => r.category === 'outbox'),
other: categorized.filter((r: CategorizedRelay) => r.category === 'other')
};
});
async function loadRelayInfos() {
isLoading = true;
try {
const categorized = categorizedRelays();
const relayUrls = categorized.map(r => r.relay);
relayInfos = await fetchRelayInfos(relayUrls);
} catch (error) {
console.warn('[RelayInfoList] Error loading relay infos:', error);
} finally {
isLoading = false;
}
}
// Load relay info when categorized relays change
$effect(() => {
const categorized = categorizedRelays();
if (categorized.length > 0) {
loadRelayInfos();
}
});
// Get relay info for a specific relay
function getRelayInfo(relayUrl: string): RelayInfoWithMetadata | undefined {
return relayInfos.find(info => info.url === relayUrl);
}
// Category colors
const categoryColors = {
both: 'bg-green-100 dark:bg-green-900 border-green-200 dark:border-green-700 text-green-800 dark:text-green-200',
inbox: 'bg-blue-100 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200',
outbox: 'bg-purple-100 dark:bg-purple-900 border-purple-200 dark:border-purple-700 text-purple-800 dark:text-purple-200',
other: 'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-200'
};
const categoryIcons = {
both: '🔄',
inbox: '📥',
outbox: '📤',
other: '🌐'
};
</script>
<div class="space-y-2">
{#if showLabels && !compact}
{@const categorizedCount = categorizedRelays().length}
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
Publishing to {categorizedCount} relay(s):
</div>
{/if}
{#if isLoading}
<div class="flex items-center justify-center py-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Loading relay info...</span>
</div>
{:else}
{@const categorized = categorizedRelays()}
<div class="space-y-1">
{#each categorized as { relay, category, label }}
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-mono text-gray-900 dark:text-gray-100">
{relay}
</span>
{#if category === 'both'}
<span class="text-xs text-gray-500 dark:text-gray-400 italic">
common relay
</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>

6
src/lib/components/RelayStatus.svelte

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Button, Alert } from "flowbite-svelte"; import { Button, Alert } from "flowbite-svelte";
import { import {
ndkInstance,
ndkSignedIn, ndkSignedIn,
testRelayConnection, testRelayConnection,
checkWebSocketSupport, checkWebSocketSupport,
checkEnvironmentForWebSocketDowngrade, checkEnvironmentForWebSocketDowngrade,
} from "$lib/ndk"; } from "$lib/ndk";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
const ndk = getNdkContext();
interface RelayStatus { interface RelayStatus {
url: string; url: string;
@ -30,7 +31,6 @@ import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
async function runRelayTests() { async function runRelayTests() {
testing = true; testing = true;
const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
testing = false; testing = false;
return; return;

8
src/lib/components/ZettelEditor.svelte

@ -195,7 +195,7 @@ import Asciidoctor from "asciidoctor";
<Button <Button
color="light" color="light"
size="sm" size="sm"
on:click={togglePreview} onclick={togglePreview}
class="flex items-center space-x-1" class="flex items-center space-x-1"
> >
{#if showPreview} {#if showPreview}
@ -210,7 +210,7 @@ import Asciidoctor from "asciidoctor";
<Button <Button
color="light" color="light"
size="sm" size="sm"
on:click={toggleTutorial} onclick={toggleTutorial}
class="flex items-center space-x-1" class="flex items-center space-x-1"
> >
<QuestionCircleOutline class="w-4 h-4" /> <QuestionCircleOutline class="w-4 h-4" />
@ -223,7 +223,7 @@ import Asciidoctor from "asciidoctor";
<Button <Button
color="primary" color="primary"
size="sm" size="sm"
on:click={handlePublish} onclick={handlePublish}
> >
Publish Publish
</Button> </Button>
@ -240,7 +240,7 @@ import Asciidoctor from "asciidoctor";
<div class="flex-1 relative border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-900"> <div class="flex-1 relative border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-900">
<Textarea <Textarea
bind:value={content} bind:value={content}
on:input={handleContentChange} oninput={handleContentChange}
{placeholder} {placeholder}
class="w-full h-full resize-none font-mono text-sm leading-relaxed p-4 bg-white dark:bg-gray-900 border-none outline-none" class="w-full h-full resize-none font-mono text-sm leading-relaxed p-4 bg-white dark:bg-gray-900 border-none outline-none"
/> />

2
src/lib/components/cards/BlogHeader.svelte

@ -91,7 +91,7 @@
{#if hashtags} {#if hashtags}
<div class="tags"> <div class="tags">
{#each hashtags as tag} {#each hashtags as tag}
<span>{tag}</span> <span class="mr-2">#{tag}</span>
{/each} {/each}
</div> </div>
{/if} {/if}

126
src/lib/components/cards/ProfileHeader.svelte

@ -2,7 +2,8 @@
import { Card, Modal, Button, P } from "flowbite-svelte"; import { Card, Modal, Button, P } from "flowbite-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts"; import { toNpub } from "$lib/utils/nostrUtils.ts";
import type { NostrProfile } from "$lib/utils/search_types";
import QrCode from "$components/util/QrCode.svelte"; import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
@ -11,24 +12,28 @@
lnurlpWellKnownUrl, lnurlpWellKnownUrl,
checkCommunity, checkCommunity,
} from "$lib/utils/search_utility"; } from "$lib/utils/search_utility";
// @ts-ignore import { bech32 } from "bech32";
import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists";
import { UserOutline } from "flowbite-svelte-icons";
const { const {
event, event,
profile, profile,
identifiers = [], identifiers = [],
communityStatusMap = {},
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
profile: NostrProfile; profile: NostrProfile;
identifiers?: { label: string; value: string; link?: string }[]; identifiers?: { label: string; value: string; link?: string }[];
communityStatusMap?: Record<string, boolean>;
}>(); }>();
let lnModalOpen = $state(false); let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null); let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null); let communityStatus = $state<boolean | null>(null);
let isInUserLists = $state<boolean | null>(null);
onMount(async () => { onMount(async () => {
if (profile?.lud16) { if (profile?.lud16) {
@ -46,6 +51,34 @@
$effect(() => { $effect(() => {
if (event?.pubkey) { if (event?.pubkey) {
// First check if we have cached profileData with user list information
const cachedProfileData = (event as any).profileData;
console.log(`[ProfileHeader] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData);
if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') {
isInUserLists = cachedProfileData.isInUserLists;
console.log(`[ProfileHeader] Using cached user list status for ${event.pubkey}: ${isInUserLists}`);
} else {
console.log(`[ProfileHeader] No cached user list data, fetching for ${event.pubkey}`);
// Fallback to fetching user lists
fetchCurrentUserLists()
.then((userLists) => {
console.log(`[ProfileHeader] Fetched ${userLists.length} user lists for ${event.pubkey}`);
isInUserLists = isPubkeyInUserLists(event.pubkey, userLists);
console.log(`[ProfileHeader] Final user list status for ${event.pubkey}: ${isInUserLists}`);
})
.catch((error) => {
console.error(`[ProfileHeader] Error fetching user lists for ${event.pubkey}:`, error);
isInUserLists = false;
});
}
// Check community status - use cached data if available
if (communityStatusMap[event.pubkey] !== undefined) {
communityStatus = communityStatusMap[event.pubkey];
console.log(`[ProfileHeader] Using cached community status for ${event.pubkey}: ${communityStatus}`);
} else {
// Fallback to checking community status
checkCommunity(event.pubkey) checkCommunity(event.pubkey)
.then((status) => { .then((status) => {
communityStatus = status; communityStatus = status;
@ -54,6 +87,7 @@
communityStatus = false; communityStatus = false;
}); });
} }
}
}); });
function navigateToIdentifier(link: string) { function navigateToIdentifier(link: string) {
@ -62,7 +96,7 @@
</script> </script>
{#if profile} {#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl"> <Card class="ArticleBox card-leather w-full max-w-2xl overflow-hidden">
<div class="space-y-4"> <div class="space-y-4">
<div class="ArticleBoxImage flex col justify-center"> <div class="ArticleBoxImage flex col justify-center">
{#if profile.banner} {#if profile.banner}
@ -80,18 +114,27 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex flex-row space-x-4 items-center"> <div class="flex flex-row space-x-4 items-center min-w-0">
{#if profile.picture} {#if profile.picture}
<img <img
src={profile.picture} src={profile.picture}
alt="Profile avatar" alt="Profile avatar"
class="w-16 h-16 rounded-full border" class="w-16 h-16 rounded-full border flex-shrink-0"
onerror={(e) => { onerror={(e) => {
(e.target as HTMLImageElement).src = "/favicon.png"; (e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}} }}
/> />
<div class="w-16 h-16 rounded-full border flex-shrink-0 bg-gray-300 dark:bg-gray-600 flex items-center justify-center hidden">
<UserOutline class="w-8 h-8 text-gray-600 dark:text-gray-300" />
</div>
{:else}
<div class="w-16 h-16 rounded-full border flex-shrink-0 bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<UserOutline class="w-8 h-8 text-gray-600 dark:text-gray-300" />
</div>
{/if} {/if}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0 flex-1">
<div class="min-w-0 flex-1">
{@render userBadge( {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile.displayName || profile.displayName ||
@ -99,6 +142,7 @@
profile.name || profile.name ||
event.pubkey, event.pubkey,
)} )}
</div>
{#if communityStatus === true} {#if communityStatus === true}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
@ -117,33 +161,51 @@
{:else if communityStatus === false} {:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div> <div class="flex-shrink-0 w-4 h-4"></div>
{/if} {/if}
{#if isInUserLists === true}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div> </div>
{:else if isInUserLists === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
</div> </div>
<div> </div>
<div class="min-w-0">
<div class="mt-2 flex flex-col gap-4"> <div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2"> <dl class="grid grid-cols-1 gap-y-2">
{#if profile.name} {#if profile.name}
<div class="flex gap-2"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px]">Name:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">Name:</dt>
<dd>{profile.name}</dd> <dd class="min-w-0 break-words">{profile.name}</dd>
</div> </div>
{/if} {/if}
{#if profile.displayName} {#if profile.displayName}
<div class="flex gap-2"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px]">Display Name:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">Display Name:</dt>
<dd>{profile.displayName}</dd> <dd class="min-w-0 break-words">{profile.displayName}</dd>
</div> </div>
{/if} {/if}
{#if profile.about} {#if profile.about}
<div class="flex gap-2"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px]">About:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">About:</dt>
<dd class="whitespace-pre-line">{profile.about}</dd> <dd class="min-w-0 break-words whitespace-pre-line">{profile.about}</dd>
</div> </div>
{/if} {/if}
{#if profile.website} {#if profile.website}
<div class="flex gap-2"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px]">Website:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">Website:</dt>
<dd> <dd class="min-w-0 break-all">
<a <a
href={profile.website} href={profile.website}
class="underline text-primary-700 dark:text-primary-200" class="underline text-primary-700 dark:text-primary-200"
@ -153,9 +215,9 @@
</div> </div>
{/if} {/if}
{#if profile.lud16} {#if profile.lud16}
<div class="flex items-center gap-2 mt-4"> <div class="flex items-center gap-2 mt-4 min-w-0">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">Lightning:</dt>
<dd> <dd class="min-w-0 break-all">
<Button <Button
class="btn-leather" class="btn-leather"
color="primary" color="primary"
@ -166,15 +228,15 @@
</div> </div>
{/if} {/if}
{#if profile.nip05} {#if profile.nip05}
<div class="flex gap-2"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">NIP-05:</dt>
<dd>{profile.nip05}</dd> <dd class="min-w-0 break-all">{profile.nip05}</dd>
</div> </div>
{/if} {/if}
{#each identifiers as id} {#each identifiers as id}
<div class="flex gap-2"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">{id.label}:</dt>
<dd class="break-all"> <dd class="min-w-0 break-all">
{#if id.link} {#if id.link}
<button <button
class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline hover:no-underline transition-colors" class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline hover:no-underline transition-colors"
@ -208,12 +270,12 @@
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile?.displayName || profile.name || event.pubkey, profile?.displayName || profile.name || event.pubkey,
)} )}
<P>{profile.lud16}</P> <P class="break-all">{profile.lud16}</P>
</div> </div>
<div class="flex flex-col items-center mt-3 space-y-4"> <div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P> <P>Scan the QR code or copy the address</P>
{#if lnurl} {#if lnurl}
<P style="overflow-wrap: anywhere"> <P class="break-all overflow-wrap-anywhere">
<CopyToClipboard icon={false} displayText={lnurl} <CopyToClipboard icon={false} displayText={lnurl}
></CopyToClipboard> ></CopyToClipboard>
</P> </P>

738
src/lib/components/embedded_events/EmbeddedEvent.svelte

@ -0,0 +1,738 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchEventWithFallback, getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { parsedContent } from "$lib/components/embedded_events/EmbeddedSnippets.svelte";
import { naddrEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { goto } from "$app/navigation";
import { getEventType } from "$lib/utils/mime";
import { nip19 } from "nostr-tools";
import { repostKinds } from "$lib/consts";
import { UserOutline } from "flowbite-svelte-icons";
import type { UserProfile } from "$lib/models/user_profile";
const {
nostrIdentifier,
nestingLevel = 0,
} = $props<{
nostrIdentifier: string;
nestingLevel?: number;
}>();
const ndk = getNdkContext();
let event = $state<NDKEvent | null>(null);
let profile = $state< UserProfile | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let authorDisplayName = $state<string | undefined>(undefined);
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// AI-NOTE: 2025-01-24 - Embedded event component for rendering nested Nostr events
// Supports up to 3 levels of nesting, after which it falls back to showing just the link
// AI-NOTE: 2025-01-24 - Updated to handle both NIP-19 identifiers and raw event IDs
// If a raw event ID is passed, it automatically creates a nevent identifier
$effect(() => {
if (nostrIdentifier) {
loadEvent();
}
});
async function loadEvent() {
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, don't load the event, just show the link
loading = false;
return;
}
loading = true;
error = null;
try {
if (!ndk) {
throw new Error("No NDK instance available");
}
// Clean the identifier (remove nostr: prefix if present)
const cleanId = nostrIdentifier.replace(/^nostr:/, "");
// Try to decode as NIP-19 identifier first
let decoded;
try {
decoded = nip19.decode(cleanId);
} catch (decodeError) {
// If decoding fails, assume it's a raw event ID and create a nevent
if (/^[0-9a-fA-F]{64}$/.test(cleanId)) {
// It's a valid hex event ID, create a nevent
const nevent = nip19.neventEncode({
id: cleanId,
relays: [],
});
decoded = nip19.decode(nevent);
} else {
throw new Error(`Invalid identifier format: ${cleanId}`);
}
}
if (!decoded) {
throw new Error("Failed to decode Nostr identifier");
}
let eventId: string | undefined;
if (decoded.type === "nevent") {
eventId = decoded.data.id;
} else if (decoded.type === "naddr") {
// For naddr, we need to construct a filter
const naddrData = decoded.data as any;
const filter = {
kinds: [naddrData.kind || 0],
authors: [naddrData.pubkey],
"#d": [naddrData.identifier],
};
const foundEvent = await fetchEventWithFallback(ndk, filter);
if (!foundEvent) {
throw new Error("Event not found");
}
event = foundEvent;
} else if (decoded.type === "note") {
// For note, treat it as a nevent
eventId = (decoded.data as any).id;
} else {
throw new Error(`Unsupported identifier type: ${decoded.type}`);
}
// If we have an event ID, fetch the event
if (eventId && !event) {
event = await fetchEventWithFallback(ndk, eventId);
if (!event) {
throw new Error("Event not found");
}
}
// Load profile for the event author
if (event?.pubkey) {
const npub = toNpub(event.pubkey);
if (npub) {
const userProfile = await getUserMetadata(npub, ndk);
authorDisplayName =
userProfile.displayName ||
(userProfile as any).display_name ||
userProfile.name ||
event.pubkey;
}
}
// Parse profile if it's a profile event
if (event?.kind === 0) {
try {
profile = JSON.parse(event.content);
} catch {
profile = null;
}
}
} catch (err) {
console.error("Error loading embedded event:", err);
error = err instanceof Error ? err.message : "Failed to load event";
} finally {
loading = false;
}
}
function getEventTitle(event: NDKEvent): string {
const titleTag = event.getMatchingTags("title")[0]?.[1];
if (titleTag) return titleTag;
// For profile events, use display name
if (event.kind === 0 && profile) {
return profile.display_name || profile.name || "Profile";
}
// For text events (kind 1), don't show a title if it would duplicate the content
if (event.kind === 1) {
return "";
}
// For other events, use first line of content, but filter out nostr identifiers
if (event.content) {
const firstLine = event.content.split("\n")[0].trim();
if (firstLine) {
// Remove nostr identifiers from the title
const cleanTitle = firstLine.replace(/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g, '').trim();
if (cleanTitle) return cleanTitle.slice(0, 100);
}
}
return "Untitled";
}
function getEventSummary(event: NDKEvent): string {
if (event.kind === 0 && profile?.about) {
return profile.about;
}
if (event.content) {
const lines = event.content.split("\n");
const summaryLines = lines.slice(1, 3).filter(line => line.trim());
if (summaryLines.length > 0) {
return summaryLines.join(" ").slice(0, 200);
}
}
return "";
}
function navigateToEvent() {
if (event) {
goto(`/events?id=${nostrIdentifier}`);
}
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable";
}
</script>
{#if nestingLevel >= MAX_NESTING_LEVEL}
<!-- At max nesting level, just show the link -->
<div class="embedded-event-max-nesting min-w-0 overflow-hidden">
<a
href="/events?id={nostrIdentifier}"
class="text-primary-600 dark:text-primary-500 hover:underline break-all"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${nostrIdentifier}`);
}}
>
{nostrIdentifier}
</a>
</div>
{:else if loading}
<!-- Loading state -->
<div class="embedded-event-loading bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 min-w-0 overflow-hidden">
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600 flex-shrink-0"></div>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading event...</span>
</div>
</div>
{:else if error}
<!-- Error state -->
<div class="embedded-event-error bg-red-50 dark:bg-red-900/20 rounded-lg p-3 border border-red-200 dark:border-red-800 min-w-0 overflow-hidden">
<div class="flex items-center space-x-2">
<span class="text-red-600 dark:text-red-400 text-sm flex-shrink-0"></span>
<span class="text-sm text-red-600 dark:text-red-400">Failed to load event</span>
</div>
<a
href="/events?id={nostrIdentifier}"
class="text-primary-600 dark:text-primary-500 hover:underline text-sm mt-1 inline-block break-all"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${nostrIdentifier}`);
}}
>
View event directly
</a>
</div>
{:else if event}
<!-- Event content -->
<div class="embedded-event bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 mb-2 min-w-0 overflow-hidden">
<!-- Event header -->
<div class="flex items-center justify-between mb-3 min-w-0">
<div class="flex items-center space-x-2 min-w-0">
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0">
Kind {event.kind}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
({getEventType(event.kind || 0)})
</span>
{#if event.pubkey}
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">Author:</span>
<div class="min-w-0 flex-1">
{#if toNpub(event.pubkey)}
{@render userBadge(
toNpub(event.pubkey) as string,
authorDisplayName,
)}
{:else}
<span class="text-xs text-gray-700 dark:text-gray-300 break-all">
{authorDisplayName || event.pubkey.slice(0, 8)}...{event.pubkey.slice(-4)}
</span>
{/if}
</div>
{/if}
</div>
</div>
<!-- Event title -->
{#if getEventTitle(event)}
<h4 class="font-semibold text-gray-900 dark:text-gray-100 mb-2 break-words">
{getEventTitle(event)}
</h4>
{/if}
<!-- Summary for non-content events -->
{#if event.kind !== 1 && getEventSummary(event)}
<div class="mb-2 min-w-0">
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">
{getEventSummary(event)}
</p>
</div>
{/if}
<!-- Content for text events -->
{#if event.kind === 1 || repostKinds.includes(event.kind)}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{#if repostKinds.includes(event.kind)}
<!-- Repost content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Reposted content:
</div>
{@render parsedContent(event.content.slice(0, 300))}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</div>
{:else}
<!-- Regular text content -->
{@render parsedContent(event.content.slice(0, 300))}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
{/if}
</div>
<!-- Contact list content (kind 3) -->
{:else if event.kind === 3}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
{@const contactData = (() => {
try {
return JSON.parse(event.content);
} catch {
return null;
}
})()}
{#if contactData}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Contact List</span>
{#if contactData.relays}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Relays: {Object.keys(contactData.relays).length}</span>
</div>
{/if}
</div>
{#if contactData.follows}
<div class="mt-2">
<span class="text-xs text-gray-500 dark:text-gray-400">Following: {contactData.follows.length} users</span>
</div>
{/if}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Invalid contact list data
</div>
{/if}
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty contact list
</div>
{/if}
</div>
<!-- Publication index content (kind 30040) -->
{:else if event.kind === 30040}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
{@const indexData = (() => {
try {
return JSON.parse(event.content);
} catch {
return null;
}
})()}
{#if indexData}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Publication Index</span>
{#if indexData.title}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Title: {indexData.title}</span>
</div>
{/if}
{#if indexData.summary}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Summary: {indexData.summary}</span>
</div>
{/if}
{#if indexData.authors}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Authors: {indexData.authors.length}</span>
</div>
{/if}
</div>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Invalid publication index data
</div>
{/if}
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty publication index
</div>
{/if}
</div>
<!-- Publication content (kinds 30041, 30818) -->
{:else if event.kind === 30041 || event.kind === 30818}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">
{event.kind === 30041 ? 'Publication Content' : 'Wiki Content'}
</span>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 300)}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty {event.kind === 30041 ? 'publication' : 'wiki'} content
</div>
{/if}
</div>
<!-- Long-form content (kind 30023) -->
{:else if event.kind === 30023}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Long-form Content</span>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 300)}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty long-form content
</div>
{/if}
</div>
<!-- Reply/Comment content (kind 1111) -->
{:else if event.kind === 1111}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Reply/Comment</span>
</div>
{#if event.content && event.content.trim()}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty reply
</div>
{/if}
</div>
</div>
<!-- Git Issue content (kind 1621) -->
{:else if event.kind === 1621}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Git Issue</span>
{#if event.tags}
{@const subjectTag = event.tags.find(tag => tag[0] === 'subject')}
{#if subjectTag && subjectTag[1]}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Subject: {subjectTag[1]}
</div>
{/if}
{/if}
</div>
{#if event.content && event.content.trim()}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty issue description
</div>
{/if}
</div>
</div>
<!-- Git Comment content (kind 1622) -->
{:else if event.kind === 1622}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Git Comment</span>
</div>
{#if event.content && event.content.trim()}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty comment
</div>
{/if}
</div>
</div>
<!-- Reaction content (kind 7) -->
{:else if event.kind === 7}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Reaction</span>
</div>
{#if event.content && event.content.trim()}
<div class="text-lg">
{event.content}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty reaction
</div>
{/if}
</div>
</div>
<!-- Zap receipt content (kind 9735) -->
{:else if event.kind === 9735}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Zap Receipt</span>
</div>
{#if event.content && event.content.trim()}
{@const zapData = (() => {
try {
return JSON.parse(event.content);
} catch {
return null;
}
})()}
{#if zapData}
<div class="text-xs text-gray-500 dark:text-gray-400">
{#if zapData.amount}
<div>Amount: {zapData.amount} sats</div>
{/if}
{#if zapData.preimage}
<div>Preimage: {zapData.preimage.slice(0, 8)}...</div>
{/if}
{#if zapData.bolt11}
<div>Invoice: {zapData.bolt11.slice(0, 20)}...</div>
{/if}
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 200)}
{#if event.content.length > 200}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
{/if}
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty zap receipt
</div>
{/if}
</div>
</div>
<!-- Image/media content (kind 20) -->
{:else if event.kind === 20}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Image/Media Post</span>
</div>
<!-- Render images from imeta tags -->
{#if event.tags}
{@const imetaTags = event.tags.filter(tag => tag[0] === 'imeta')}
{#if imetaTags.length > 0}
<div class="space-y-2">
{#each imetaTags as imetaTag}
{@const imetaData = (() => {
const data: any = {};
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ')) {
data.url = item.substring(4);
} else if (item.startsWith('dim ')) {
data.dimensions = item.substring(4);
} else if (item.startsWith('m ')) {
data.mimeType = item.substring(2);
} else if (item.startsWith('size ')) {
data.size = item.substring(5);
} else if (item.startsWith('blurhash ')) {
data.blurhash = item.substring(9);
} else if (item.startsWith('x ')) {
data.x = item.substring(2);
}
}
return data;
})()}
{#if imetaData.url && imetaData.mimeType?.startsWith('image/')}
<div class="relative">
<img
src={imetaData.url}
alt="imeta"
class="max-w-full h-auto rounded-lg border border-gray-200 dark:border-gray-700"
style="max-height: 300px; object-fit: cover;"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const fallback = (e.target as HTMLImageElement).nextElementSibling;
if (fallback) fallback.classList.remove('hidden');
}}
/>
<div class="hidden text-xs text-gray-500 dark:text-gray-400 mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">
Image failed to load: {imetaData.url}
</div>
<!-- Image metadata -->
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{#if imetaData.dimensions}
<span class="mr-2">Size: {imetaData.dimensions}</span>
{/if}
{#if imetaData.size}
<span class="mr-2">File: {Math.round(parseInt(imetaData.size) / 1024)}KB</span>
{/if}
{#if imetaData.mimeType}
<span>Type: {imetaData.mimeType}</span>
{/if}
</div>
</div>
{:else if imetaData.url}
<!-- Non-image media -->
<div class="p-3 bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-600 dark:text-gray-400">
<a href={imetaData.url} target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-400 hover:underline">
View Media ({imetaData.mimeType || 'unknown type'})
</a>
</div>
{#if imetaData.size}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Size: {Math.round(parseInt(imetaData.size) / 1024)}KB
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
{/if}
<!-- Text content -->
{#if event.content && event.content.trim()}
<div class="mt-3 prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{/if}
<!-- Alt text -->
{#if event.tags}
{@const altTag = event.tags.find(tag => tag[0] === 'alt')}
{#if altTag && altTag[1]}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
Alt: {altTag[1]}
</div>
{/if}
{/if}
</div>
</div>
<!-- Profile content -->
{:else if event.kind === 0 && profile}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-12 h-12 rounded-full object-cover flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center flex-shrink-0 hidden">
<UserOutline class="w-6 h-6 text-gray-600 dark:text-gray-300" />
</div>
{:else}
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<UserOutline class="w-6 h-6 text-gray-600 dark:text-gray-300" />
</div>
{/if}
{#if profile.about}
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">
{profile.about.slice(0, 200)}
{#if profile.about.length > 200}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</p>
{/if}
</div>
<!-- Generic content for other event kinds -->
{:else if event.content}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 300)}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
No content
</div>
{/if}
<!-- Event identifiers -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 min-w-0 overflow-hidden">
<div class="flex flex-wrap gap-2 text-xs min-w-0">
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">ID:</span>
<a
href="/events?id={event!.id}"
class="font-mono text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${event!.id}`);
}}
>
{event!.id.slice(0, 8)}...{event!.id.slice(-4)}
</a>
{#if isAddressableEvent(event!)}
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">Address:</span>
<span class="font-mono text-gray-700 dark:text-gray-300 break-all">
{getNaddrUrl(event!).slice(0, 12)}...{getNaddrUrl(event!).slice(-8)}
</span>
{/if}
</div>
</div>
</div>
{/if}

311
src/lib/components/embedded_events/EmbeddedSnippets.svelte

@ -0,0 +1,311 @@
<script module lang="ts">
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { NDKRelaySetFromNDK, toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { get } from "svelte/store";
import { searchRelays } from "$lib/consts";
import { userStore, type UserState } from "$lib/stores/userStore";
import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { nip19 } from "nostr-tools";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import type NDK from "@nostr-dev-kit/ndk";
export {
parsedContent,
repostContent,
quotedContent,
truncateContent,
truncateRenderedContent,
getNotificationType,
fetchAuthorProfiles
};
/**
* Truncates content to a specified length
*/
function truncateContent(content: string, maxLength: number = 300): string {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + "...";
}
/**
* Truncates rendered HTML content while preserving quote boxes
*/
function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string {
if (renderedHtml.length <= maxLength) return renderedHtml;
const hasQuoteBoxes = renderedHtml.includes('jump-to-message');
if (hasQuoteBoxes) {
const quoteBoxPattern = /<div class="block w-fit my-2 px-3 py-2 bg-gray-200[^>]*onclick="window\.dispatchEvent\(new CustomEvent\('jump-to-message'[^>]*>[^<]*<\/div>/g;
const quoteBoxes = renderedHtml.match(quoteBoxPattern) || [];
let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||');
if (textOnly.length > maxLength) {
const availableLength = maxLength - (quoteBoxes.join('').length);
if (availableLength > 50) {
textOnly = textOnly.slice(0, availableLength) + "...";
} else {
textOnly = textOnly.slice(0, 50) + "...";
}
}
let result = textOnly;
quoteBoxes.forEach(box => {
result = result.replace('|||QUOTEBOX|||', box);
});
return result;
} else {
if (renderedHtml.includes('<')) {
const truncated = renderedHtml.slice(0, maxLength);
const lastTagStart = truncated.lastIndexOf('<');
const lastTagEnd = truncated.lastIndexOf('>');
if (lastTagStart > lastTagEnd) {
return renderedHtml.slice(0, lastTagStart) + "...";
}
return truncated + "...";
} else {
return renderedHtml.slice(0, maxLength) + "...";
}
}
}
/**
* Gets notification type based on event kind
*/
function getNotificationType(event: NDKEvent): string {
switch (event.kind) {
case 1: return "Reply";
case 1111: return "Custom Reply";
case 9802: return "Highlight";
case 6: return "Repost";
case 16: return "Generic Repost";
case 24: return "Public Message";
default: return `Kind ${event.kind}`;
}
}
/**
* Fetches author profiles for a list of events
*/
async function fetchAuthorProfiles(events: NDKEvent[], ndk: NDK): Promise<Map<string, { name?: string; displayName?: string; picture?: string }>> {
const authorProfiles = new Map<string, { name?: string; displayName?: string; picture?: string }>();
const uniquePubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) uniquePubkeys.add(event.pubkey);
});
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Try cache first
let profile = await getUserMetadata(npub, ndk, false);
if (profile && (profile.name || profile.displayName || profile.picture)) {
authorProfiles.set(pubkey, profile);
return;
}
// Try search relays
for (const relay of searchRelays) {
try {
if (!ndk) break;
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [pubkey] },
undefined,
relaySet
);
if (profileEvent) {
const profileData = JSON.parse(profileEvent.content);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
return;
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from ${relay}:`, error);
}
}
// Try all available relays as fallback
try {
if (!ndk) return;
const userStoreValue: UserState = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [pubkey] },
undefined,
ndkRelaySet
);
if (profileEvent) {
const profileData = JSON.parse(profileEvent.content);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
}
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error);
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Error processing profile for ${pubkey}:`, error);
}
});
await Promise.all(profilePromises);
return authorProfiles;
}
async function findQuotedMessage(eventId: string, publicMessages: NDKEvent[], ndk: NDK): Promise<NDKEvent | undefined> {
// Validate eventId format (should be 64 character hex string)
const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId);
if (!isValidEventId) return undefined;
// First try to find in local messages
let quotedMessage = publicMessages.find(msg => msg.id === eventId);
// If not found locally, fetch from relays
if (!quotedMessage) {
try {
if (ndk) {
const userStoreValue: UserState = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const fetchedEvent = await ndk.fetchEvent({ ids: [eventId], limit: 1 }, undefined, ndkRelaySet);
quotedMessage = fetchedEvent || undefined;
}
}
} catch (error) {
console.warn(`[findQuotedMessage] Failed to fetch quoted event ${eventId}:`, error);
}
}
return quotedMessage;
}
</script>
{#snippet parsedContent(content: string)}
{#await parseEmbeddedMarkup(content, 0) then parsed}
{@html parsed}
{/await}
{/snippet}
{#snippet repostContent(content: string)}
{@const originalEvent = (() => {
try {
return JSON.parse(content);
} catch {
return null;
}
})()}
{#if originalEvent}
{@const originalContent = originalEvent.content || ""}
{@const originalAuthor = originalEvent.pubkey || ""}
{@const originalCreatedAt = originalEvent.created_at || 0}
{@const originalKind = originalEvent.kind || 1}
{@const formattedDate = originalCreatedAt ? new Date(originalCreatedAt * 1000).toLocaleDateString() : "Unknown date"}
{@const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown"}
<div class="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 my-2">
<!-- Event header -->
<div class="flex items-center justify-between mb-3 min-w-0">
<div class="flex items-center space-x-2 min-w-0">
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0">
Kind {originalKind}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
(repost)
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">Author:</span>
<span class="text-xs text-gray-700 dark:text-gray-300 font-mono">
{shortAuthor}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formattedDate}
</span>
</div>
</div>
<!-- Reposted content -->
<div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
{#await parseEmbeddedMarkup(originalContent, 0) then parsedOriginalContent}
{@html parsedOriginalContent}
{/await}
</div>
</div>
{:else}
{#await parseEmbeddedMarkup(content, 0) then parsedContent}
{@html parsedContent}
{/await}
{/if}
{/snippet}
{#snippet quotedContent(message: NDKEvent, publicMessages: NDKEvent[], ndk: NDK)}
{@const qTags = message.getMatchingTags("q")}
{#if qTags.length > 0}
{@const qTag = qTags[0]}
{@const eventId = qTag[1]}
{#if eventId}
{#await findQuotedMessage(eventId, publicMessages, ndk) then quotedMessage}
{#if quotedMessage}
{@const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"}
{#await parseEmbeddedMarkup(quotedContent, 0) then parsedContent}
<button type="button" class="block text-left w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick={() => window.dispatchEvent(new CustomEvent('jump-to-message', { detail: eventId }))}>
{@html parsedContent}
</button>
{/await}
{:else}
{@const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId)}
{#if isValidEventId}
{@const nevent = (() => {
try {
return nip19.neventEncode({ id: eventId });
} catch (error) {
console.warn(`[quotedContent] Failed to encode nevent for ${eventId}:`, error);
return null;
}
})()}
{#if nevent}
<button type="button" class="block text-left w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick={() => window.location.href=`/events?id=${nevent}`}>
Quoted message not found. Click to view event {eventId.slice(0, 8)}...
</button>
{:else}
<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">
Quoted message not found. Event ID: {eventId.slice(0, 8)}...
</div>
{/if}
{:else}
<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">
Invalid quoted message reference
</div>
{/if}
{/if}
{/await}
{/if}
{/if}
{/snippet}

162
src/lib/components/event_input/EventForm.svelte

@ -0,0 +1,162 @@
<script lang="ts">
import { Tooltip } from "flowbite-svelte";
import type { EventData, TagData, ValidationResult } from "./types";
import { validateEvent } from "./validation";
// AI-NOTE: 2025-01-24 - EventForm component handles basic form inputs and validation
// This component focuses on event kind and content, with validation feedback
let {
eventData = $bindable(),
tags,
onvalidate,
}: {
eventData: EventData;
tags: TagData[];
onvalidate: (isValid: boolean, error?: string, warning?: string) => void;
} = $props();
let validationError = $state<string | null>(null);
let validationWarning = $state<string | null>(null);
/**
* Validates the current form data
*/
function validateForm(): ValidationResult {
return validateEvent(eventData, tags);
}
/**
* Handles form validation
*/
function handleValidate(e: Event) {
e.preventDefault();
validationError = null;
validationWarning = null;
const validation = validateForm();
if (!validation.valid) {
validationError = validation.reason || "Validation failed.";
onvalidate(false, validation.reason || "Validation failed.");
return;
}
if (validation.warning) {
validationWarning = validation.warning;
onvalidate(true, undefined, validation.warning);
} else {
onvalidate(true);
}
}
/**
* Validates kind input
*/
function isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
/**
* Gets kind description
*/
function getKindDescription(kind: number): string {
switch (kind) {
case 1:
return "Text Note";
case 30023:
return "Long-form Content";
case 30040:
return "Publication Index";
case 30041:
return "Publication Section";
case 30818:
return "AsciiDoc Document";
default:
return "Custom Event";
}
}
</script>
<form class="space-y-4" onsubmit={handleValidate}>
<!-- Event Kind -->
<div>
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-kind">
Kind
</label>
<input
id="event-kind"
type="number"
class="input input-bordered w-full"
bind:value={eventData.kind}
min="0"
max="65535"
required
/>
{#if !isValidKind(eventData.kind)}
<div class="text-red-600 dark:text-red-400 text-sm mt-1">
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if isValidKind(eventData.kind)}
<div class="flex items-center gap-2 mt-1">
<span class="text-sm text-gray-600 dark:text-gray-400">
{getKindDescription(eventData.kind)}
</span>
{#if eventData.kind === 30040}
<Tooltip class="tooltip-leather" type="auto" placement="bottom">
<button
type="button"
class="w-6 h-6 rounded-full bg-blue-500 hover:bg-blue-600 text-white flex items-center justify-center text-sm font-bold border border-blue-600 shadow-sm"
title="Learn more about Publication Index events"
>
?
</button>
<div class="max-w-sm p-2 text-xs">
<strong>30040 - Publication Index:</strong> Events that organize AsciiDoc content into structured publications with metadata tags and section references.
</div>
</Tooltip>
{/if}
</div>
{/if}
</div>
<!-- Event Content -->
<div>
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-content">
Content
</label>
<textarea
id="event-content"
bind:value={eventData.content}
placeholder="Content (start with a header for the title)"
class="textarea textarea-bordered w-full h-40"
required
></textarea>
<!-- Content hints based on kind -->
{#if eventData.kind === 30023}
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use Markdown format for long-form content. Do not use AsciiDoc headers (=).
</div>
{:else if eventData.kind === 30040 || eventData.kind === 30041 || eventData.kind === 30818}
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use AsciiDoc format. Start with a document title (=) and include section headers (==).
</div>
{/if}
</div>
<!-- Validation Messages -->
{#if validationError}
<div class="text-red-600 dark:text-red-400 text-sm">
{validationError}
</div>
{/if}
{#if validationWarning}
<div class="text-yellow-600 dark:text-yellow-400 text-sm">
Warning: {validationWarning}
</div>
{/if}
</form>

172
src/lib/components/event_input/EventPreview.svelte

@ -0,0 +1,172 @@
<script lang="ts">
import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata";
import { build30040EventSet } from "$lib/utils/event_input_utils";
import type { EventData, TagData, EventPreview } from "./types";
// AI-NOTE: 2025-01-24 - EventPreview component shows a preview of the event that will be published
// This component generates a preview based on the current form data
let {
ndk,
eventData,
tags,
showJsonPreview,
onTogglePreview,
}: {
ndk: any;
eventData: EventData;
tags: TagData[];
showJsonPreview: boolean;
onTogglePreview: () => void;
} = $props();
/**
* Converts TagData array to NDK-compatible format
*/
function convertTagsToNDKFormat(tags: TagData[]): string[][] {
return tags
.filter(tag => tag.key.trim() !== "")
.map(tag => [tag.key, ...tag.values]);
}
/**
* Generates event preview
*/
let eventPreview = $derived.by(() => {
const userState = get(userStore);
const pubkey = userState.pubkey;
if (!pubkey) {
return null;
}
// Build the event data similar to how it's done in publishing
const baseEvent = {
pubkey: String(pubkey),
created_at: eventData.createdAt,
kind: Number(eventData.kind)
};
if (Number(eventData.kind) === 30040) {
// For 30040, we need to show the index event structure
try {
// Convert tags to compatible format (exclude preset tags)
const presetTagKeys = ["version", "d", "title"];
const compatibleTags: [string, string][] = tags
.filter(tag => tag.key.trim() !== "" && !presetTagKeys.includes(tag.key))
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]);
// Create a mock NDK instance for preview
const mockNdk = { sign: async () => ({ sig: "mock_signature" }) };
const { indexEvent } = build30040EventSet(
eventData.content,
compatibleTags,
baseEvent,
mockNdk as any,
);
// Add preset tags from UI (version, d, title)
const finalTags = indexEvent.tags.filter(tag => !presetTagKeys.includes(tag[0]));
const versionTag = tags.find(t => t.key === "version");
const dTag = tags.find(t => t.key === "d");
const titleTag = tags.find(t => t.key === "title");
if (versionTag && versionTag.values[0]) {
finalTags.push(["version", versionTag.values[0]]);
}
if (dTag && dTag.values[0]) {
finalTags.push(["d", dTag.values[0]]);
}
if (titleTag && titleTag.values[0]) {
finalTags.push(["title", titleTag.values[0]]);
}
return {
type: "30040_index_event",
event: {
id: "[will be generated]",
pubkey: String(pubkey),
created_at: eventData.createdAt,
kind: 30040,
tags: finalTags,
content: indexEvent.content,
sig: "[will be generated]"
}
};
} catch (error) {
return {
type: "error",
message: `Failed to generate 30040 preview: ${error instanceof Error ? error.message : "Unknown error"}`
};
}
} else {
// For other event types
let eventTags = convertTagsToNDKFormat(tags);
// For AsciiDoc events, remove metadata from content
let finalContent = eventData.content;
if (eventData.kind === 30040 || eventData.kind === 30041) {
finalContent = removeMetadataFromContent(eventData.content);
}
// Prefix Nostr addresses
const prefixedContent = prefixNostrAddresses(finalContent);
return {
type: "standard_event",
event: {
id: "[will be generated]",
pubkey: String(pubkey),
created_at: eventData.createdAt,
kind: Number(eventData.kind),
tags: eventTags,
content: prefixedContent,
sig: "[will be generated]"
}
};
}
});
</script>
<!-- Event Preview Section -->
<div class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Event Preview</h3>
<button
type="button"
class="btn btn-sm btn-outline btn-secondary"
onclick={onTogglePreview}
>
{showJsonPreview ? 'Hide' : 'Show'} JSON Preview
</button>
</div>
{#if showJsonPreview}
{#if eventPreview}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
{#if eventPreview.type === 'error'}
<div class="text-red-600 dark:text-red-400 text-sm">
{eventPreview.message}
</div>
{:else}
<div class="mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Event Type: {eventPreview.type === '30040_index_event' ? '30040 Publication Index' : 'Standard Event'}
</span>
</div>
<pre class="text-xs bg-white dark:bg-gray-900 p-3 rounded border overflow-x-auto text-gray-800 dark:text-gray-200 font-mono whitespace-pre-wrap">{JSON.stringify(eventPreview.event, null, 2)}</pre>
{/if}
</div>
{:else}
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-700">
<div class="text-yellow-800 dark:text-yellow-200 text-sm">
Please log in to see the event preview.
</div>
</div>
{/if}
{/if}
</div>

342
src/lib/components/event_input/TagManager.svelte

@ -0,0 +1,342 @@
<script lang="ts">
import { extractSmartMetadata, metadataToTags } from "$lib/utils/asciidoc_metadata";
import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils";
import type { TagData, PresetTag } from "./types";
// AI-NOTE: 2025-01-24 - TagManager component handles tag management with preset tags
// This component automatically manages preset tags based on event kind and content
let {
tags = $bindable(),
kind,
content,
}: {
tags: TagData[];
kind: number;
content: string;
} = $props();
let removedTags = $state<Set<string>>(new Set());
let extractedMetadata = $state<[string, string][]>([]);
let lastContent = $state("");
let lastKind = $state(0);
// Define preset tags for different event kinds
let presetTags = $derived.by(() => {
const presets: PresetTag[] = [];
// Version tag for 30040 events
if (kind === 30040) {
presets.push({
key: "version",
defaultValue: "1",
required: true,
autoUpdate: false,
description: "Publication version"
});
}
// D-tag and title for addressable events
if (requiresDTag(kind)) {
presets.push({
key: "d",
defaultValue: "default-title",
required: true,
autoUpdate: true,
description: "Document identifier (derived from title)"
});
presets.push({
key: "title",
defaultValue: "Default Title",
required: true,
autoUpdate: true,
description: "Document title (extracted from content)"
});
}
return presets;
});
// Extract metadata from content for AsciiDoc events
$effect(() => {
if (kind === 30040 || kind === 30041) {
const { metadata } = extractSmartMetadata(content);
extractedMetadata = metadataToTags(metadata);
} else {
extractedMetadata = [];
}
});
// Manage preset tags automatically
$effect(() => {
// Only run this effect when content or kind changes, not when tags change
if (content === lastContent && kind === lastKind) {
return; // Skip if nothing has changed
}
lastContent = content;
lastKind = kind;
const currentTags = [...tags]; // Create a copy to avoid mutation
const newTags: TagData[] = [];
// Add preset tags
for (const preset of presetTags) {
if (removedTags.has(preset.key)) continue;
let value = preset.defaultValue;
// Auto-update values based on content
if (preset.autoUpdate && content.trim()) {
if (preset.key === "title") {
const { metadata } = extractSmartMetadata(content);
value = metadata.title || preset.defaultValue;
} else if (preset.key === "d") {
const { metadata } = extractSmartMetadata(content);
value = titleToDTag(metadata.title || "") || preset.defaultValue;
}
}
// Find existing tag or create new one
const existingTag = currentTags.find(t => t.key === preset.key);
if (existingTag) {
// For preset tags, always ensure exactly one value
if (preset.autoUpdate) {
newTags.push({
key: preset.key,
values: [value] // Only keep the first (primary) value
});
} else {
newTags.push({
key: preset.key,
values: [existingTag.values[0] || preset.defaultValue] // Keep user value or default
});
}
} else {
newTags.push({
key: preset.key,
values: [value]
});
}
}
// Add non-preset tags (avoid duplicates)
for (const tag of currentTags) {
const isPresetKey = presetTags.some(p => p.key === tag.key);
const alreadyAdded = newTags.some(t => t.key === tag.key);
if (!isPresetKey && !alreadyAdded) {
newTags.push(tag);
}
}
// Ensure there's always an empty tag row for user input
if (newTags.length === 0 || newTags[newTags.length - 1].key !== "") {
newTags.push({ key: "", values: [""] });
}
// Only update if the tags have actually changed
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(currentTags);
if (tagsChanged) {
tags = newTags;
}
});
/**
* Adds a new tag
*/
function addTag(): void {
tags = [...tags, { key: "", values: [""] }];
}
/**
* Removes a tag at the specified index
*/
function removeTag(index: number): void {
const tagKey = tags[index]?.key;
if (tagKey) {
removedTags.add(tagKey);
}
tags = tags.filter((_, i) => i !== index);
}
/**
* Adds a value to a tag
*/
function addTagValue(tagIndex: number): void {
tags = tags.map((tag, i) => {
if (i === tagIndex) {
return { ...tag, values: [...tag.values, ""] };
}
return tag;
});
}
/**
* Removes a value from a tag
*/
function removeTagValue(tagIndex: number, valueIndex: number): void {
tags = tags.map((tag, i) => {
if (i === tagIndex) {
const newValues = tag.values.filter((_, vi) => vi !== valueIndex);
return { ...tag, values: newValues.length > 0 ? newValues : [""] };
}
return tag;
});
}
/**
* Updates a tag key
*/
function updateTagKey(index: number, newKey: string): void {
tags = tags.map((tag, i) => {
if (i === index) {
return { ...tag, key: newKey };
}
return tag;
});
}
/**
* Updates a tag value
*/
function updateTagValue(tagIndex: number, valueIndex: number, newValue: string): void {
tags = tags.map((tag, i) => {
if (i === tagIndex) {
const newValues = [...tag.values];
newValues[valueIndex] = newValue;
return { ...tag, values: newValues };
}
return tag;
});
}
/**
* Checks if a tag is a preset tag
*/
function isPresetTag(tagKey: string): boolean {
return presetTags.some(p => p.key === tagKey);
}
/**
* Gets preset tag info
*/
function getPresetTagInfo(tagKey: string): PresetTag | undefined {
return presetTags.find(p => p.key === tagKey);
}
</script>
<div class="space-y-4">
<label for="tags-container" class="block font-medium mb-1 text-gray-700 dark:text-gray-300">
Tags
</label>
<!-- Extracted Metadata Section -->
{#if extractedMetadata.length > 0}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
Extracted Metadata (from AsciiDoc header)
</h4>
<div class="text-sm text-blue-700 dark:text-blue-300">
{extractedMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')}
</div>
</div>
{/if}
<!-- Tags Container -->
<div id="tags-container" class="space-y-2">
{#each tags as tag, i}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 space-y-2">
<!-- Tag Key Row -->
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Tag:</span>
<input
type="text"
class="input input-bordered flex-1"
placeholder="tag key (e.g., q, p, e)"
value={tag.key}
oninput={(e) => updateTagKey(i, (e.target as HTMLInputElement).value)}
/>
{#if isPresetTag(tag.key)}
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
Preset
</span>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
onclick={() => removeTag(i)}
>
×
</button>
</div>
<!-- Preset Tag Description -->
{#if isPresetTag(tag.key)}
{@const presetInfo = getPresetTagInfo(tag.key)}
{#if presetInfo}
<div class="text-xs text-gray-600 dark:text-gray-400 italic">
{presetInfo.description}
{#if presetInfo.autoUpdate}
(auto-updates from content)
{/if}
</div>
{/if}
{/if}
<!-- Tag Values -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Values:</span>
<button
type="button"
class="btn btn-sm btn-outline btn-primary"
onclick={() => addTagValue(i)}
>
Add Value
</button>
</div>
{#each tag.values as value, valueIndex}
<div class="flex gap-2 items-center">
<span class="text-xs text-gray-500 dark:text-gray-400 min-w-[40px]">
{valueIndex + 1}:
</span>
<input
type="text"
class="input input-bordered flex-1"
placeholder="value"
value={value}
oninput={(e) => updateTagValue(i, valueIndex, (e.target as HTMLInputElement).value)}
/>
{#if tag.values.length > 1}
<button
type="button"
class="btn btn-sm btn-outline btn-error"
onclick={() => removeTagValue(i, valueIndex)}
>
×
</button>
{/if}
</div>
{/each}
</div>
</div>
{/each}
<!-- Add Tag Button -->
<div class="flex justify-end">
<button
type="button"
class="btn btn-primary btn-sm border border-primary-600 px-3 py-1"
onclick={addTag}
>
Add Tag
</button>
</div>
</div>
</div>

277
src/lib/components/event_input/eventServices.ts

@ -0,0 +1,277 @@
/**
* Event publishing and loading services
*/
import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore";
import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { anonymousRelays } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata";
import { build30040EventSet } from "$lib/utils/event_input_utils";
import type { EventData, TagData, PublishResult, LoadEventResult } from "./types";
/**
* Converts TagData array to NDK-compatible format
*/
function convertTagsToNDKFormat(tags: TagData[]): string[][] {
return tags
.filter(tag => tag.key.trim() !== "")
.map(tag => [tag.key, ...tag.values]);
}
/**
* Publishes an event to relays
*/
export async function publishEvent(ndk: any, eventData: EventData, tags: TagData[]): Promise<PublishResult> {
if (!ndk) {
return { success: false, error: "NDK context not available" };
}
const userState = get(userStore);
const pubkey = userState.pubkey;
if (!pubkey) {
return { success: false, error: "User not logged in." };
}
const pubkeyString = String(pubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) {
return { success: false, error: "Invalid public key: must be a 64-character hex string." };
}
const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt };
let events: NDKEvent[] = [];
console.log("Publishing event with kind:", eventData.kind);
console.log("Content length:", eventData.content.length);
console.log("Content preview:", eventData.content.substring(0, 100));
console.log("Tags:", tags);
if (Number(eventData.kind) === 30040) {
console.log("=== 30040 EVENT CREATION START ===");
console.log("Creating 30040 event set with content:", eventData.content);
try {
// Get the current d and title values from the UI
const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || "";
const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || "";
// Convert multi-value tags to the format expected by build30040EventSet
// Filter out d and title tags since we'll add them manually
const compatibleTags: [string, string][] = tags
.filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title")
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]);
const { indexEvent, sectionEvents } = build30040EventSet(
eventData.content,
compatibleTags,
baseEvent,
ndk,
);
// Override the d and title tags with the UI values if they exist
const finalTags = indexEvent.tags.filter(tag => tag[0] !== "d" && tag[0] !== "title");
if (dTagValue) {
finalTags.push(["d", dTagValue]);
}
if (titleTagValue) {
finalTags.push(["title", titleTagValue]);
}
// Update the index event with the correct tags
indexEvent.tags = finalTags;
console.log("Index event:", indexEvent);
console.log("Section events:", sectionEvents);
// Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent];
console.log("Total events to publish:", events.length);
console.log("=== 30040 EVENT CREATION END ===");
} catch (error) {
console.error("Error in build30040EventSet:", error);
return {
success: false,
error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`
};
}
} else {
// Convert multi-value tags to the format expected by NDK
let eventTags = convertTagsToNDKFormat(tags);
// For AsciiDoc events, remove metadata from content
let finalContent = eventData.content;
if (eventData.kind === 30040 || eventData.kind === 30041) {
finalContent = removeMetadataFromContent(eventData.content);
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(finalContent);
// Create event with proper serialization
const eventDataForNDK = {
kind: eventData.kind,
content: prefixedContent,
tags: eventTags,
pubkey: pubkeyString,
created_at: eventData.createdAt,
};
events = [new NDKEventClass(ndk, eventDataForNDK)];
}
let atLeastOne = false;
let relaysPublished: string[] = [];
let lastEventId: string | null = null;
for (let i = 0; i < events.length; i++) {
const event = events[i];
try {
console.log("Publishing event:", {
kind: event.kind,
content: event.content,
tags: event.tags,
hasContent: event.content && event.content.length > 0,
});
// Always sign with a plain object if window.nostr is available
// Create a completely plain object to avoid proxy cloning issues
const plainEvent = {
kind: Number(event.kind),
pubkey: String(event.pubkey),
created_at: Number(
event.created_at ?? Math.floor(Date.now() / 1000),
),
tags: event.tags.map((tag) => tag.map(String)),
content: String(event.content),
};
if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig;
if ("id" in signed) {
event.id = signed.id as string;
}
} else {
await event.sign();
}
// Use direct WebSocket publishing like CommentBox does
const signedEvent = {
...plainEvent,
id: event.id,
sig: event.sig,
};
// Try to publish to relays directly
const relays = [
...anonymousRelays,
...get(activeOutboxRelays),
...get(activeInboxRelays),
];
let published = false;
for (const relayUrl of relays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
relaysPublished.push(relayUrl);
WebSocketPool.instance.release(ws);
resolve();
} else {
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (published) {
atLeastOne = true;
// For 30040, set lastEventId to the index event (last in array)
if (Number(eventData.kind) === 30040) {
if (i === events.length - 1) {
lastEventId = event.id;
}
} else {
lastEventId = event.id;
}
}
} catch (signError) {
console.error("Error signing/publishing event:", signError);
return {
success: false,
error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`
};
}
}
if (atLeastOne) {
return {
success: true,
eventId: lastEventId || undefined,
relays: relaysPublished
};
} else {
return { success: false, error: "Failed to publish to any relay." };
}
}
/**
* Loads an event by its hex ID
*/
export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventResult | null> {
if (!ndk) {
throw new Error("NDK context not available");
}
const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000);
if (foundEvent) {
// Convert NDK event format to our format
const eventData: EventData = {
kind: foundEvent.kind, // Use the actual kind from the event
content: foundEvent.content || "", // Preserve content exactly as-is
createdAt: Math.floor(Date.now() / 1000), // Use current time for replacement
};
// Convert NDK tags format to our format
const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({
key: tag[0] || "",
values: tag.slice(1)
}));
return { eventData, tags };
}
return null;
}

63
src/lib/components/event_input/types.ts

@ -0,0 +1,63 @@
/**
* Type definitions for the EventInput component system
*/
export interface EventData {
kind: number;
content: string;
createdAt: number;
}
export interface TagData {
key: string;
values: string[];
}
export interface ValidationResult {
valid: boolean;
reason?: string;
warning?: string;
}
export interface PublishResult {
success: boolean;
eventId?: string;
relays?: string[];
error?: string;
}
export interface LoadEventResult {
eventData: EventData;
tags: TagData[];
}
export interface EventPreview {
type: 'standard_event' | '30040_index_event' | 'error';
event?: {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
};
message?: string;
}
export interface PresetTag {
key: string;
defaultValue: string;
required: boolean;
autoUpdate: boolean;
description: string;
}
export interface KindConfig {
kind: number;
name: string;
description: string;
presetTags: PresetTag[];
requiresContent: boolean;
contentValidation?: (content: string) => ValidationResult;
}

90
src/lib/components/event_input/validation.ts

@ -0,0 +1,90 @@
/**
* Event validation utilities
*/
import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore";
import type { EventData, TagData, ValidationResult } from "./types";
import {
validateNotAsciidoc,
validateAsciiDoc,
validate30040EventSet,
} from "$lib/utils/event_input_utils";
/**
* Validates an event and its tags
*/
export function validateEvent(eventData: EventData, tags: TagData[]): ValidationResult {
const userState = get(userStore);
const pubkey = userState.pubkey;
if (!pubkey) {
return { valid: false, reason: "Not logged in." };
}
// Content validation - 30040 events don't require content
if (eventData.kind !== 30040 && !eventData.content.trim()) {
return { valid: false, reason: "Content required." };
}
// Kind-specific validation
if (eventData.kind === 30023) {
const v = validateNotAsciidoc(eventData.content);
if (!v.valid) return v;
}
if (eventData.kind === 30040) {
// Check for required tags
const versionTag = tags.find(t => t.key === "version");
const dTag = tags.find(t => t.key === "d");
const titleTag = tags.find(t => t.key === "title");
if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'version' tag." };
}
if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'd' tag." };
}
if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'title' tag." };
}
// Validate content format if present
if (eventData.content.trim()) {
const v = validate30040EventSet(eventData.content);
if (!v.valid) return v;
if (v.warning) return { valid: true, warning: v.warning };
}
}
if (eventData.kind === 30041 || eventData.kind === 30818) {
const v = validateAsciiDoc(eventData.content);
if (!v.valid) return v;
}
return { valid: true };
}
/**
* Validates that a kind is within valid range
*/
export function isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
/**
* Validates that a tag has a valid key
*/
export function isValidTagKey(key: string): boolean {
return key.trim().length > 0;
}
/**
* Validates that a tag has at least one value
*/
export function isValidTag(tag: TagData): boolean {
return isValidTagKey(tag.key) && tag.values.some(v => v.trim().length > 0);
}

88
src/lib/components/publications/Publication.svelte

@ -24,43 +24,67 @@
import TableOfContents from "./TableOfContents.svelte"; import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte"; import type { TableOfContents as TocType } from "./table_of_contents.svelte";
let { rootAddress, publicationType, indexEvent } = $props<{ let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
rootAddress: string; rootAddress: string;
publicationType: string; publicationType: string;
indexEvent: NDKEvent; indexEvent: NDKEvent;
publicationTree: SveltePublicationTree;
toc: TocType;
}>(); }>();
const publicationTree = getContext(
"publicationTree",
) as SveltePublicationTree;
const toc = getContext("toc") as TocType;
// #region Loading // #region Loading
let leaves = $state<Array<NDKEvent | null>>([]); let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state<boolean>(false); let isLoading = $state(false);
let isDone = $state<boolean>(false); let isDone = $state(false);
let lastElementRef = $state<HTMLElement | null>(null); let lastElementRef = $state<HTMLElement | null>(null);
let activeAddress = $state<string | null>(null); let activeAddress = $state<string | null>(null);
let loadedAddresses = $state<Set<string>>(new Set());
let hasInitialized = $state(false);
let observer: IntersectionObserver; let observer: IntersectionObserver;
async function loadMore(count: number) { async function loadMore(count: number) {
if (!publicationTree) {
console.warn("[Publication] publicationTree is not available");
return;
}
console.log(`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`);
isLoading = true; isLoading = true;
try {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const iterResult = await publicationTree.next(); const iterResult = await publicationTree.next();
const { done, value } = iterResult; const { done, value } = iterResult;
if (done) { if (done) {
console.log("[Publication] Iterator done, no more events");
isDone = true; isDone = true;
break; break;
} }
if (value) {
const address = value.tagAddress();
console.log(`[Publication] Got event: ${address} (${value.id})`);
if (!loadedAddresses.has(address)) {
loadedAddresses.add(address);
leaves.push(value); leaves.push(value);
console.log(`[Publication] Added event: ${address}`);
} else {
console.warn(`[Publication] Duplicate event detected: ${address}`);
} }
} else {
console.log("[Publication] Got null event");
leaves.push(null);
}
}
} catch (error) {
console.error("[Publication] Error loading more content:", error);
} finally {
isLoading = false; isLoading = false;
console.log(`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`);
}
} }
function setLastElementRef(el: HTMLElement, i: number) { function setLastElementRef(el: HTMLElement, i: number) {
@ -85,6 +109,34 @@
// #endregion // #endregion
// AI-NOTE: 2025-01-24 - Combined effect to handle publicationTree changes and initial loading
// This prevents conflicts between separate effects that could cause duplicate loading
$effect(() => {
if (publicationTree) {
// Reset state when publicationTree changes
leaves = [];
isLoading = false;
isDone = false;
lastElementRef = null;
loadedAddresses = new Set();
hasInitialized = false;
// Reset the publication tree iterator to prevent duplicate events
if (typeof publicationTree.resetIterator === 'function') {
publicationTree.resetIterator();
}
// AI-NOTE: 2025-01-24 - Use setTimeout to ensure iterator reset completes before loading
// This prevents race conditions where loadMore is called before the iterator is fully reset
setTimeout(() => {
// Load initial content after reset
console.log("[Publication] Loading initial content after reset");
hasInitialized = true;
loadMore(12);
}, 0);
}
});
// #region Columns visibility // #region Columns visibility
let currentBlog: null | string = $state(null); let currentBlog: null | string = $state(null);
@ -175,14 +227,17 @@
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone) { if (entry.isIntersecting && !isLoading && !isDone && publicationTree) {
loadMore(1); loadMore(1);
} }
}); });
}, },
{ threshold: 0.5 }, { threshold: 0.5 },
); );
loadMore(12);
// AI-NOTE: 2025-01-24 - Removed duplicate loadMore call
// Initial content loading is handled by the $effect that watches publicationTree
// This prevents duplicate loading when both onMount and $effect trigger
return () => { return () => {
observer.disconnect(); observer.disconnect();
@ -207,11 +262,12 @@
/> />
<TableOfContents <TableOfContents
{rootAddress} {rootAddress}
{toc}
depth={2} depth={2}
onSectionFocused={(address: string) => onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)} publicationTree.setBookmark(address)}
onLoadMore={() => { onLoadMore={() => {
if (!isLoading && !isDone) { if (!isLoading && !isDone && publicationTree) {
loadMore(4); loadMore(4);
} }
}} }}
@ -241,6 +297,8 @@
{rootAddress} {rootAddress}
{leaves} {leaves}
{address} {address}
{publicationTree}
{toc}
ref={(el) => onPublicationSectionMounted(el, address)} ref={(el) => onPublicationSectionMounted(el, address)}
/> />
{/if} {/if}
@ -249,7 +307,7 @@
{#if isLoading} {#if isLoading}
<Button disabled color="primary">Loading...</Button> <Button disabled color="primary">Loading...</Button>
{:else if !isDone} {:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button> <Button color="primary" onclick={() => loadMore(1)}>Show More</Button>
{:else} {:else}
<p class="text-gray-500 dark:text-gray-400"> <p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication. You've reached the end of the publication.
@ -300,6 +358,8 @@
{rootAddress} {rootAddress}
{leaves} {leaves}
address={leaf.tagAddress()} address={leaf.tagAddress()}
{publicationTree}
{toc}
ref={(el) => setLastElementRef(el, i)} ref={(el) => setLastElementRef(el, i)}
/> />

336
src/lib/components/publications/PublicationFeed.svelte

@ -1,24 +1,30 @@
<script lang="ts"> <script lang="ts">
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { filterValidIndexEvents, debounceAsync } from "$lib/utils"; import { filterValidIndexEvents, debounceAsync } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte"; import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte"; import ArticleHeader from "./PublicationHeader.svelte";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { import {
getMatchingTags, getMatchingTags,
toNpub,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache"; import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache"; import { indexEventCache } from "$lib/utils/indexEventCache";
import { isValidNip05Address } from "$lib/utils/search_utility"; import { isValidNip05Address } from "$lib/utils/search_utility";
import { userStore } from "$lib/stores/userStore.ts";
import { nip19 } from "nostr-tools";
const props = $props<{ const props = $props<{
searchQuery?: string; searchQuery?: string;
showOnlyMyPublications?: boolean;
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void; onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>(); }>();
const ndk = getNdkContext();
// Component state // Component state
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
@ -27,15 +33,53 @@
let loading: boolean = $state(true); let loading: boolean = $state(true);
let hasInitialized = $state(false); let hasInitialized = $state(false);
let fallbackTimeout: ReturnType<typeof setTimeout> | null = null; let fallbackTimeout: ReturnType<typeof setTimeout> | null = null;
let gridContainer: HTMLElement;
// Relay management // Relay management
let allRelays: string[] = $state([]); let allRelays: string[] = $state([]);
let ndk = $derived($ndkInstance);
// Event management // Event management
let allIndexEvents: NDKEvent[] = $state([]); let allIndexEvents: NDKEvent[] = $state([]);
// Calculate the number of columns based on window width
let columnCount = $state(1);
let publicationsToDisplay = $state(10);
// Update column count and publications when window resizes
$effect(() => {
if (typeof window !== 'undefined') {
const width = window.innerWidth;
let newColumnCount = 1;
if (width >= 1280) newColumnCount = 4; // xl:grid-cols-4
else if (width >= 1024) newColumnCount = 3; // lg:grid-cols-3
else if (width >= 768) newColumnCount = 2; // md:grid-cols-2
if (columnCount !== newColumnCount) {
columnCount = newColumnCount;
publicationsToDisplay = newColumnCount * 10;
// Update the view immediately when column count changes
if (allIndexEvents.length > 0) {
let source = allIndexEvents;
// Apply user filter first
source = filterEventsByUser(source);
// Then apply search filter if query exists
if (props.searchQuery?.trim()) {
source = filterEventsBySearch(source);
}
eventsInView = source.slice(0, publicationsToDisplay);
endOfFeed = eventsInView.length >= source.length;
}
}
}
});
// Initialize relays and fetch events // Initialize relays and fetch events
// AI-NOTE: This function is called when the component mounts and when relay configuration changes
// It ensures that events are fetched from the current set of active relays
async function initializeAndFetch() { async function initializeAndFetch() {
if (!ndk) { if (!ndk) {
console.debug('[PublicationFeed] No NDK instance available'); console.debug('[PublicationFeed] No NDK instance available');
@ -56,6 +100,17 @@
if (newRelays.length === 0) { if (newRelays.length === 0) {
console.debug('[PublicationFeed] No relays available, waiting...'); console.debug('[PublicationFeed] No relays available, waiting...');
// Set up a retry mechanism when relays become available
const unsubscribe = activeInboxRelays.subscribe((relays) => {
if (relays.length > 0 && !hasInitialized) {
console.debug('[PublicationFeed] Relays now available, retrying initialization');
unsubscribe();
setTimeout(() => {
hasInitialized = true;
initializeAndFetch();
}, 1000);
}
});
return; return;
} }
@ -70,11 +125,12 @@
} }
} }
// Watch for relay store changes // Watch for relay store changes and user authentication state
$effect(() => { $effect(() => {
const inboxRelays = $activeInboxRelays; const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays; const outboxRelays = $activeOutboxRelays;
const newRelays = [...inboxRelays, ...outboxRelays]; const newRelays = [...inboxRelays, ...outboxRelays];
const userState = $userStore;
if (newRelays.length > 0 && !hasInitialized) { if (newRelays.length > 0 && !hasInitialized) {
console.debug('[PublicationFeed] Relays available, initializing'); console.debug('[PublicationFeed] Relays available, initializing');
@ -93,6 +149,18 @@
initializeAndFetch(); initializeAndFetch();
}, 3000); }, 3000);
} }
} else if (hasInitialized && newRelays.length > 0) {
// AI-NOTE: Re-fetch events when user authentication state changes or relays are updated
// This ensures that when a user logs in and their relays are loaded, we fetch events from those relays
const currentRelaysString = allRelays.sort().join(',');
const newRelaysString = newRelays.sort().join(',');
if (currentRelaysString !== newRelaysString) {
console.debug('[PublicationFeed] Relay configuration changed, re-fetching events');
// Clear cache to force fresh fetch from new relays
indexEventCache.clear();
setTimeout(() => initializeAndFetch(), 0);
}
} }
}); });
@ -121,8 +189,8 @@
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`, `[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
); );
allIndexEvents = cachedEvents; allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= publicationsToDisplay;
loading = false; loading = false;
return; return;
} }
@ -210,8 +278,8 @@
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Update the view immediately with new events // Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= publicationsToDisplay;
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`); console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`);
} }
@ -236,15 +304,109 @@
indexEventCache.set(allRelays, allIndexEvents); indexEventCache.set(allRelays, allIndexEvents);
// Final update to ensure we have the latest view // Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= publicationsToDisplay;
loading = false; loading = false;
} }
// Function to convert various Nostr identifiers to npub using the utility function
const convertToNpub = (input: string): string | null => {
const result = toNpub(input);
if (!result) {
console.debug("[PublicationFeed] Failed to convert to npub:", input);
}
return result;
};
// Function to filter events by npub (author or p tags)
const filterEventsByNpub = (events: NDKEvent[], npub: string): NDKEvent[] => {
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
console.debug("[PublicationFeed] Invalid npub format:", npub);
return events;
}
const pubkey = decoded.data.toLowerCase();
console.debug("[PublicationFeed] Filtering events for npub:", npub, "pubkey:", pubkey);
const filtered = events.filter((event) => {
// Check if user is the author of the event
const eventPubkey = event.pubkey.toLowerCase();
const isAuthor = eventPubkey === pubkey;
// Check if user is listed in "p" tags (participants/contributors)
const pTags = getMatchingTags(event, "p");
const isInPTags = pTags.some(tag => tag[1]?.toLowerCase() === pubkey);
const matches = isAuthor || isInPTags;
if (matches) {
console.debug("[PublicationFeed] Event matches npub filter:", {
id: event.id,
eventPubkey,
searchPubkey: pubkey,
isAuthor,
isInPTags,
pTags: pTags.map(tag => tag[1])
});
}
return matches;
});
console.debug("[PublicationFeed] Events after npub filtering:", filtered.length);
return filtered;
} catch (error) {
console.debug("[PublicationFeed] Error filtering by npub:", npub, error);
return events;
}
};
// Function to filter events by current user's pubkey
const filterEventsByUser = (events: NDKEvent[]) => {
if (!props.showOnlyMyPublications) return events;
const currentUser = $userStore;
if (!currentUser.signedIn || !currentUser.pubkey) {
console.debug("[PublicationFeed] User not signed in or no pubkey, showing all events");
return events;
}
const userPubkey = currentUser.pubkey.toLowerCase();
console.debug("[PublicationFeed] Filtering events for user:", userPubkey);
const filtered = events.filter((event) => {
// Check if user is the author of the event
const eventPubkey = event.pubkey.toLowerCase();
const isAuthor = eventPubkey === userPubkey;
// Check if user is listed in "p" tags (participants/contributors)
const pTags = getMatchingTags(event, "p");
const isInPTags = pTags.some(tag => tag[1]?.toLowerCase() === userPubkey);
const matches = isAuthor || isInPTags;
if (matches) {
console.debug("[PublicationFeed] Event matches user filter:", {
id: event.id,
eventPubkey,
userPubkey,
isAuthor,
isInPTags,
pTags: pTags.map(tag => tag[1])
});
}
return matches;
});
console.debug("[PublicationFeed] Events after user filtering:", filtered.length);
return filtered;
};
// Function to filter events based on search query // Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => { const filterEventsBySearch = (events: NDKEvent[]) => {
if (!props.searchQuery) return events; if (!props.searchQuery) return events;
const query = props.searchQuery.toLowerCase(); const query = props.searchQuery.trim();
console.debug( console.debug(
"[PublicationFeed] Filtering events with query:", "[PublicationFeed] Filtering events with query:",
query, query,
@ -261,6 +423,27 @@
return cachedResult.events; return cachedResult.events;
} }
// AI-NOTE: Check if the query is a Nostr identifier (npub, hex, nprofile)
const npub = convertToNpub(query);
if (npub) {
console.debug("[PublicationFeed] Query is a Nostr identifier, filtering by npub:", npub);
const filtered = filterEventsByNpub(events, npub);
// Cache the filtered results
const result = {
events: filtered,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "publication",
searchTerm: query,
};
searchCache.set("publication", query, result);
return filtered;
}
// Check if the query is a NIP-05 address // Check if the query is a NIP-05 address
const isNip05Query = isValidNip05Address(query); const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query); console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
@ -276,7 +459,7 @@
// For NIP-05 queries, only match against NIP-05 tags // For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) { if (isNip05Query) {
const matches = nip05 === query; const matches = nip05 === query.toLowerCase();
if (matches) { if (matches) {
console.debug("[PublicationFeed] Event matches NIP-05 search:", { console.debug("[PublicationFeed] Event matches NIP-05 search:", {
id: event.id, id: event.id,
@ -288,11 +471,12 @@
} }
// For regular queries, match against all fields // For regular queries, match against all fields
const queryLower = query.toLowerCase();
const matches = const matches =
title.includes(query) || title.includes(queryLower) ||
authorName.includes(query) || authorName.includes(queryLower) ||
authorPubkey.includes(query) || authorPubkey.includes(queryLower) ||
nip05.includes(query); nip05.includes(queryLower);
if (matches) { if (matches) {
console.debug("[PublicationFeed] Event matches search:", { console.debug("[PublicationFeed] Event matches search:", {
id: event.id, id: event.id,
@ -323,21 +507,62 @@
// Debounced search function // Debounced search function
const debouncedSearch = debounceAsync(async (query: string) => { const debouncedSearch = debounceAsync(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query); console.debug("[PublicationFeed] Search query or user filter changed:", query);
let filtered = allIndexEvents;
// Apply user filter first
filtered = filterEventsByUser(filtered);
// Then apply search filter if query exists
if (query && query.trim()) { if (query && query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents); filtered = filterEventsBySearch(filtered);
eventsInView = filtered.slice(0, 30);
endOfFeed = filtered.length <= 30;
} else {
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
} }
eventsInView = filtered.slice(0, publicationsToDisplay);
endOfFeed = filtered.length <= publicationsToDisplay;
}, 300); }, 300);
// AI-NOTE: Watch for changes in search query and user filter
$effect(() => { $effect(() => {
// Trigger search when either search query or user filter changes
// Also watch for changes in user store to update filter when user logs in/out
debouncedSearch(props.searchQuery); debouncedSearch(props.searchQuery);
}); });
// AI-NOTE: Watch for user authentication state changes to re-fetch events when user logs in/out
$effect(() => {
const userState = $userStore;
if (hasInitialized && userState.signedIn) {
console.debug('[PublicationFeed] User signed in, checking if we need to re-fetch events');
// Check if we have user-specific relays that we haven't fetched from yet
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
const newRelays = [...inboxRelays, ...outboxRelays];
if (newRelays.length > 0) {
const currentRelaysString = allRelays.sort().join(',');
const newRelaysString = newRelays.sort().join(',');
if (currentRelaysString !== newRelaysString) {
console.debug('[PublicationFeed] User logged in with new relays, re-fetching events');
// Clear cache to force fresh fetch from user's relays
indexEventCache.clear();
setTimeout(() => initializeAndFetch(), 0);
}
}
}
});
// AI-NOTE: Watch for changes in the user filter checkbox
$effect(() => {
// Trigger filtering when the user filter checkbox changes
// Access both props to ensure the effect runs when either changes
const searchQuery = props.searchQuery;
const showOnlyMyPublications = props.showOnlyMyPublications;
debouncedSearch(searchQuery);
});
// Emit event count updates // Emit event count updates
$effect(() => { $effect(() => {
if (props.onEventCountUpdate) { if (props.onEventCountUpdate) {
@ -351,15 +576,27 @@
async function loadMorePublications() { async function loadMorePublications() {
loadingMore = true; loadingMore = true;
const current = eventsInView.length; const current = eventsInView.length;
let source = props.searchQuery.trim() let source = allIndexEvents;
? filterEventsBySearch(allIndexEvents)
: allIndexEvents; // Apply user filter first
eventsInView = source.slice(0, current + 30); source = filterEventsByUser(source);
// Then apply search filter if query exists
if (props.searchQuery.trim()) {
source = filterEventsBySearch(source);
}
eventsInView = source.slice(0, current + publicationsToDisplay);
endOfFeed = eventsInView.length >= source.length; endOfFeed = eventsInView.length >= source.length;
loadingMore = false; loadingMore = false;
} }
function getSkeletonIds(): string[] { function getSkeletonIds(): string[] {
// Only access window on client-side
if (typeof window === 'undefined') {
return ['skeleton-0', 'skeleton-1', 'skeleton-2']; // Default fallback for SSR
}
const skeletonHeight = 192; // The height of the card component in pixels (h-48 = 12rem = 192px). const skeletonHeight = 192; // The height of the card component in pixels (h-48 = 12rem = 192px).
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2; const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = []; const skeletonIds = [];
@ -388,14 +625,57 @@
cleanup(); cleanup();
}); });
onMount(async () => { onMount(() => {
console.debug('[PublicationFeed] onMount called'); console.debug('[PublicationFeed] onMount called');
// The effect will handle fetching when relays become available // The effect will handle fetching when relays become available
// Add window resize listener for responsive updates
const handleResize = () => {
if (typeof window !== 'undefined') {
const width = window.innerWidth;
let newColumnCount = 1;
if (width >= 1280) newColumnCount = 4; // xl:grid-cols-4
else if (width >= 1024) newColumnCount = 3; // lg:grid-cols-3
else if (width >= 768) newColumnCount = 2; // md:grid-cols-2
if (columnCount !== newColumnCount) {
columnCount = newColumnCount;
publicationsToDisplay = newColumnCount * 10;
// Update the view immediately when column count changes
if (allIndexEvents.length > 0) {
let source = allIndexEvents;
// Apply user filter first
source = filterEventsByUser(source);
// Then apply search filter if query exists
if (props.searchQuery?.trim()) {
source = filterEventsBySearch(source);
}
eventsInView = source.slice(0, publicationsToDisplay);
endOfFeed = eventsInView.length >= source.length;
}
}
}
};
window.addEventListener('resize', handleResize);
// Initial calculation
handleResize();
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
};
}); });
</script> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<div <div
bind:this={gridContainer}
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full" class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full"
> >
{#if loading && eventsInView.length === 0} {#if loading && eventsInView.length === 0}

2
src/lib/components/publications/PublicationHeader.svelte

@ -35,7 +35,7 @@
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived( let author: string = $derived(
event.getMatchingTags(event, "author")[0]?.[1] ?? "unknown", event.getMatchingTags("author")[0]?.[1] ?? "unknown",
); );
let version: string = $derived( let version: string = $derived(
event.getMatchingTags("version")[0]?.[1] ?? "1", event.getMatchingTags("version")[0]?.[1] ?? "1",

7
src/lib/components/publications/PublicationSection.svelte

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { import {
contentParagraph, contentParagraph,
sectionHeading, sectionHeading,
@ -10,6 +9,7 @@
import type { Asciidoctor, Document } from "asciidoctor"; import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte"; import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor"; import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser"; import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
@ -17,15 +17,18 @@
address, address,
rootAddress, rootAddress,
leaves, leaves,
publicationTree,
toc,
ref, ref,
}: { }: {
address: string; address: string;
rootAddress: string; rootAddress: string;
leaves: Array<NDKEvent | null>; leaves: Array<NDKEvent | null>;
publicationTree: SveltePublicationTree;
toc: TocType;
ref: (ref: HTMLElement) => void; ref: (ref: HTMLElement) => void;
} = $props(); } = $props();
const publicationTree: SveltePublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext("asciidoctor"); const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by( let leafEvent: Promise<NDKEvent | null> = $derived.by(

7
src/lib/components/publications/TableOfContents.svelte

@ -12,15 +12,14 @@
import Self from "./TableOfContents.svelte"; import Self from "./TableOfContents.svelte";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
let { depth, onSectionFocused, onLoadMore } = $props<{ let { depth, onSectionFocused, onLoadMore, toc } = $props<{
rootAddress: string; rootAddress: string;
depth: number; depth: number;
toc: TableOfContents;
onSectionFocused?: (address: string) => void; onSectionFocused?: (address: string) => void;
onLoadMore?: () => void; onLoadMore?: () => void;
}>(); }>();
let toc = getContext("toc") as TableOfContents;
let entries = $derived.by<TocEntry[]>(() => { let entries = $derived.by<TocEntry[]>(() => {
const newEntries = []; const newEntries = [];
for (const [_, entry] of toc.addressMap) { for (const [_, entry] of toc.addressMap) {
@ -175,7 +174,7 @@
btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} {isLastEntry ? 'pb-4' : ''}" btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} {isLastEntry ? 'pb-4' : ''}"
bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)} bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)}
> >
<Self rootAddress={address} depth={childDepth} {onSectionFocused} {onLoadMore} /> <Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} />
</SidebarDropdownWrapper> </SidebarDropdownWrapper>
{/if} {/if}
{/each} {/each}

37
src/lib/components/publications/table_of_contents.svelte.ts

@ -159,7 +159,7 @@ export class TableOfContents {
// Handle any other nodes that have already been resolved in parallel. // Handle any other nodes that have already been resolved in parallel.
await Promise.all( await Promise.all(
Array.from(this.#publicationTree.resolvedAddresses).map((address) => Array.from(this.#publicationTree.resolvedAddresses).map((address) =>
this.#buildTocEntryFromResolvedNode(address), this.#buildTocEntryFromResolvedNode(address)
), ),
); );
@ -219,7 +219,9 @@ export class TableOfContents {
this.addressMap.set(childAddress, childEntry); this.addressMap.set(childAddress, childEntry);
} }
await this.#matchChildrenToTagOrder(entry); // AI-NOTE: 2025-01-24 - Removed redundant sorting since the publication tree already preserves 'a' tag order
// The children are already in the correct order from the publication tree
// await this.#matchChildrenToTagOrder(entry);
entry.childrenResolved = true; entry.childrenResolved = true;
}; };
@ -253,35 +255,8 @@ export class TableOfContents {
return entry; return entry;
} }
/** // AI-NOTE: 2025-01-24 - Removed #matchChildrenToTagOrder method since the publication tree already preserves 'a' tag order
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding // The children are already in the correct order from the publication tree, so no additional sorting is needed
* Nostr index event.
*
* @param entry The ToC entry to reorder.
*
* This function has a time complexity of `O(n log n)`, where `n` is the number of children the
* parent event has. Average size of `n` is small enough to be negligible.
*/
async #matchChildrenToTagOrder(entry: TocEntry) {
const parentEvent = await this.#publicationTree.getEvent(entry.address);
if (parentEvent?.kind === indexKind) {
const tagOrder = parentEvent.getMatchingTags("a").map((tag) => tag[1]);
const addressToOrdinal = new Map<string, number>();
// Build map of addresses to their ordinals from tag order
tagOrder.forEach((address, index) => {
addressToOrdinal.set(address, index);
});
entry.children.sort((a, b) => {
const aOrdinal =
addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER;
const bOrdinal =
addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER;
return aOrdinal - bOrdinal;
});
}
}
#buildTocEntryFromResolvedNode(address: string) { #buildTocEntryFromResolvedNode(address: string) {
if (this.addressMap.has(address)) { if (this.addressMap.has(address)) {

28
src/lib/components/util/ArticleNav.svelte

@ -12,6 +12,8 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { indexKind } from "$lib/consts";
let { publicationType, indexEvent } = $props<{ let { publicationType, indexEvent } = $props<{
rootId: any; rootId: any;
@ -27,7 +29,7 @@
indexEvent.getMatchingTags("p")[0]?.[1] ?? null, indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
); );
let isLeaf: boolean = $derived(indexEvent.kind === 30041); let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let isIndexEvent: boolean = $derived(indexEvent.kind === 30040); let isIndexEvent: boolean = $derived(indexEvent.kind === indexKind);
let lastScrollY = $state(0); let lastScrollY = $state(0);
let isVisible = $state(true); let isVisible = $state(true);
@ -105,11 +107,22 @@
} }
} }
// Check if user came from visualization page
let cameFromVisualization = $derived.by(() => {
const url = $page.url;
return url.searchParams.has('from') && url.searchParams.get('from') === 'visualize';
});
function visualizePublication() { function visualizePublication() {
const eventId = indexEvent.id; const eventId = indexEvent.id;
goto(`/visualize?event=${eventId}`); goto(`/visualize?event=${eventId}`);
} }
function returnToVisualization() {
// Go back to visualization page
goto('/visualize');
}
let unsubscribe: () => void; let unsubscribe: () => void;
onMount(() => { onMount(() => {
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
@ -194,6 +207,18 @@
<span class="hidden sm:inline">Discussion</span> <span class="hidden sm:inline">Discussion</span>
</Button> </Button>
{/if} {/if}
{#if cameFromVisualization}
<Button
class="btn-leather !w-auto"
outline={true}
onclick={returnToVisualization}
title="Return to visualization"
>
<CaretLeftOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Return to Visualization</span
>
</Button>
{:else if isIndexEvent}
<Button <Button
class="btn-leather !w-auto" class="btn-leather !w-auto"
outline={true} outline={true}
@ -204,6 +229,7 @@
class="hidden sm:inline">Visualize Publication</span class="hidden sm:inline">Visualize Publication</span
> >
</Button> </Button>
{/if}
</div> </div>
</div> </div>
</nav> </nav>

6
src/lib/components/util/CardActions.svelte

@ -12,6 +12,7 @@
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import LazyImage from "$components/util/LazyImage.svelte";
// Component props // Component props
let { event } = $props<{ event: NDKEvent }>(); let { event } = $props<{ event: NDKEvent }>();
@ -191,10 +192,11 @@
<div <div
class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden" class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
> >
<img <LazyImage
src={image} src={image}
alt="Publication cover" alt="Publication cover"
class="rounded w-full h-full object-cover" eventId={event.id}
className="rounded w-full h-full object-cover"
/> />
</div> </div>
{/if} {/if}

6
src/lib/components/util/ContainingIndexes.svelte

@ -5,12 +5,14 @@
import { findContainingIndexEvents } from "$lib/utils/event_search"; import { findContainingIndexEvents } from "$lib/utils/event_search";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils"; import { naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
let { event } = $props<{ let { event } = $props<{
event: NDKEvent; event: NDKEvent;
}>(); }>();
const ndk = getNdkContext();
let containingIndexes = $state<NDKEvent[]>([]); let containingIndexes = $state<NDKEvent[]>([]);
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -25,7 +27,7 @@
error = null; error = null;
try { try {
containingIndexes = await findContainingIndexEvents(event); containingIndexes = await findContainingIndexEvents(event, ndk);
console.log( console.log(
"[ContainingIndexes] Found containing indexes:", "[ContainingIndexes] Found containing indexes:",
containingIndexes.length, containingIndexes.length,

4
src/lib/components/util/Details.svelte

@ -62,7 +62,7 @@
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<!-- Index author badge --> <!-- Index author badge -->
<P class="text-base font-normal" <P class="text-base font-normal"
>{@render userBadge(event.pubkey, author)}</P >{@render userBadge(event.pubkey, undefined)}</P
> >
<CardActions {event}></CardActions> <CardActions {event}></CardActions>
</div> </div>
@ -124,7 +124,7 @@
{#each hashtags as tag} {#each hashtags as tag}
<button <button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)} onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="text-sm hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer" class="text-sm hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer mr-2"
>#{tag}</button >#{tag}</button
> >
{/each} {/each}

7
src/lib/components/util/Interactions.svelte

@ -8,15 +8,16 @@
import ZapOutline from "$components/util/ZapOutline.svelte"; import ZapOutline from "$components/util/ZapOutline.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { ndkInstance } from "$lib/ndk";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import { getNdkContext } from "$lib/ndk";
const { const {
rootId, rootId,
event,
direction = "row", direction = "row",
} = $props<{ rootId: string; event?: NDKEvent; direction?: string }>(); } = $props<{ rootId: string; event?: NDKEvent; direction?: string }>();
const ndk = getNdkContext();
// Reactive arrays to hold incoming events // Reactive arrays to hold incoming events
let likes: NDKEvent[] = []; let likes: NDKEvent[] = [];
let zaps: NDKEvent[] = []; let zaps: NDKEvent[] = [];
@ -38,7 +39,7 @@
* Returns the subscription for later cleanup. * Returns the subscription for later cleanup.
*/ */
function subscribeCount(kind: number, targetArray: NDKEvent[]) { function subscribeCount(kind: number, targetArray: NDKEvent[]) {
const sub = $ndkInstance.subscribe({ const sub = ndk.subscribe({
kinds: [kind], kinds: [kind],
"#a": [rootId], // Will this work? "#a": [rootId], // Will this work?
}); });

37
src/lib/components/util/Profile.svelte

@ -8,22 +8,21 @@
loginWithAmber, loginWithAmber,
loginWithNpub loginWithNpub
} from "$lib/stores/userStore"; } from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import { import {
ArrowRightToBracketOutline, ArrowRightToBracketOutline,
UserOutline, UserOutline,
FileSearchOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; import { getUserMetadata } from "$lib/utils/nostrUtils";
import { activeInboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>(); const ndk = getNdkContext();
let { isNav = false } = $props<{ isNav?: boolean }>();
// UI state for login functionality // UI state for login functionality
let isLoadingExtension: boolean = $state(false); let isLoadingExtension: boolean = $state(false);
@ -187,8 +186,24 @@
try { try {
console.log("Refreshing profile for npub:", userState.npub); console.log("Refreshing profile for npub:", userState.npub);
// Check if we have relays available
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
if (inboxRelays.length === 0 && outboxRelays.length === 0) {
console.log("Profile: No relays available, will retry when relays become available");
// Set up a retry mechanism when relays become available
const unsubscribe = activeInboxRelays.subscribe((relays) => {
if (relays.length > 0 && !isRefreshingProfile) {
console.log("Profile: Relays now available, retrying profile fetch");
unsubscribe();
setTimeout(() => refreshProfile(), 1000);
}
});
return;
}
// Try using NDK's built-in profile fetching first // Try using NDK's built-in profile fetching first
const ndk = get(ndkInstance);
if (ndk && userState.ndkUser) { if (ndk && userState.ndkUser) {
console.log("Using NDK's built-in profile fetching"); console.log("Using NDK's built-in profile fetching");
const userProfile = await userState.ndkUser.fetchProfile(); const userProfile = await userState.ndkUser.fetchProfile();
@ -220,7 +235,7 @@
// Fallback to getUserMetadata // Fallback to getUserMetadata
console.log("Falling back to getUserMetadata"); console.log("Falling back to getUserMetadata");
const freshProfile = await getUserMetadata(userState.npub, true); // Force fresh fetch const freshProfile = await getUserMetadata(userState.npub, ndk, true); // Force fresh fetch
console.log("Fresh profile data from getUserMetadata:", freshProfile); console.log("Fresh profile data from getUserMetadata:", freshProfile);
// Update the userStore with fresh profile data // Update the userStore with fresh profile data
@ -281,7 +296,7 @@
isLoadingExtension = true; isLoadingExtension = true;
isLoadingAmber = false; isLoadingAmber = false;
try { try {
await loginWithExtension(); await loginWithExtension(ndk);
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage( showResultMessage(
`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`, `❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`,
@ -310,7 +325,7 @@
qrCodeDataUrl = qrCodeDataUrl =
(await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined; (await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined;
const user = await amberSigner.blockUntilReady(); const user = await amberSigner.blockUntilReady();
await loginWithAmber(amberSigner, user); await loginWithAmber(amberSigner, user, ndk);
showQrCode = false; showQrCode = false;
} else { } else {
throw new Error("Failed to generate Nostr Connect URI"); throw new Error("Failed to generate Nostr Connect URI");
@ -328,7 +343,7 @@
const inputNpub = prompt("Enter your npub (public key):"); const inputNpub = prompt("Enter your npub (public key):");
if (inputNpub) { if (inputNpub) {
try { try {
await loginWithNpub(inputNpub); await loginWithNpub(inputNpub, ndk);
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage( showResultMessage(
`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`, `❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`,
@ -340,7 +355,7 @@
async function handleSignOutClick() { async function handleSignOutClick() {
localStorage.removeItem("amber/nsec"); localStorage.removeItem("amber/nsec");
localStorage.removeItem("alexandria/amber/fallback"); localStorage.removeItem("alexandria/amber/fallback");
logoutUser(); logoutUser(ndk);
} }
function handleViewProfile() { function handleViewProfile() {

11
src/lib/consts.ts

@ -3,6 +3,7 @@
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [30041, 30818, 30023]; export const zettelKinds = [30041, 30818, 30023];
export const repostKinds = [6, 16];
export const communityRelays = [ export const communityRelays = [
"wss://theforest.nostr1.com", "wss://theforest.nostr1.com",
@ -14,6 +15,9 @@ export const searchRelays = [
"wss://aggr.nostr.land", "wss://aggr.nostr.land",
"wss://relay.noswhere.com", "wss://relay.noswhere.com",
"wss://nostr.wine", "wss://nostr.wine",
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://freelay.sovbit.host",
]; ];
export const secondaryRelays = [ export const secondaryRelays = [
@ -39,8 +43,9 @@ export const lowbandwidthRelays = [
]; ];
export const localRelays: string[] = [ export const localRelays: string[] = [
"wss://localhost:8080", "ws://localhost:8080",
"wss://localhost:4869", "ws://localhost:4869",
"ws://localhost:3334",
]; ];
export enum FeedType { export enum FeedType {
@ -48,5 +53,7 @@ export enum FeedType {
UserRelays = "user", UserRelays = "user",
} }
export const EXPIRATION_DURATION = 28 * 24 * 60 * 60; // 4 weeks in seconds
export const loginStorageKey = "alexandria/login/pubkey"; export const loginStorageKey = "alexandria/login/pubkey";
export const feedTypeStorageKey = "alexandria/feed/type"; export const feedTypeStorageKey = "alexandria/feed/type";

236
src/lib/data_structures/docs/relay_selector_design.md

@ -0,0 +1,236 @@
# Relay Selector Class Design
The relay selector will be a singleton that tracks, rates, and ranks Nostr
relays to help the application determine which relay should be used to handle
each request. It will weight relays based on observed characteristics, then use
these weights to implement a weighted round robin algorithm for selecting
relays, with some additional modifications to account for domain-specific
features of Nostr.
## Relay Weights
### Categories
Relays are broadly divided into three categories:
1. **Public**: no authorization is required
2. **Private Write**: authorization is required to write to this relay, but not
to read
3. **Private Read and Write**: authorization is required to use any features of
this relay
The broadest level of relay selection is based on these categories.
- For users that are not logged in, public relays are used exclusively.
- For logged-in users, public and private read relays are initially rated
equally for read operations.
- For logged-in users, private write relays are preferred above public relays
for write operations.
### User Preferences
The relay selector will respect user relay preferences while still attempting to
optimize for responsiveness and success rate.
- User inbox relays will be stored in a separate list from general-purpose
relays, and weighted and sorted separately using the same algorithm as the
general-purpose relay list.
- Local relays (beginning with `wss://localhost` or `ws://localhost`) will be
stored _unranked_ in a separate list, and used when the relay selector is
operating on a web browser (as opposed to a server).
- When a caller requests relays from the relay selector, the selector will
return:
- The highest-ranked general-purpose relay
- The highest-ranked user inbox relay
- (If on browser) any local relays
### Weighted Metrics
Several weighted metrics are used to compute a relay's score. The score is used
to rank relays to determine which to prefer when fetching events.
#### Response Time
The response time weight of each relay is computed according to the logarithmic
function $`r(t) = -log(t) + 1`$, where $`t`$ is the median response time in
seconds. This function has a few features which make it useful:
- $`r(1) = 1`$, making a response time of 1s the netural point. This causes the
algorithm to prefer relays that respond in under 1s.
- $`r(0.3) \approx 1.5`$ and $`r(3) \approx 0.5`$. This clusters the 0.5 to 1.5
weight range in the 300ms to 3s response time range, which is a sufficiently
rapid response time to keep user's from switching context.
- The function has a long tail, so it doesn't discount slower response times too
heavily, too quickly.
#### Success Rate
The success rate $`s(x)`$ is computed as the fraction of total requests sent to
the relay that returned at least one event in response. The optimal score is 1,
meaning the relay successfully responds to 100% of requests.
#### Trust Level
Certain relays may be assigned a constant "trust level" score $`T`$. This
modifier is a number in the range $`[-0.5, 0.5]`$ that indicates how much a
relay is trusted by the GitCitadel organization.
A few factors contribute to a higher trust rating:
- Effective filtering of spam and abusive content.
- Good data transparency, including such policies as honoring deletion requests.
- Event aggregation policies that aim at synchronization with the broader relay
network.
#### Preferred Vendors
Certain relays may be assigned a constant "preferred vendor" score $`V`$. This
modifier is a number in the range $`[0, 0.5]`$. It is used to increase the
priority of GitCitadel's preferred relay vendors.
### Overall Weight
The overall weight of a relay is calculated as
$`w(t, x) = r(t) \times s(x) + T + V`$. The `RelaySelector` class maintains a
list of relays sorted by their overall weights. The weights may be updated at
runtime when $`t`$ or $`x`$ change. On update, the relay list is re-sorted to
account for the new weights.
## Algorithm
The relay weights contribute to a weighted round robin (WRR) algorithm for relay
selection. Pseudocode for the algorithm is given below:
```pseudocode
Constants and Variables:
const N // Number of relays
const CW // Connection weight
wInit // Map of relay URLs to initial weights
conn // Map of relay URLs to the number of active connections to that relay
wCurr // Current relay weights
rSorted // List of relay URLs sorted in ascending order
Function getRelay:
r = rSorted[N - 1] // Get the highest-ranked relay
conn[r]++ // Increment the number of connections
wCurr[r] = wInit[r] + conn[r] * CW // Adjust current weights based on new connection weight
sort rSorted by wCurr // Re-sort based on updated weights
return r
```
## Class Methods
The `RelaySelector` class should expose the following methods to support updates
to relay weights. Pseudocode for each method is given below.
### Add Response Time Datum
This function updates the class state by side effect. Locking should be used in
concurrent use cases.
```pseudocode
Constants and Variables:
const CW // Connection weight
rT // A map of relay URLs to their Trust Level scores
rV // A map of relay URLs to their Preferred Vendor scores
rTimes // A map of relay URLs to a list or recorded response times
rReqs // A map of relay URLs to the number of recorded requests
rSucc // A map of relay URLs to the number of successful requests
rTimes // A map of relay URLs to recorded response times
wInit // Map of relay URLs to initial weights
conn // Map of relay URLs to the number of active connections to that relay
wCurr // Current relay weights
rSorted // List of relay URLs sorted in ascending order
Parameters:
r // A relay URL
rt // A response time datum recorded for the given relay
Function addResponseTimeDatum:
append rt to rTimes[r]
sort rTimes[r]
rtMed = median of rTimes[r]
rtWeight = -1 * log(rtMed) + 1
succRate = rSucc[r] / rReqs[r]
wInit[r] = rtWeight * succRate + rT[r] + rV[r]
wCurr[r] = wInit[r] + conn[r] * CW
sort rSorted by wCurr
```
### Add Success Rate Datum
This function updates the class state by side effect. Locking should be used in
concurrent use cases.
```pseudocode
Constants and Variables:
const CW // Connection weight
rT // A map of relay URLs to their Trust Level scores
rV // A map of relay URLs to their Preferred Vendor scores
rReqs // A map of relay URLs to the number of recorded requests
rSucc // A map of relay URLs to the number of successful requests
rTimes // A map of relay URLs to recorded response times
wInit // Map of relay URLs to initial weights
conn // Map of relay URLs to the number of active connections to that relay
wCurr // Current relay weights
rSorted // List of relay URLs sorted in ascending order
Parameters:
r // A relay URL
s // A boolean value indicating whether the latest request to relay r succeeded
Function addSuccessRateDatum:
rReqs[r]++
if s is true:
rSucc[r]++
rtMed = median of rTimes[r]
rtWeight = -1 * log(rtMed) + 1
succRate = rSuccReqs[r] / rReqs[r]
wInit[r] = rtWeight * succRate + rT[r] + rV[r]
wCurr[r] = wInit[r] + conn[r] * CW
sort rSorted by wCurr
```
### Add Relay
```pseudocode
Constants and Variables:
general // A list of general-purpose relay URLs
inbox // A list of user-defined inbox relay URLs
local // A list of local relay URLs
Parameters:
r // The relay URL
rType // The relay type (general, inbox, or local)
Function addRelay:
if rType is "general":
add r to general
sort general by current weights
if rType is "inbox":
add r to inbox
sort inbox by current weights
if rType is "local":
add r to local
```
### Get Relay
```
Constants and Variables:
general // A sorted list of general-purpose relay URLs
inbox // A sorted list of user-defined inbox relay URLs
local // An unsorted list of local relay URLs
Parameters:
rank // The requested rank
Function getRelay:
selected = []
if local has members:
add all local members to selected
if rank less than length of inbox:
add inbox[rank] to selected
if rank less than length of general:
add general[rank] to selected
```

391
src/lib/data_structures/publication_tree.ts

@ -2,6 +2,13 @@ import { Lazy } from "./lazy.ts";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import { fetchEventById } from "../utils/websocket_utils.ts"; import { fetchEventById } from "../utils/websocket_utils.ts";
import {
fetchEventWithFallback,
NDKRelaySetFromNDK,
} from "../utils/nostrUtils.ts";
import { get } from "svelte/store";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { searchRelays, secondaryRelays } from "../consts.ts";
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Branch, Branch,
@ -62,6 +69,12 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
#bookmark?: string; #bookmark?: string;
/**
* AI-NOTE: 2025-01-24 - Track visited nodes to prevent duplicate iteration
* This ensures that each node is only yielded once during iteration
*/
#visitedNodes: Set<string> = new Set();
/** /**
* The NDK instance used to fetch events. * The NDK instance used to fetch events.
*/ */
@ -220,6 +233,38 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}); });
} }
/**
* AI-NOTE: 2025-01-24 - Reset the cursor to the beginning of the tree
* This is useful when the component state is reset and we want to start iteration from the beginning
*/
resetCursor() {
this.#bookmark = undefined;
this.#cursor.target = null;
}
/**
* AI-NOTE: 2025-01-24 - Reset the iterator state to start from the beginning
* This ensures that when the component resets, the iterator starts fresh
*/
resetIterator() {
this.resetCursor();
// Clear visited nodes to allow fresh iteration
this.#visitedNodes.clear();
// Clear all nodes except the root to force fresh loading
const rootAddress = this.#root.address;
this.#nodes.clear();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
// Clear events cache to ensure fresh data
this.#events.clear();
this.#eventCache.clear();
// Force the cursor to move to the root node to restart iteration
this.#cursor.tryMoveTo().then((success) => {
if (!success) {
console.warn("[PublicationTree] Failed to reset iterator to root node");
}
});
}
onBookmarkMoved(observer: (address: string) => void) { onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer); this.#bookmarkMovedObservers.push(observer);
} }
@ -451,7 +496,19 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!this.#cursor.target) { if (!this.#cursor.target) {
return { done, value: null }; return { done, value: null };
} }
const value = (await this.getEvent(this.#cursor.target.address)) ?? null;
const address = this.#cursor.target.address;
// AI-NOTE: 2025-01-24 - Check if this node has already been visited
if (this.#visitedNodes.has(address)) {
console.debug(`[PublicationTree] Skipping already visited node: ${address}`);
return { done: false, value: null };
}
// Mark this node as visited
this.#visitedNodes.add(address);
const value = (await this.getEvent(address)) ?? null;
return { done, value }; return { done, value };
} }
@ -482,7 +539,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
continue; continue;
} }
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) { if (
this.#cursor.target &&
this.#cursor.target.status === PublicationTreeNodeStatus.Error
) {
return { done: false, value: null }; return { done: false, value: null };
} }
@ -490,7 +550,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
} while (this.#cursor.tryMoveToParent()); } while (this.#cursor.tryMoveToParent());
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) { if (
this.#cursor.target &&
this.#cursor.target.status === PublicationTreeNodeStatus.Error
) {
return { done: false, value: null }; return { done: false, value: null };
} }
@ -529,7 +592,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
} while (this.#cursor.tryMoveToParent()); } while (this.#cursor.tryMoveToParent());
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) { if (
this.#cursor.target &&
this.#cursor.target.status === PublicationTreeNodeStatus.Error
) {
return { done: false, value: null }; return { done: false, value: null };
} }
@ -584,46 +650,83 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
.filter((tag) => tag[0] === "a") .filter((tag) => tag[0] === "a")
.map((tag) => tag[1]); .map((tag) => tag[1]);
console.debug(`[PublicationTree] Current event ${currentEvent.id} has ${currentEvent.tags.length} tags:`, currentEvent.tags); console.debug(
console.debug(`[PublicationTree] Found ${currentChildAddresses.length} a-tags in current event:`, currentChildAddresses); `[PublicationTree] Current event ${currentEvent.id} has ${currentEvent.tags.length} tags:`,
currentEvent.tags,
);
console.debug(
`[PublicationTree] Found ${currentChildAddresses.length} a-tags in current event:`,
currentChildAddresses,
);
// If no a-tags found, try e-tags as fallback // If no a-tags found, try e-tags as fallback
if (currentChildAddresses.length === 0) { if (currentChildAddresses.length === 0) {
const eTags = currentEvent.tags const eTags = currentEvent.tags
.filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])); .filter((tag) =>
tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])
);
console.debug(`[PublicationTree] Found ${eTags.length} e-tags for current event ${currentEvent.id}:`, eTags.map(tag => tag[1])); console.debug(
`[PublicationTree] Found ${eTags.length} e-tags for current event ${currentEvent.id}:`,
eTags.map((tag) => tag[1]),
);
// For e-tags with hex IDs, fetch the referenced events to get their addresses // For e-tags with hex IDs, fetch the referenced events to get their addresses
const eTagPromises = eTags.map(async (tag) => { const eTagPromises = eTags.map(async (tag) => {
try { try {
console.debug(`[PublicationTree] Fetching event for e-tag ${tag[1]} in depthFirstRetrieve`); console.debug(
`[PublicationTree] Fetching event for e-tag ${
tag[1]
} in depthFirstRetrieve`,
);
const referencedEvent = await fetchEventById(tag[1]); const referencedEvent = await fetchEventById(tag[1]);
if (referencedEvent) { if (referencedEvent) {
// Construct the proper address format from the referenced event // Construct the proper address format from the referenced event
const dTag = referencedEvent.tags.find(tag => tag[0] === "d")?.[1]; const dTag = referencedEvent.tags.find((tag) => tag[0] === "d")
?.[1];
if (dTag) { if (dTag) {
const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`; const address =
console.debug(`[PublicationTree] Constructed address from e-tag in depthFirstRetrieve: ${address}`); `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`;
console.debug(
`[PublicationTree] Constructed address from e-tag in depthFirstRetrieve: ${address}`,
);
return address; return address;
} else { } else {
console.debug(`[PublicationTree] Referenced event ${tag[1]} has no d-tag in depthFirstRetrieve`); console.debug(
`[PublicationTree] Referenced event ${
tag[1]
} has no d-tag in depthFirstRetrieve`,
);
} }
} else { } else {
console.debug(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]} in depthFirstRetrieve - event not found`); console.debug(
`[PublicationTree] Failed to fetch event for e-tag ${
tag[1]
} in depthFirstRetrieve - event not found`,
);
} }
return null; return null;
} catch (error) { } catch (error) {
console.warn(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]} in depthFirstRetrieve:`, error); console.warn(
`[PublicationTree] Failed to fetch event for e-tag ${
tag[1]
} in depthFirstRetrieve:`,
error,
);
return null; return null;
} }
}); });
const resolvedAddresses = await Promise.all(eTagPromises); const resolvedAddresses = await Promise.all(eTagPromises);
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[]; const validAddresses = resolvedAddresses.filter((addr) =>
addr !== null
) as string[];
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags in depthFirstRetrieve:`, validAddresses); console.debug(
`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags in depthFirstRetrieve:`,
validAddresses,
);
if (validAddresses.length > 0) { if (validAddresses.length > 0) {
currentChildAddresses.push(...validAddresses); currentChildAddresses.push(...validAddresses);
@ -642,8 +745,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Augment the tree with the children of the current event. // Augment the tree with the children of the current event.
const childPromises = currentChildAddresses const childPromises = currentChildAddresses
.filter(childAddress => !this.#nodes.has(childAddress)) .filter((childAddress) => !this.#nodes.has(childAddress))
.map(childAddress => this.#addNode(childAddress, currentNode!)); .map((childAddress) => this.#addNode(childAddress, currentNode!));
await Promise.all(childPromises); await Promise.all(childPromises);
@ -658,8 +761,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
// AI-NOTE: 2025-01-24 - Add debugging to track node addition
console.debug(`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`);
const lazyNode = new Lazy<PublicationTreeNode>(() => const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode), this.#resolveNode(address, parentNode)
); );
parentNode.children!.push(lazyNode); parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode); this.#nodes.set(address, lazyNode);
@ -685,22 +791,144 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!event) { if (!event) {
const [kind, pubkey, dTag] = address.split(":"); const [kind, pubkey, dTag] = address.split(":");
const fetchedEvent = await this.#ndk.fetchEvent({
// AI-NOTE: 2025-01-24 - Enhanced event fetching with comprehensive fallback
// First try to fetch using the enhanced fetchEventWithFallback function
// which includes search relay fallback logic
return fetchEventWithFallback(this.#ndk, {
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
"#d": [dTag], "#d": [dTag],
}); }, 5000) // 5 second timeout for publication events
.then((fetchedEvent) => {
// Cache the event if found
if (fetchedEvent) { if (fetchedEvent) {
// Cache the event if found
this.#eventCache.set(address, fetchedEvent); this.#eventCache.set(address, fetchedEvent);
event = fetchedEvent; event = fetchedEvent;
} }
}
if (!event) { if (!event) {
console.warn(
`[PublicationTree] Event with address ${address} not found on primary relays, trying search relays.`,
);
// If still not found, try a more aggressive search using search relays
return this.#trySearchRelayFallback(
address,
kind,
pubkey,
dTag,
parentNode,
);
}
return this.#buildNodeFromEvent(event, address, parentNode);
})
.catch((error) => {
console.warn(
`[PublicationTree] Error fetching event for address ${address}:`,
error,
);
// Try search relay fallback even on error
return this.#trySearchRelayFallback(
address,
kind,
pubkey,
dTag,
parentNode,
);
});
}
return await this.#buildNodeFromEvent(event, address, parentNode);
}
/**
* AI-NOTE: 2025-01-24 - Aggressive search relay fallback for publication events
* This method tries to find events on search relays when they're not found on primary relays
*/
async #trySearchRelayFallback(
address: string,
kind: string,
pubkey: string,
dTag: string,
parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> {
try {
console.log(
`[PublicationTree] Trying search relay fallback for address: ${address}`,
);
// Get current relay configuration
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
// Create a comprehensive relay set including search relays
const allRelays = [
...inboxRelays,
...outboxRelays,
...searchRelays,
...secondaryRelays,
];
const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates
console.log(
`[PublicationTree] Trying ${uniqueRelays.length} relays for fallback search:`,
uniqueRelays,
);
// Try each relay individually with a shorter timeout
for (const relay of uniqueRelays) {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], this.#ndk);
const fetchedEvent = await this.#ndk.fetchEvent(
{
kinds: [parseInt(kind)],
authors: [pubkey],
"#d": [dTag],
},
undefined,
relaySet,
).withTimeout(3000); // 3 second timeout per relay
if (fetchedEvent) {
console.log(
`[PublicationTree] Found event ${fetchedEvent.id} on search relay: ${relay}`,
);
// Cache the event
this.#eventCache.set(address, fetchedEvent);
this.#events.set(address, fetchedEvent);
return await this.#buildNodeFromEvent(fetchedEvent, address, parentNode);
}
} catch (error) {
console.debug( console.debug(
`[PublicationTree] Event with address ${address} not found.`, `[PublicationTree] Failed to fetch from relay ${relay}:`,
error,
);
continue; // Try next relay
}
}
// If we get here, the event was not found on any relay
console.warn(
`[PublicationTree] Event with address ${address} not found on any relay after fallback search.`,
);
return {
type: PublicationTreeNodeType.Leaf,
status: PublicationTreeNodeStatus.Error,
address,
parent: parentNode,
children: [],
};
} catch (error) {
console.error(
`[PublicationTree] Error in search relay fallback for ${address}:`,
error,
); );
return { return {
@ -711,22 +939,43 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], children: [],
}; };
} }
}
/**
* AI-NOTE: 2025-01-24 - Helper method to build a node from an event
* This extracts the common logic for building nodes from events
*/
async #buildNodeFromEvent(
event: NDKEvent,
address: string,
parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> {
this.#events.set(address, event); this.#events.set(address, event);
const childAddresses = event.tags const childAddresses = event.tags
.filter((tag) => tag[0] === "a") .filter((tag) => tag[0] === "a")
.map((tag) => tag[1]); .map((tag) => tag[1]);
console.debug(`[PublicationTree] Event ${event.id} has ${event.tags.length} tags:`, event.tags); console.debug(
console.debug(`[PublicationTree] Found ${childAddresses.length} a-tags:`, childAddresses); `[PublicationTree] Event ${event.id} has ${event.tags.length} tags:`,
event.tags,
);
console.debug(
`[PublicationTree] Found ${childAddresses.length} a-tags:`,
childAddresses,
);
// If no a-tags found, try e-tags as fallback // If no a-tags found, try e-tags as fallback
if (childAddresses.length === 0) { if (childAddresses.length === 0) {
const eTags = event.tags const eTags = event.tags
.filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])); .filter((tag) =>
tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])
);
console.debug(`[PublicationTree] Found ${eTags.length} e-tags for event ${event.id}:`, eTags.map(tag => tag[1])); console.debug(
`[PublicationTree] Found ${eTags.length} e-tags for event ${event.id}:`,
eTags.map((tag) => tag[1]),
);
// For e-tags with hex IDs, fetch the referenced events to get their addresses // For e-tags with hex IDs, fetch the referenced events to get their addresses
const eTagPromises = eTags.map(async (tag) => { const eTagPromises = eTags.map(async (tag) => {
@ -736,32 +985,39 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (referencedEvent) { if (referencedEvent) {
// Construct the proper address format from the referenced event // Construct the proper address format from the referenced event
const dTag = referencedEvent.tags.find(tag => tag[0] === "d")?.[1]; const dTag = referencedEvent.tags.find((tag) => tag[0] === "d")
?.[1];
if (dTag) { if (dTag) {
const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`; const address =
console.debug(`[PublicationTree] Constructed address from e-tag: ${address}`); `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`;
console.debug(
`[PublicationTree] Constructed address from e-tag: ${address}`,
);
return address; return address;
} else { } else {
console.debug(`[PublicationTree] Referenced event ${tag[1]} has no d-tag`); console.debug(
`[PublicationTree] Referenced event ${tag[1]} has no d-tag`,
);
} }
} else { } else {
console.debug(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}`); console.debug(
`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}`,
);
} }
return null; return null;
} catch (error) { } catch (error) {
console.warn(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}:`, error); console.warn(
`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}:`,
error,
);
return null; return null;
} }
}); });
const resolvedAddresses = await Promise.all(eTagPromises); // AI-NOTE: 2025-01-24 - Remove e-tag processing from synchronous method
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[]; // E-tags should be resolved asynchronously in #resolveNode method
// Adding raw event IDs here causes duplicate processing
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags:`, validAddresses); console.debug(`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`);
if (validAddresses.length > 0) {
childAddresses.push(...validAddresses);
}
} }
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
@ -772,10 +1028,25 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], children: [],
}; };
const childPromises = childAddresses.map(address => // AI-NOTE: 2025-01-24 - Fixed child node addition in buildNodeFromEvent
this.addEventByAddress(address, event) // Previously called addEventByAddress which expected parent to be in tree
// Now directly adds child nodes to current node's children array
// Add children in the order they appear in the a-tags to preserve section order
// Use sequential processing to ensure order is maintained
console.log(`[PublicationTree] Adding ${childAddresses.length} children in order:`, childAddresses);
for (const childAddress of childAddresses) {
console.log(`[PublicationTree] Adding child: ${childAddress}`);
try {
// Add the child node directly to the current node's children
this.#addNode(childAddress, node);
console.log(`[PublicationTree] Successfully added child: ${childAddress}`);
} catch (error) {
console.warn(
`[PublicationTree] Error adding child ${childAddress} for ${node.address}:`,
error,
); );
await Promise.all(childPromises); }
}
this.#nodeResolvedObservers.forEach((observer) => observer(address)); this.#nodeResolvedObservers.forEach((observer) => observer(address));
@ -783,15 +1054,31 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#getNodeType(event: NDKEvent): PublicationTreeNodeType { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && ( // AI-NOTE: 2025-01-24 - Show nested 30040s and their zettel kind leaves
event.tags.some((tag) => tag[0] === "a") || // Only 30040 events with children should be branches
event.tags.some((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])) // Zettel kinds (30041, 30818, 30023) are always leaves
)) { if (event.kind === 30040) {
return PublicationTreeNodeType.Branch; // Check if this 30040 has any children (a-tags only, since e-tags are handled separately)
const hasChildren = event.tags.some((tag) => tag[0] === "a");
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`);
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf;
} }
// Zettel kinds are always leaves
if ([30041, 30818, 30023].includes(event.kind)) {
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - Zettel kind, type: Leaf`);
return PublicationTreeNodeType.Leaf; return PublicationTreeNodeType.Leaf;
} }
// For other kinds, check if they have children (a-tags only)
const hasChildren = event.tags.some((tag) => tag[0] === "a");
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`);
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf;
}
// #endregion // #endregion
} }

68
src/lib/data_structures/websocket_pool.ts

@ -42,7 +42,10 @@ export class WebSocketPool {
* @param maxConnections - The maximum number of simultaneous WebSocket connections. Defaults to * @param maxConnections - The maximum number of simultaneous WebSocket connections. Defaults to
* 16. * 16.
*/ */
private constructor(idleTimeoutMs: number = 60000, maxConnections: number = 16) { private constructor(
idleTimeoutMs: number = 60000,
maxConnections: number = 16,
) {
this.#idleTimeoutMs = idleTimeoutMs; this.#idleTimeoutMs = idleTimeoutMs;
this.#maxConnections = maxConnections; this.#maxConnections = maxConnections;
} }
@ -71,15 +74,17 @@ export class WebSocketPool {
} }
if (limit == null || isNaN(limit)) { if (limit == null || isNaN(limit)) {
throw new Error('[WebSocketPool] Connection limit must be a number.'); throw new Error("[WebSocketPool] Connection limit must be a number.");
} }
if (limit <= 0) { if (limit <= 0) {
throw new Error('[WebSocketPool] Connection limit must be greater than 0.'); throw new Error(
"[WebSocketPool] Connection limit must be greater than 0.",
);
} }
if (!Number.isInteger(limit)) { if (!Number.isInteger(limit)) {
throw new Error('[WebSocketPool] Connection limit must be an integer.'); throw new Error("[WebSocketPool] Connection limit must be an integer.");
} }
this.#maxConnections = limit; this.#maxConnections = limit;
@ -106,15 +111,15 @@ export class WebSocketPool {
} }
if (timeoutMs == null || isNaN(timeoutMs)) { if (timeoutMs == null || isNaN(timeoutMs)) {
throw new Error('[WebSocketPool] Idle timeout must be a number.'); throw new Error("[WebSocketPool] Idle timeout must be a number.");
} }
if (timeoutMs <= 0) { if (timeoutMs <= 0) {
throw new Error('[WebSocketPool] Idle timeout must be greater than 0.'); throw new Error("[WebSocketPool] Idle timeout must be greater than 0.");
} }
if (!Number.isInteger(timeoutMs)) { if (!Number.isInteger(timeoutMs)) {
throw new Error('[WebSocketPool] Idle timeout must be an integer.'); throw new Error("[WebSocketPool] Idle timeout must be an integer.");
} }
this.#idleTimeoutMs = timeoutMs; this.#idleTimeoutMs = timeoutMs;
@ -163,7 +168,7 @@ export class WebSocketPool {
return newHandle.ws; return newHandle.ws;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`[WebSocketPool] Failed to acquire connection for ${normalizedUrl}: ${error}` `[WebSocketPool] Failed to acquire connection for ${normalizedUrl}: ${error}`,
); );
} }
} }
@ -179,7 +184,9 @@ export class WebSocketPool {
const normalizedUrl = this.#normalizeUrl(ws.url); const normalizedUrl = this.#normalizeUrl(ws.url);
const handle = this.#pool.get(normalizedUrl); const handle = this.#pool.get(normalizedUrl);
if (!handle) { if (!handle) {
throw new Error('[WebSocketPool] Attempted to release an unmanaged WebSocket connection.'); throw new Error(
"[WebSocketPool] Attempted to release an unmanaged WebSocket connection.",
);
} }
if (--handle.refCount === 0) { if (--handle.refCount === 0) {
@ -191,20 +198,30 @@ export class WebSocketPool {
* Closes all WebSocket connections and "drains" the pool. * Closes all WebSocket connections and "drains" the pool.
*/ */
public drain(): void { public drain(): void {
console.debug(
`[WebSocketPool] Draining pool with ${this.#pool.size} connections and ${this.#waitingQueue.length} waiting requests`,
);
// Clear all idle timers first // Clear all idle timers first
for (const handle of this.#pool.values()) { for (const handle of this.#pool.values()) {
this.#clearIdleTimer(handle); this.#clearIdleTimer(handle);
} }
// Reject all waiting requests
for (const { reject } of this.#waitingQueue) { for (const { reject } of this.#waitingQueue) {
reject(new Error('[WebSocketPool] Draining pool.')); reject(new Error("[WebSocketPool] Draining pool."));
} }
this.#waitingQueue = []; this.#waitingQueue = [];
// Close all connections and clean up
for (const handle of this.#pool.values()) { for (const handle of this.#pool.values()) {
if (handle.ws && handle.ws.readyState === WebSocket.OPEN) {
handle.ws.close(); handle.ws.close();
} }
}
this.#pool.clear(); this.#pool.clear();
console.debug("[WebSocketPool] Pool drained successfully");
} }
// #endregion // #endregion
@ -231,7 +248,9 @@ export class WebSocketPool {
this.#removeSocket(handle); this.#removeSocket(handle);
this.#processWaitingQueue(); this.#processWaitingQueue();
reject( reject(
new Error(`[WebSocketPool] WebSocket connection failed for ${url}: ${event.type}`) new Error(
`[WebSocketPool] WebSocket connection failed for ${url}: ${event.type}`,
),
); );
}; };
} catch (error) { } catch (error) {
@ -243,8 +262,24 @@ export class WebSocketPool {
#removeSocket(handle: WebSocketHandle): void { #removeSocket(handle: WebSocketHandle): void {
this.#clearIdleTimer(handle); this.#clearIdleTimer(handle);
handle.ws.onopen = handle.ws.onerror = handle.ws.onclose = null;
this.#pool.delete(this.#normalizeUrl(handle.ws.url)); // Clean up event listeners to prevent memory leaks
// AI-NOTE: Code that checks out connections should clean up its own listener callbacks before
// releasing the connection to the pool.
if (handle.ws) {
handle.ws.onopen = null;
handle.ws.onerror = null;
handle.ws.onclose = null;
handle.ws.onmessage = null;
}
const url = this.#normalizeUrl(handle.ws.url);
this.#pool.delete(url);
console.debug(
`[WebSocketPool] Removed socket for ${url}, pool size: ${this.#pool.size}`,
);
this.#processWaitingQueue(); this.#processWaitingQueue();
} }
@ -261,6 +296,9 @@ export class WebSocketPool {
handle.idleTimer = setTimeout(() => { handle.idleTimer = setTimeout(() => {
const refCount = handle.refCount; const refCount = handle.refCount;
if (refCount === 0 && handle.ws.readyState === WebSocket.OPEN) { if (refCount === 0 && handle.ws.readyState === WebSocket.OPEN) {
console.debug(
`[WebSocketPool] Closing idle connection to ${handle.ws.url}`,
);
handle.ws.close(); handle.ws.close();
this.#removeSocket(handle); this.#removeSocket(handle);
} }
@ -308,7 +346,7 @@ export class WebSocketPool {
#checkOut(handle: WebSocketHandle): void { #checkOut(handle: WebSocketHandle): void {
if (handle.refCount == null) { if (handle.refCount == null) {
throw new Error('[WebSocketPool] Handle refCount unexpectedly null.'); throw new Error("[WebSocketPool] Handle refCount unexpectedly null.");
} }
++handle.refCount; ++handle.refCount;
@ -323,7 +361,7 @@ export class WebSocketPool {
// The logic to remove a trailing slash for connection coalescing can be kept, // The logic to remove a trailing slash for connection coalescing can be kept,
// but should be done on the normalized string. // but should be done on the normalized string.
if (urlObj.pathname !== '/' && normalized.endsWith('/')) { if (urlObj.pathname !== "/" && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1); normalized = normalized.slice(0, -1);
} }

1
src/lib/models/search_type.d.ts vendored

@ -0,0 +1 @@
export type SearchType = "id" | "d" | "t" | "n" | "q";

12
src/lib/models/user_profile.d.ts vendored

@ -0,0 +1,12 @@
export interface UserProfile {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
isInUserLists?: boolean;
listKinds?: number[];
}

22
src/lib/navigator/EventNetwork/Legend.svelte

@ -247,7 +247,7 @@
> >
{showTagAnchors ? 'ON' : 'OFF'} {showTagAnchors ? 'ON' : 'OFF'}
</button> </button>
<span class="text-sm">Show Tag Anchors</span> <span class="text-sm text-gray-700 dark:text-gray-300">Show Tag Anchors</span>
</div> </div>
{#if showTagAnchors} {#if showTagAnchors}
@ -317,7 +317,7 @@
bind:group={tagSortMode} bind:group={tagSortMode}
class="w-3 h-3" class="w-3 h-3"
/> />
<span class="text-xs">Count</span> <span class="text-xs text-gray-700 dark:text-gray-300">Count</span>
</label> </label>
<label class="flex items-center gap-1 cursor-pointer"> <label class="flex items-center gap-1 cursor-pointer">
<input <input
@ -327,7 +327,7 @@
bind:group={tagSortMode} bind:group={tagSortMode}
class="w-3 h-3" class="w-3 h-3"
/> />
<span class="text-xs">Alphabetical</span> <span class="text-xs text-gray-700 dark:text-gray-300">Alphabetical</span>
</label> </label>
</div> </div>
</div> </div>
@ -343,7 +343,7 @@
title={isDisabled ? `Click to show ${tag.label}` : `Click to hide ${tag.label}`} title={isDisabled ? `Click to show ${tag.label}` : `Click to hide ${tag.label}`}
aria-pressed={!isDisabled} aria-pressed={!isDisabled}
> >
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};"> <span class="text-xs text-gray-700 dark:text-gray-300 truncate max-w-32" style="opacity: {isDisabled ? 0.5 : 1};" title="{tag.label} ({tag.count})">
{tag.label} ({tag.count}) {tag.label} ({tag.count})
</span> </span>
<div class="flex items-center"> <div class="flex items-center">
@ -395,12 +395,12 @@
> >
{showPersonNodes ? 'ON' : 'OFF'} {showPersonNodes ? 'ON' : 'OFF'}
</button> </button>
<span class="text-sm">Show Person Nodes</span> <span class="text-sm text-gray-700 dark:text-gray-300">Show Person Nodes</span>
</div> </div>
{#if showPersonNodes} {#if showPersonNodes}
<div class="flex items-center space-x-3 text-xs"> <div class="flex items-center space-x-3 text-xs">
<label class="flex items-center space-x-1"> <label class="flex items-center space-x-1 text-gray-700 dark:text-gray-300">
<input <input
type="checkbox" type="checkbox"
bind:checked={showSignedBy} bind:checked={showSignedBy}
@ -409,7 +409,7 @@
/> />
<span>Signed by</span> <span>Signed by</span>
</label> </label>
<label class="flex items-center space-x-1"> <label class="flex items-center space-x-1 text-gray-700 dark:text-gray-300">
<input <input
type="checkbox" type="checkbox"
bind:checked={showReferenced} bind:checked={showReferenced}
@ -432,13 +432,13 @@
{/if} {/if}
</p> </p>
<label class="flex items-center gap-1 cursor-pointer"> <label class="flex items-center gap-1 cursor-pointer text-gray-700 dark:text-gray-300">
<input <input
type="checkbox" type="checkbox"
onclick={invertPersonSelection} onclick={invertPersonSelection}
class="w-3 h-3" class="w-3 h-3"
/> />
<span class="text-xs">Invert Selection</span> <span class="text-xs text-gray-700 dark:text-gray-300">Invert Selection</span>
</label> </label>
</div> </div>
@ -466,8 +466,8 @@
style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};" style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};"
></span> ></span>
</div> </div>
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};"> <span class="text-xs text-gray-700 dark:text-gray-300 truncate" style="opacity: {isDisabled ? 0.5 : 1};" title="{person.displayName || person.pubkey}">
{person.displayName || person.pubkey.substring(0, 8)} {person.displayName || person.pubkey}
</span> </span>
</button> </button>
{/each} {/each}

46
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -7,12 +7,12 @@
<script lang="ts"> <script lang="ts">
import type { NetworkNode } from "./types"; import type { NetworkNode } from "./types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import { getEventKindName } from "$lib/utils/eventColors"; import { getEventKindName } from "$lib/utils/eventColors";
import { import {
getDisplayNameSync, getDisplayNameSync,
replacePubkeysWithDisplayNames, replacePubkeysWithDisplayNames,
} from "$lib/utils/profileCache"; } from "$lib/utils/npubCache";
import {indexKind, zettelKinds, wikiKind} from "$lib/consts"; import {indexKind, zettelKinds, wikiKind} from "$lib/consts";
// Component props // Component props
@ -47,6 +47,11 @@
* Gets the author name from the event tags * Gets the author name from the event tags
*/ */
function getAuthorTag(node: NetworkNode): string { function getAuthorTag(node: NetworkNode): string {
// For person anchor nodes, use the pubkey directly
if (node.isPersonAnchor && node.pubkey) {
return getDisplayNameSync(node.pubkey);
}
if (node.event) { if (node.event) {
const authorTags = getMatchingTags(node.event, "author"); const authorTags = getMatchingTags(node.event, "author");
if (authorTags.length > 0) { if (authorTags.length > 0) {
@ -98,10 +103,29 @@
*/ */
function getEventUrl(node: NetworkNode): string { function getEventUrl(node: NetworkNode): string {
if (isPublicationEvent(node.kind)) { if (isPublicationEvent(node.kind)) {
return `/publication?id=${node.id}`; return `/publication/id/${node.id}?from=visualize`;
}
// For tag anchor nodes, only create URLs for supported tag types
if (node.isTagAnchor && node.tagType && node.tagValue) {
// Only create URLs for supported parameters: t, n, d
if (node.tagType === 't' || node.tagType === 'n' || node.tagType === 'd') {
return `/events?${node.tagType}=${encodeURIComponent(node.tagValue)}`;
} }
// For other tag types, don't create a URL
return '';
}
// For person anchor nodes, use the pubkey to create an npub
if (node.isPersonAnchor && node.pubkey) {
const npub = toNpub(node.pubkey);
return `/events?id=${npub}`;
}
// For regular events, use the event ID
if (node.id && !node.id.startsWith('tag-anchor-')) {
return `/events?id=${node.id}`; return `/events?id=${node.id}`;
} }
// For other nodes, don't create a URL
return '';
}
/** /**
* Gets display text for the link * Gets display text for the link
@ -188,9 +212,15 @@
<div class="tooltip-content"> <div class="tooltip-content">
<!-- Title with link --> <!-- Title with link -->
<div class="tooltip-title"> <div class="tooltip-title">
<a href="/publication?id={node.id}" class="tooltip-title-link"> {#if getEventUrl(node)}
<a href={getEventUrl(node)} class="tooltip-title-link">
{getLinkText(node)} {getLinkText(node)}
</a> </a>
{:else}
<span class="tooltip-title-text">
{getLinkText(node)}
</span>
{/if}
</div> </div>
<!-- Node type and kind --> <!-- Node type and kind -->
@ -206,12 +236,18 @@
</div> </div>
<!-- Pub Author --> <!-- Pub Author -->
{#if !node.isPersonAnchor}
<div class="tooltip-metadata"> <div class="tooltip-metadata">
Pub Author: {getAuthorTag(node)} Pub Author: {getAuthorTag(node)}
</div> </div>
{/if}
<!-- Published by (from node.author) --> <!-- Published by (from node.author) -->
{#if node.author} {#if node.isPersonAnchor}
<div class="tooltip-metadata">
Person: {getAuthorTag(node)}
</div>
{:else if node.author}
<div class="tooltip-metadata"> <div class="tooltip-metadata">
published_by: {node.author} published_by: {node.author}
</div> </div>

88
src/lib/navigator/EventNetwork/index.svelte

@ -208,7 +208,9 @@
svgGroup.attr("transform", event.transform); svgGroup.attr("transform", event.transform);
}); });
// Initialize with identity transform
svgElement.call(zoomBehavior); svgElement.call(zoomBehavior);
svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
// Set up arrow marker for links // Set up arrow marker for links
const defs = svgElement.append("defs"); const defs = svgElement.append("defs");
@ -250,7 +252,7 @@
/** /**
* Generates graph data from events, including tag and person anchors * Generates graph data from events, including tag and person anchors
*/ */
function generateGraphData() { async function generateGraphData() {
debug("Generating graph with events", { debug("Generating graph with events", {
eventCount: events.length, eventCount: events.length,
currentLevels, currentLevels,
@ -309,7 +311,7 @@
personMap = extractUniquePersons(events, followListEvents); personMap = extractUniquePersons(events, followListEvents);
// Create person anchor nodes based on filters // Create person anchor nodes based on filters
const personResult = createPersonAnchorNodes( const personResult = await createPersonAnchorNodes(
personMap, personMap,
width, width,
height, height,
@ -505,9 +507,10 @@
// Center the nodes when the simulation is done // Center the nodes when the simulation is done
newSimulation.on("end", () => { newSimulation.on("end", () => {
if (!starVisualization) { // Add a small delay to ensure the simulation has fully settled
setTimeout(() => {
centerGraph(); centerGraph();
} }, 100);
}); });
// Create drag handler // Create drag handler
@ -866,7 +869,7 @@
* Updates the graph with new data * Updates the graph with new data
* Generates the graph from events, creates the simulation, and renders nodes and links * Generates the graph from events, creates the simulation, and renders nodes and links
*/ */
function updateGraph() { async function updateGraph() {
debug("updateGraph called", { debug("updateGraph called", {
eventCount: events?.length, eventCount: events?.length,
starVisualization, starVisualization,
@ -878,7 +881,7 @@
try { try {
validateGraphElements(); validateGraphElements();
const graphData = generateGraphData(); const graphData = await generateGraphData();
// Save current positions before filtering // Save current positions before filtering
saveNodePositions(graphData.nodes); saveNodePositions(graphData.nodes);
@ -1011,17 +1014,17 @@
}); });
// Debounced update function // Debounced update function
function scheduleGraphUpdate() { async function scheduleGraphUpdate() {
if (updateTimer) { if (updateTimer) {
clearTimeout(updateTimer); clearTimeout(updateTimer);
} }
updateTimer = setTimeout(() => { updateTimer = setTimeout(async () => {
if (!isUpdating && svg && events?.length > 0) { if (!isUpdating && svg && events?.length > 0) {
debug("Scheduled graph update executing", graphDependencies); debug("Scheduled graph update executing", graphDependencies);
isUpdating = true; isUpdating = true;
try { try {
updateGraph(); await updateGraph();
} catch (error) { } catch (error) {
console.error("Error updating graph:", error); console.error("Error updating graph:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
@ -1183,17 +1186,59 @@
*/ */
function centerGraph() { function centerGraph() {
if (svg && svgGroup && zoomBehavior) { if (svg && svgGroup && zoomBehavior) {
const svgWidth = svg.clientWidth || width; debug("Centering graph", { width, height });
const svgHeight = svg.clientHeight || height;
// Get all nodes to calculate bounds
const nodes = svgGroup.selectAll('.node').data();
if (nodes.length === 0) {
debug("No nodes found for centering");
return;
}
// Calculate bounds of all nodes
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
nodes.forEach((node: NetworkNode) => {
if (node.x != null && node.y != null) {
minX = Math.min(minX, node.x);
maxX = Math.max(maxX, node.x);
minY = Math.min(minY, node.y);
maxY = Math.max(maxY, node.y);
}
});
// Calculate the center of the graph content
const graphCenterX = (minX + maxX) / 2;
const graphCenterY = (minY + maxY) / 2;
// Reset zoom and center // Calculate the viewBox center
const viewBoxCenterX = width / 2;
const viewBoxCenterY = height / 2;
// Calculate the translation needed to center the graph
const translateX = viewBoxCenterX - graphCenterX;
const translateY = viewBoxCenterY - graphCenterY;
debug("Centering graph", {
graphBounds: { minX, maxX, minY, maxY },
graphCenter: { graphCenterX, graphCenterY },
viewBoxCenter: { viewBoxCenterX, viewBoxCenterY },
translation: { translateX, translateY }
});
// Apply the centering transform
d3.select(svg) d3.select(svg)
.transition() .transition()
.duration(750) .duration(750)
.call( .call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8), d3.zoomIdentity.translate(translateX, translateY).scale(0.8),
); );
} else {
debug("Cannot center graph - missing required elements", {
hasSvg: !!svg,
hasSvgGroup: !!svgGroup,
hasZoomBehavior: !!zoomBehavior
});
} }
} }
@ -1235,9 +1280,9 @@
<p>{errorMessage}</p> <p>{errorMessage}</p>
<button <button
class="network-error-retry" class="network-error-retry"
onclick={() => { onclick={async () => {
errorMessage = null; errorMessage = null;
updateGraph(); await updateGraph();
}} }}
> >
Retry Retry
@ -1258,20 +1303,20 @@
{autoDisabledTags} {autoDisabledTags}
bind:showTagAnchors bind:showTagAnchors
bind:selectedTagType bind:selectedTagType
onTagSettingsChange={() => { onTagSettingsChange={async () => {
// Trigger graph update when tag settings change // Trigger graph update when tag settings change
if (svg && events?.length) { if (svg && events?.length) {
updateGraph(); await updateGraph();
} }
}} }}
bind:showPersonNodes bind:showPersonNodes
personAnchors={personAnchorInfo} personAnchors={personAnchorInfo}
{disabledPersons} {disabledPersons}
onPersonToggle={handlePersonToggle} onPersonToggle={handlePersonToggle}
onPersonSettingsChange={() => { onPersonSettingsChange={async () => {
// Trigger graph update when person settings change // Trigger graph update when person settings change
if (svg && events?.length) { if (svg && events?.length) {
updateGraph(); await updateGraph();
} }
}} }}
bind:showSignedBy bind:showSignedBy
@ -1348,7 +1393,10 @@
outline outline
size="lg" size="lg"
class="network-control-button btn-leather rounded-lg p-2" class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph} onclick={() => {
debug("Center button clicked");
centerGraph();
}}
aria-label="Center graph" aria-label="Center graph"
> >
<svg <svg

51
src/lib/navigator/EventNetwork/utils/forceSimulation.ts

@ -5,7 +5,7 @@
* graph simulations for the event network visualization. * graph simulations for the event network visualization.
*/ */
import type { NetworkNode, NetworkLink } from "../types"; import type { NetworkLink, NetworkNode } from "../types";
import * as d3 from "d3"; import * as d3 from "d3";
import { createDebugFunction } from "./common"; import { createDebugFunction } from "./common";
@ -60,14 +60,14 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> {
export function updateNodeVelocity( export function updateNodeVelocity(
node: NetworkNode, node: NetworkNode,
deltaVx: number, deltaVx: number,
deltaVy: number deltaVy: number,
) { ) {
debug("Updating node velocity", { debug("Updating node velocity", {
nodeId: node.id, nodeId: node.id,
currentVx: node.vx, currentVx: node.vx,
currentVy: node.vy, currentVy: node.vy,
deltaVx, deltaVx,
deltaVy deltaVy,
}); });
if (typeof node.vx === "number" && typeof node.vy === "number") { if (typeof node.vx === "number" && typeof node.vy === "number") {
@ -129,9 +129,9 @@ export function applyConnectedGravity(
// Find all nodes connected to this node (excluding tag anchors and person anchors) // Find all nodes connected to this node (excluding tag anchors and person anchors)
const connectedNodes = links const connectedNodes = links
.filter(link => link.source.id === node.id || link.target.id === node.id) .filter((link) => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source) .map((link) => link.source.id === node.id ? link.target : link.source)
.filter(n => !n.isTagAnchor && !n.isPersonAnchor); .filter((n) => !n.isTagAnchor && !n.isPersonAnchor);
if (connectedNodes.length === 0) return; if (connectedNodes.length === 0) return;
@ -164,11 +164,16 @@ export function applyConnectedGravity(
*/ */
export function setupDragHandlers( export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>, simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9 warmupClickEnergy: number = 0.9,
) { ) {
return d3 return d3
.drag() .drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { .on(
"start",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Tag anchors and person anchors retain their anchor behavior // Tag anchors and person anchors retain their anchor behavior
if (d.isTagAnchor || d.isPersonAnchor) { if (d.isTagAnchor || d.isPersonAnchor) {
// Still allow dragging but maintain anchor status // Still allow dragging but maintain anchor status
@ -184,16 +189,27 @@ export function setupDragHandlers(
// Fix node position at current location // Fix node position at current location
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
}) },
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { )
.on(
"drag",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Update position for all nodes including anchors // Update position for all nodes including anchors
// Update fixed position to mouse position // Update fixed position to mouse position
d.fx = event.x; d.fx = event.x;
d.fy = event.y; d.fy = event.y;
}) },
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { )
.on(
"end",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Cool down simulation when drag ends // Cool down simulation when drag ends
if (!event.active) { if (!event.active) {
simulation.alphaTarget(0); simulation.alphaTarget(0);
@ -203,7 +219,8 @@ export function setupDragHandlers(
// This allows users to manually position any node type // This allows users to manually position any node type
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
}); },
);
} }
/** /**
@ -219,13 +236,13 @@ export function createSimulation(
nodes: NetworkNode[], nodes: NetworkNode[],
links: NetworkLink[], links: NetworkLink[],
nodeRadius: number, nodeRadius: number,
linkDistance: number linkDistance: number,
): Simulation<NetworkNode, NetworkLink> { ): Simulation<NetworkNode, NetworkLink> {
debug("Creating simulation", { debug("Creating simulation", {
nodeCount: nodes.length, nodeCount: nodes.length,
linkCount: links.length, linkCount: links.length,
nodeRadius, nodeRadius,
linkDistance linkDistance,
}); });
try { try {
@ -236,7 +253,7 @@ export function createSimulation(
"link", "link",
d3.forceLink(links) d3.forceLink(links)
.id((d: NetworkNode) => d.id) .id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1) .distance(linkDistance * 0.1),
) )
.force("collide", d3.forceCollide().radius(nodeRadius * 4)); .force("collide", d3.forceCollide().radius(nodeRadius * 4));

35
src/lib/navigator/EventNetwork/utils/networkBuilder.ts

@ -6,11 +6,11 @@
*/ */
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import type { GraphData, GraphState, NetworkLink, NetworkNode } from "../types";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { communityRelays } from "$lib/consts"; import { communityRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getDisplayNameSync } from '$lib/utils/profileCache'; import { getDisplayNameSync } from "$lib/utils/npubCache";
import { createDebugFunction } from "./common"; import { createDebugFunction } from "./common";
// Configuration // Configuration
@ -32,12 +32,20 @@ const debug = createDebugFunction("NetworkBuilder");
*/ */
export function createNetworkNode( export function createNetworkNode(
event: NDKEvent, event: NDKEvent,
level: number = 0 level: number = 0,
): NetworkNode { ): NetworkNode {
debug("Creating network node", { eventId: event.id, kind: event.kind, level }); debug("Creating network node", {
eventId: event.id,
kind: event.kind,
level,
});
const isContainer = event.kind === INDEX_EVENT_KIND; const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : event.kind === CONTENT_EVENT_KIND || event.kind === 30818 ? "Content" : `Kind ${event.kind}`; const nodeType = isContainer
? "Index"
: event.kind === CONTENT_EVENT_KIND || event.kind === 30818
? "Content"
: `Kind ${event.kind}`;
// Create the base node with essential properties // Create the base node with essential properties
const node: NetworkNode = { const node: NetworkNode = {
@ -157,7 +165,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
const aTags = getMatchingTags(event, "a"); const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", { debug("Processing a-tags for event", {
eventId: event.id, eventId: event.id,
aTagCount: aTags.length aTagCount: aTags.length,
}); });
aTags.forEach((tag) => { aTags.forEach((tag) => {
@ -295,7 +303,7 @@ export function processIndexEvent(
*/ */
export function generateGraph( export function generateGraph(
events: NDKEvent[], events: NDKEvent[],
maxLevel: number maxLevel: number,
): GraphData { ): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel }); debug("Generating graph", { eventCount: events.length, maxLevel });
@ -305,17 +313,18 @@ export function generateGraph(
// Find root events (index events not referenced by others, and all non-publication events) // Find root events (index events not referenced by others, and all non-publication events)
const publicationKinds = [30040, 30041, 30818]; const publicationKinds = [30040, 30041, 30818];
const rootEvents = events.filter( const rootEvents = events.filter(
(e) => e.id && ( (e) =>
e.id && (
// Index events not referenced by others // Index events not referenced by others
(e.kind === INDEX_EVENT_KIND && !state.referencedIds.has(e.id)) || (e.kind === INDEX_EVENT_KIND && !state.referencedIds.has(e.id)) ||
// All non-publication events are treated as roots // All non-publication events are treated as roots
(e.kind !== undefined && !publicationKinds.includes(e.kind)) (e.kind !== undefined && !publicationKinds.includes(e.kind))
) ),
); );
debug("Found root events", { debug("Found root events", {
rootCount: rootEvents.length, rootCount: rootEvents.length,
rootIds: rootEvents.map(e => e.id) rootIds: rootEvents.map((e) => e.id),
}); });
// Process each root event // Process each root event
@ -323,7 +332,7 @@ export function generateGraph(
debug("Processing root event", { debug("Processing root event", {
rootId: rootEvent.id, rootId: rootEvent.id,
kind: rootEvent.kind, kind: rootEvent.kind,
aTags: getMatchingTags(rootEvent, "a").length aTags: getMatchingTags(rootEvent, "a").length,
}); });
processIndexEvent(rootEvent, 0, state, maxLevel); processIndexEvent(rootEvent, 0, state, maxLevel);
}); });
@ -336,7 +345,7 @@ export function generateGraph(
debug("Graph generation complete", { debug("Graph generation complete", {
nodeCount: result.nodes.length, nodeCount: result.nodes.length,
linkCount: result.links.length linkCount: result.links.length,
}); });
return result; return result;

86
src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

@ -5,9 +5,9 @@
*/ */
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink } from "../types"; import type { NetworkLink, NetworkNode } from "../types";
import { getDisplayNameSync } from "$lib/utils/profileCache"; import { getDisplayNameSync } from "$lib/utils/npubCache";
import { SeededRandom, createDebugFunction } from "./common"; import { createDebugFunction, SeededRandom } from "./common";
const PERSON_ANCHOR_RADIUS = 15; const PERSON_ANCHOR_RADIUS = 15;
const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000; const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000;
@ -16,7 +16,6 @@ const MAX_PERSON_NODES = 20; // Default limit for person nodes
// Debug function // Debug function
const debug = createDebugFunction("PersonNetworkBuilder"); const debug = createDebugFunction("PersonNetworkBuilder");
/** /**
* Creates a deterministic seed from a string * Creates a deterministic seed from a string
*/ */
@ -42,12 +41,15 @@ export interface PersonConnection {
*/ */
export function extractUniquePersons( export function extractUniquePersons(
events: NDKEvent[], events: NDKEvent[],
followListEvents?: NDKEvent[] followListEvents?: NDKEvent[],
): Map<string, PersonConnection> { ): Map<string, PersonConnection> {
// Map of pubkey -> PersonConnection // Map of pubkey -> PersonConnection
const personMap = new Map<string, PersonConnection>(); const personMap = new Map<string, PersonConnection>();
debug("Extracting unique persons", { eventCount: events.length, followListCount: followListEvents?.length || 0 }); debug("Extracting unique persons", {
eventCount: events.length,
followListCount: followListEvents?.length || 0,
});
// First collect pubkeys from follow list events // First collect pubkeys from follow list events
const followListPubkeys = new Set<string>(); const followListPubkeys = new Set<string>();
@ -60,10 +62,10 @@ export function extractUniquePersons(
// People in follow lists (p tags) // People in follow lists (p tags)
if (event.tags) { if (event.tags) {
event.tags event.tags
.filter(tag => { .filter((tag) => {
tag[0] === 'p' tag[0] === "p";
}) })
.forEach(tag => { .forEach((tag) => {
followListPubkeys.add(tag[1]); followListPubkeys.add(tag[1]);
}); });
} }
@ -79,7 +81,7 @@ export function extractUniquePersons(
personMap.set(event.pubkey, { personMap.set(event.pubkey, {
signedByEventIds: new Set(), signedByEventIds: new Set(),
referencedInEventIds: new Set(), referencedInEventIds: new Set(),
isFromFollowList: followListPubkeys.has(event.pubkey) isFromFollowList: followListPubkeys.has(event.pubkey),
}); });
} }
personMap.get(event.pubkey)!.signedByEventIds.add(event.id); personMap.get(event.pubkey)!.signedByEventIds.add(event.id);
@ -87,14 +89,14 @@ export function extractUniquePersons(
// Track referenced connections from "p" tags // Track referenced connections from "p" tags
if (event.tags) { if (event.tags) {
event.tags.forEach(tag => { event.tags.forEach((tag) => {
if (tag[0] === "p" && tag[1]) { if (tag[0] === "p" && tag[1]) {
const referencedPubkey = tag[1]; const referencedPubkey = tag[1];
if (!personMap.has(referencedPubkey)) { if (!personMap.has(referencedPubkey)) {
personMap.set(referencedPubkey, { personMap.set(referencedPubkey, {
signedByEventIds: new Set(), signedByEventIds: new Set(),
referencedInEventIds: new Set(), referencedInEventIds: new Set(),
isFromFollowList: followListPubkeys.has(referencedPubkey) isFromFollowList: followListPubkeys.has(referencedPubkey),
}); });
} }
personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id); personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id);
@ -115,7 +117,7 @@ function buildEligiblePerson(
pubkey: string, pubkey: string,
connection: PersonConnection, connection: PersonConnection,
showSignedBy: boolean, showSignedBy: boolean,
showReferenced: boolean showReferenced: boolean,
): { ): {
pubkey: string; pubkey: string;
connection: PersonConnection; connection: PersonConnection;
@ -125,11 +127,11 @@ function buildEligiblePerson(
const connectedEventIds = new Set<string>(); const connectedEventIds = new Set<string>();
if (showSignedBy) { if (showSignedBy) {
connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); connection.signedByEventIds.forEach((id) => connectedEventIds.add(id));
} }
if (showReferenced) { if (showReferenced) {
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); connection.referencedInEventIds.forEach((id) => connectedEventIds.add(id));
} }
if (connectedEventIds.size === 0) { if (connectedEventIds.size === 0) {
@ -140,7 +142,7 @@ function buildEligiblePerson(
pubkey, pubkey,
connection, connection,
connectedEventIds, connectedEventIds,
totalConnections: connectedEventIds.size totalConnections: connectedEventIds.size,
}; };
} }
@ -155,7 +157,7 @@ function getEligiblePersons(
personMap: Map<string, PersonConnection>, personMap: Map<string, PersonConnection>,
showSignedBy: boolean, showSignedBy: boolean,
showReferenced: boolean, showReferenced: boolean,
limit: number limit: number,
): EligiblePerson[] { ): EligiblePerson[] {
// Build eligible persons and keep only top N using a min-heap or partial sort // Build eligible persons and keep only top N using a min-heap or partial sort
const eligible: EligiblePerson[] = []; const eligible: EligiblePerson[] = [];
@ -163,16 +165,20 @@ function getEligiblePersons(
for (const [pubkey, connection] of personMap) { for (const [pubkey, connection] of personMap) {
let totalConnections = 0; let totalConnections = 0;
if (showSignedBy) totalConnections += connection.signedByEventIds.size; if (showSignedBy) totalConnections += connection.signedByEventIds.size;
if (showReferenced) totalConnections += connection.referencedInEventIds.size; if (showReferenced) {
totalConnections += connection.referencedInEventIds.size;
}
if (totalConnections === 0) continue; if (totalConnections === 0) continue;
// Only build the set if this person is eligible // Only build the set if this person is eligible
const connectedEventIds = new Set<string>(); const connectedEventIds = new Set<string>();
if (showSignedBy) { if (showSignedBy) {
connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); connection.signedByEventIds.forEach((id) => connectedEventIds.add(id));
} }
if (showReferenced) { if (showReferenced) {
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); connection.referencedInEventIds.forEach((id) =>
connectedEventIds.add(id)
);
} }
eligible.push({ pubkey, connection, totalConnections, connectedEventIds }); eligible.push({ pubkey, connection, totalConnections, connectedEventIds });
@ -192,22 +198,27 @@ export function createPersonAnchorNodes(
height: number, height: number,
showSignedBy: boolean, showSignedBy: boolean,
showReferenced: boolean, showReferenced: boolean,
limit: number = MAX_PERSON_NODES limit: number = MAX_PERSON_NODES,
): { nodes: NetworkNode[], totalCount: number } { ): { nodes: NetworkNode[]; totalCount: number } {
const anchorNodes: NetworkNode[] = []; const anchorNodes: NetworkNode[] = [];
const centerX = width / 2; const centerX = width / 2;
const centerY = height / 2; const centerY = height / 2;
// Calculate eligible persons and their connection counts // Calculate eligible persons and their connection counts
const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit); const eligiblePersons = getEligiblePersons(
personMap,
showSignedBy,
showReferenced,
limit,
);
// Create nodes for the limited set // Create nodes for the limited set
debug("Creating person anchor nodes", { debug("Creating person anchor nodes", {
eligibleCount: eligiblePersons.length, eligibleCount: eligiblePersons.length,
limitedCount: eligiblePersons.length, limitedCount: eligiblePersons.length,
showSignedBy, showSignedBy,
showReferenced showReferenced,
}); });
eligiblePersons.forEach(({ pubkey, connection, connectedEventIds }) => { eligiblePersons.forEach(({ pubkey, connection, connectedEventIds }) => {
@ -226,7 +237,8 @@ export function createPersonAnchorNodes(
const anchorNode: NetworkNode = { const anchorNode: NetworkNode = {
id: `person-anchor-${pubkey}`, id: `person-anchor-${pubkey}`,
title: displayName, title: displayName,
content: `${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`, content:
`${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`,
author: "", author: "",
kind: 0, // Special kind for anchors kind: 0, // Special kind for anchors
type: "PersonAnchor", type: "PersonAnchor",
@ -245,11 +257,14 @@ export function createPersonAnchorNodes(
anchorNodes.push(anchorNode); anchorNodes.push(anchorNode);
}); });
debug("Created person anchor nodes", { count: anchorNodes.length, totalEligible: eligiblePersons.length }); debug("Created person anchor nodes", {
count: anchorNodes.length,
totalEligible: eligiblePersons.length,
});
return { return {
nodes: anchorNodes, nodes: anchorNodes,
totalCount: eligiblePersons.length totalCount: eligiblePersons.length,
}; };
} }
@ -264,9 +279,12 @@ export interface PersonLink extends NetworkLink {
export function createPersonLinks( export function createPersonLinks(
personAnchors: NetworkNode[], personAnchors: NetworkNode[],
nodes: NetworkNode[], nodes: NetworkNode[],
personMap: Map<string, PersonConnection> personMap: Map<string, PersonConnection>,
): PersonLink[] { ): PersonLink[] {
debug("Creating person links", { anchorCount: personAnchors.length, nodeCount: nodes.length }); debug("Creating person links", {
anchorCount: personAnchors.length,
nodeCount: nodes.length,
});
const nodeMap = new Map(nodes.map((n) => [n.id, n])); const nodeMap = new Map(nodes.map((n) => [n.id, n]));
@ -286,11 +304,11 @@ export function createPersonLinks(
return undefined; return undefined;
} }
let connectionType: 'signed-by' | 'referenced' | undefined; let connectionType: "signed-by" | "referenced" | undefined;
if (connection.signedByEventIds.has(nodeId)) { if (connection.signedByEventIds.has(nodeId)) {
connectionType = 'signed-by'; connectionType = "signed-by";
} else if (connection.referencedInEventIds.has(nodeId)) { } else if (connection.referencedInEventIds.has(nodeId)) {
connectionType = 'referenced'; connectionType = "referenced";
} }
const link: PersonLink = { const link: PersonLink = {
@ -324,9 +342,9 @@ export interface PersonAnchorInfo {
*/ */
export function extractPersonAnchorInfo( export function extractPersonAnchorInfo(
personAnchors: NetworkNode[], personAnchors: NetworkNode[],
personMap: Map<string, PersonConnection> personMap: Map<string, PersonConnection>,
): PersonAnchorInfo[] { ): PersonAnchorInfo[] {
return personAnchors.map(anchor => { return personAnchors.map((anchor) => {
const connection = personMap.get(anchor.pubkey || ""); const connection = personMap.get(anchor.pubkey || "");
return { return {
pubkey: anchor.pubkey || "", pubkey: anchor.pubkey || "",

51
src/lib/navigator/EventNetwork/utils/starForceSimulation.ts

@ -7,7 +7,7 @@
*/ */
import * as d3 from "d3"; import * as d3 from "d3";
import type { NetworkNode, NetworkLink } from "../types"; import type { NetworkLink, NetworkNode } from "../types";
import type { Simulation } from "./forceSimulation"; import type { Simulation } from "./forceSimulation";
import { createTagGravityForce } from "./tagNetworkBuilder"; import { createTagGravityForce } from "./tagNetworkBuilder";
@ -28,12 +28,15 @@ export function createStarSimulation(
nodes: NetworkNode[], nodes: NetworkNode[],
links: NetworkLink[], links: NetworkLink[],
width: number, width: number,
height: number height: number,
): Simulation<NetworkNode, NetworkLink> { ): Simulation<NetworkNode, NetworkLink> {
// Create the simulation // Create the simulation
const simulation = d3.forceSimulation(nodes) as any const simulation = d3.forceSimulation(nodes) as any;
simulation simulation
.force("center", d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY)) .force(
"center",
d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY),
)
.velocityDecay(0.2) // Lower decay for more responsive simulation .velocityDecay(0.2) // Lower decay for more responsive simulation
.alphaDecay(0.0001) // Much slower alpha decay to prevent freezing .alphaDecay(0.0001) // Much slower alpha decay to prevent freezing
.alphaMin(0.001); // Keep minimum energy to prevent complete freeze .alphaMin(0.001); // Keep minimum energy to prevent complete freeze
@ -93,7 +96,7 @@ export function createStarSimulation(
simulation.force("radial", createRadialForce(nodes, links)); simulation.force("radial", createRadialForce(nodes, links));
// Add tag gravity force if there are tag anchors // Add tag gravity force if there are tag anchors
const hasTagAnchors = nodes.some(n => n.isTagAnchor); const hasTagAnchors = nodes.some((n) => n.isTagAnchor);
if (hasTagAnchors) { if (hasTagAnchors) {
simulation.force("tagGravity", createTagGravityForce(nodes, links)); simulation.force("tagGravity", createTagGravityForce(nodes, links));
} }
@ -122,9 +125,9 @@ function applyRadialForce(
nodes: NetworkNode[], nodes: NetworkNode[],
nodeToCenter: Map<string, NetworkNode>, nodeToCenter: Map<string, NetworkNode>,
targetDistance: number, targetDistance: number,
alpha: number alpha: number,
): void { ): void {
nodes.forEach(node => { nodes.forEach((node) => {
if (node.kind === 30041) { if (node.kind === 30041) {
const center = nodeToCenter.get(node.id); const center = nodeToCenter.get(node.id);
if ( if (
@ -157,7 +160,7 @@ function createRadialForce(nodes: NetworkNode[], links: NetworkLink[]): any {
// Build a map of content nodes to their star centers // Build a map of content nodes to their star centers
const nodeToCenter = new Map<string, NetworkNode>(); const nodeToCenter = new Map<string, NetworkNode>();
links.forEach(link => { links.forEach((link) => {
const source = link.source as NetworkNode; const source = link.source as NetworkNode;
const target = link.target as NetworkNode; const target = link.target as NetworkNode;
if (source.kind === 30040 && target.kind === 30041) { if (source.kind === 30040 && target.kind === 30041) {
@ -183,14 +186,14 @@ export function applyInitialStarPositions(
nodes: NetworkNode[], nodes: NetworkNode[],
links: NetworkLink[], links: NetworkLink[],
width: number, width: number,
height: number height: number,
): void { ): void {
// Group nodes by their star centers // Group nodes by their star centers
const starGroups = new Map<string, NetworkNode[]>(); const starGroups = new Map<string, NetworkNode[]>();
const starCenters: NetworkNode[] = []; const starCenters: NetworkNode[] = [];
// Identify star centers // Identify star centers
nodes.forEach(node => { nodes.forEach((node) => {
if (node.isContainer && node.kind === 30040) { if (node.isContainer && node.kind === 30040) {
starCenters.push(node); starCenters.push(node);
starGroups.set(node.id, []); starGroups.set(node.id, []);
@ -198,7 +201,7 @@ export function applyInitialStarPositions(
}); });
// Assign content nodes to their star centers // Assign content nodes to their star centers
links.forEach(link => { links.forEach((link) => {
const source = link.source as NetworkNode; const source = link.source as NetworkNode;
const target = link.target as NetworkNode; const target = link.target as NetworkNode;
if (source.kind === 30040 && target.kind === 30041) { if (source.kind === 30040 && target.kind === 30041) {
@ -233,7 +236,7 @@ export function applyInitialStarPositions(
// Position content nodes around their star centers // Position content nodes around their star centers
starGroups.forEach((contentNodes, centerId) => { starGroups.forEach((contentNodes, centerId) => {
const center = nodes.find(n => n.id === centerId); const center = nodes.find((n) => n.id === centerId);
if (!center) return; if (!center) return;
const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1); const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1);
@ -252,7 +255,11 @@ export function applyInitialStarPositions(
* @param d - The node being dragged * @param d - The node being dragged
* @param simulation - The d3 force simulation instance * @param simulation - The d3 force simulation instance
*/ */
function dragstarted(event: any, d: NetworkNode, simulation: Simulation<NetworkNode, NetworkLink>) { function dragstarted(
event: any,
d: NetworkNode,
simulation: Simulation<NetworkNode, NetworkLink>,
) {
// If no other drag is active, set a low alpha target to keep the simulation running smoothly // If no other drag is active, set a low alpha target to keep the simulation running smoothly
if (!event.active) { if (!event.active) {
simulation.alphaTarget(0.1).restart(); simulation.alphaTarget(0.1).restart();
@ -281,7 +288,11 @@ function dragged(event: any, d: NetworkNode) {
* @param d - The node being dragged * @param d - The node being dragged
* @param simulation - The d3 force simulation instance * @param simulation - The d3 force simulation instance
*/ */
function dragended(event: any, d: NetworkNode, simulation: Simulation<NetworkNode, NetworkLink>) { function dragended(
event: any,
d: NetworkNode,
simulation: Simulation<NetworkNode, NetworkLink>,
) {
// If no other drag is active, lower the alpha target to let the simulation cool down // If no other drag is active, lower the alpha target to let the simulation cool down
if (!event.active) { if (!event.active) {
simulation.alphaTarget(0); simulation.alphaTarget(0);
@ -297,12 +308,16 @@ function dragended(event: any, d: NetworkNode, simulation: Simulation<NetworkNod
* @returns The d3 drag behavior * @returns The d3 drag behavior
*/ */
export function createStarDragHandler( export function createStarDragHandler(
simulation: Simulation<NetworkNode, NetworkLink> simulation: Simulation<NetworkNode, NetworkLink>,
): any { ): any {
// These handlers are now top-level functions, so we use closures to pass simulation to them. // These handlers are now top-level functions, so we use closures to pass simulation to them.
// This is a common pattern in JavaScript/TypeScript when you need to pass extra arguments to event handlers. // This is a common pattern in JavaScript/TypeScript when you need to pass extra arguments to event handlers.
return d3.drag() return d3.drag()
.on('start', function(event: any, d: NetworkNode) { dragstarted(event, d, simulation); }) .on("start", function (event: any, d: NetworkNode) {
.on('drag', dragged) dragstarted(event, d, simulation);
.on('end', function(event: any, d: NetworkNode) { dragended(event, d, simulation); }); })
.on("drag", dragged)
.on("end", function (event: any, d: NetworkNode) {
dragended(event, d, simulation);
});
} }

88
src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts

@ -8,12 +8,16 @@
*/ */
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import type { GraphData, GraphState, NetworkLink, NetworkNode } from "../types";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { createNetworkNode, createEventMap, extractEventIdFromATag, getEventColor } from './networkBuilder'; import {
import { createDebugFunction } from './common'; createEventMap,
import { wikiKind, indexKind, zettelKinds } from '$lib/consts'; createNetworkNode,
extractEventIdFromATag,
getEventColor,
} from "./networkBuilder";
import { createDebugFunction } from "./common";
import { indexKind, wikiKind, zettelKinds } from "$lib/consts";
// Debug function // Debug function
const debug = createDebugFunction("StarNetworkBuilder"); const debug = createDebugFunction("StarNetworkBuilder");
@ -38,7 +42,7 @@ export interface StarNetwork {
export function createStarNetwork( export function createStarNetwork(
indexEvent: NDKEvent, indexEvent: NDKEvent,
state: GraphState, state: GraphState,
level: number = 0 level: number = 0,
): StarNetwork | null { ): StarNetwork | null {
debug("Creating star network", { indexId: indexEvent.id, level }); debug("Creating star network", { indexId: indexEvent.id, level });
@ -53,16 +57,19 @@ export function createStarNetwork(
// Extract referenced event IDs from 'a' tags // Extract referenced event IDs from 'a' tags
const referencedIds = getMatchingTags(indexEvent, "a") const referencedIds = getMatchingTags(indexEvent, "a")
.map(tag => extractEventIdFromATag(tag)) .map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null); .filter((id): id is string => id !== null);
debug("Found referenced IDs", { count: referencedIds.length, ids: referencedIds }); debug("Found referenced IDs", {
count: referencedIds.length,
ids: referencedIds,
});
// Get peripheral nodes (both content and nested indices) // Get peripheral nodes (both content and nested indices)
const peripheralNodes: NetworkNode[] = []; const peripheralNodes: NetworkNode[] = [];
const links: NetworkLink[] = []; const links: NetworkLink[] = [];
referencedIds.forEach(id => { referencedIds.forEach((id) => {
const node = state.nodeMap.get(id); const node = state.nodeMap.get(id);
if (node) { if (node) {
// Set the peripheral node level // Set the peripheral node level
@ -73,7 +80,7 @@ export function createStarNetwork(
links.push({ links.push({
source: centerNode, source: centerNode,
target: node, target: node,
isSequential: false // Star links are not sequential isSequential: false, // Star links are not sequential
}); });
debug("Added peripheral node", { nodeId: id, nodeType: node.type }); debug("Added peripheral node", { nodeId: id, nodeType: node.type });
@ -83,7 +90,7 @@ export function createStarNetwork(
return { return {
center: centerNode, center: centerNode,
peripheralNodes, peripheralNodes,
links links,
}; };
} }
@ -97,7 +104,7 @@ export function createStarNetwork(
export function createStarNetworks( export function createStarNetworks(
events: NDKEvent[], events: NDKEvent[],
maxLevel: number, maxLevel: number,
existingNodeMap?: Map<string, NetworkNode> existingNodeMap?: Map<string, NetworkNode>,
): StarNetwork[] { ): StarNetwork[] {
debug("Creating star networks", { eventCount: events.length, maxLevel }); debug("Creating star networks", { eventCount: events.length, maxLevel });
@ -107,7 +114,7 @@ export function createStarNetworks(
// Create nodes for all events if not using existing map // Create nodes for all events if not using existing map
if (!existingNodeMap) { if (!existingNodeMap) {
events.forEach(event => { events.forEach((event) => {
if (!event.id) return; if (!event.id) return;
const node = createNetworkNode(event); const node = createNetworkNode(event);
nodeMap.set(event.id, node); nodeMap.set(event.id, node);
@ -118,13 +125,13 @@ export function createStarNetworks(
nodeMap, nodeMap,
links: [], links: [],
eventMap, eventMap,
referencedIds: new Set<string>() referencedIds: new Set<string>(),
}; };
// Find all index events and non-publication events // Find all index events and non-publication events
const publicationKinds = [wikiKind, indexKind, ...zettelKinds]; const publicationKinds = [wikiKind, indexKind, ...zettelKinds];
const indexEvents = events.filter(event => event.kind === indexKind); const indexEvents = events.filter((event) => event.kind === indexKind);
const nonPublicationEvents = events.filter(event => const nonPublicationEvents = events.filter((event) =>
event.kind !== undefined && !publicationKinds.includes(event.kind) event.kind !== undefined && !publicationKinds.includes(event.kind)
); );
@ -135,7 +142,7 @@ export function createStarNetworks(
const processedIndices = new Set<string>(); const processedIndices = new Set<string>();
// Process all index events regardless of level // Process all index events regardless of level
indexEvents.forEach(indexEvent => { indexEvents.forEach((indexEvent) => {
if (!indexEvent.id || processedIndices.has(indexEvent.id)) return; if (!indexEvent.id || processedIndices.has(indexEvent.id)) return;
const star = createStarNetwork(indexEvent, state, 0); const star = createStarNetwork(indexEvent, state, 0);
@ -144,25 +151,25 @@ export function createStarNetworks(
processedIndices.add(indexEvent.id); processedIndices.add(indexEvent.id);
debug("Created star network", { debug("Created star network", {
centerId: star.center.id, centerId: star.center.id,
peripheralCount: star.peripheralNodes.length peripheralCount: star.peripheralNodes.length,
}); });
} }
}); });
// Add non-publication events as standalone nodes (stars with no peripherals) // Add non-publication events as standalone nodes (stars with no peripherals)
nonPublicationEvents.forEach(event => { nonPublicationEvents.forEach((event) => {
if (!event.id || !nodeMap.has(event.id)) return; if (!event.id || !nodeMap.has(event.id)) return;
const node = nodeMap.get(event.id)!; const node = nodeMap.get(event.id)!;
const star: StarNetwork = { const star: StarNetwork = {
center: node, center: node,
peripheralNodes: [], peripheralNodes: [],
links: [] links: [],
}; };
starNetworks.push(star); starNetworks.push(star);
debug("Created standalone star for non-publication event", { debug("Created standalone star for non-publication event", {
eventId: event.id, eventId: event.id,
kind: event.kind kind: event.kind,
}); });
}); });
@ -175,32 +182,36 @@ export function createStarNetworks(
* @param starNetworks - Array of star networks * @param starNetworks - Array of star networks
* @returns Additional links connecting different star networks * @returns Additional links connecting different star networks
*/ */
export function createInterStarConnections(starNetworks: StarNetwork[]): NetworkLink[] { export function createInterStarConnections(
starNetworks: StarNetwork[],
): NetworkLink[] {
debug("Creating inter-star connections", { starCount: starNetworks.length }); debug("Creating inter-star connections", { starCount: starNetworks.length });
const interStarLinks: NetworkLink[] = []; const interStarLinks: NetworkLink[] = [];
// Create a map of center nodes for quick lookup // Create a map of center nodes for quick lookup
const centerNodeMap = new Map<string, NetworkNode>(); const centerNodeMap = new Map<string, NetworkNode>();
starNetworks.forEach(star => { starNetworks.forEach((star) => {
centerNodeMap.set(star.center.id, star.center); centerNodeMap.set(star.center.id, star.center);
}); });
// For each star, check if any of its peripheral nodes are centers of other stars // For each star, check if any of its peripheral nodes are centers of other stars
starNetworks.forEach(star => { starNetworks.forEach((star) => {
star.peripheralNodes.forEach(peripheralNode => { star.peripheralNodes.forEach((peripheralNode) => {
// If this peripheral node is the center of another star, create an inter-star link // If this peripheral node is the center of another star, create an inter-star link
if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) { if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) {
const targetStar = starNetworks.find(s => s.center.id === peripheralNode.id); const targetStar = starNetworks.find((s) =>
s.center.id === peripheralNode.id
);
if (targetStar) { if (targetStar) {
interStarLinks.push({ interStarLinks.push({
source: star.center, source: star.center,
target: targetStar.center, target: targetStar.center,
isSequential: false isSequential: false,
}); });
debug("Created inter-star connection", { debug("Created inter-star connection", {
from: star.center.id, from: star.center.id,
to: targetStar.center.id to: targetStar.center.id,
}); });
} }
} }
@ -220,11 +231,11 @@ export function createInterStarConnections(starNetworks: StarNetwork[]): Network
export function applyStarLayout( export function applyStarLayout(
starNetworks: StarNetwork[], starNetworks: StarNetwork[],
width: number, width: number,
height: number height: number,
): void { ): void {
debug("Applying star layout", { debug("Applying star layout", {
starCount: starNetworks.length, starCount: starNetworks.length,
dimensions: { width, height } dimensions: { width, height },
}); });
const centerX = width / 2; const centerX = width / 2;
@ -256,7 +267,8 @@ export function applyStarLayout(
// For multiple stars, arrange them in a grid or circle // For multiple stars, arrange them in a grid or circle
const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length)); const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length));
const starSpacingX = width / (starsPerRow + 1); const starSpacingX = width / (starsPerRow + 1);
const starSpacingY = height / (Math.ceil(starNetworks.length / starsPerRow) + 1); const starSpacingY = height /
(Math.ceil(starNetworks.length / starsPerRow) + 1);
starNetworks.forEach((star, index) => { starNetworks.forEach((star, index) => {
const row = Math.floor(index / starsPerRow); const row = Math.floor(index / starsPerRow);
@ -292,7 +304,7 @@ export function applyStarLayout(
*/ */
export function generateStarGraph( export function generateStarGraph(
events: NDKEvent[], events: NDKEvent[],
maxLevel: number maxLevel: number,
): GraphData { ): GraphData {
debug("Generating star graph", { eventCount: events.length, maxLevel }); debug("Generating star graph", { eventCount: events.length, maxLevel });
@ -303,7 +315,7 @@ export function generateStarGraph(
// Initialize all nodes first // Initialize all nodes first
const nodeMap = new Map<string, NetworkNode>(); const nodeMap = new Map<string, NetworkNode>();
events.forEach(event => { events.forEach((event) => {
if (!event.id) return; if (!event.id) return;
const node = createNetworkNode(event); const node = createNetworkNode(event);
nodeMap.set(event.id, node); nodeMap.set(event.id, node);
@ -320,9 +332,9 @@ export function generateStarGraph(
const allLinks: NetworkLink[] = []; const allLinks: NetworkLink[] = [];
// Add nodes and links from all stars // Add nodes and links from all stars
starNetworks.forEach(star => { starNetworks.forEach((star) => {
nodesInStars.add(star.center.id); nodesInStars.add(star.center.id);
star.peripheralNodes.forEach(node => { star.peripheralNodes.forEach((node) => {
nodesInStars.add(node.id); nodesInStars.add(node.id);
}); });
allLinks.push(...star.links); allLinks.push(...star.links);
@ -339,14 +351,14 @@ export function generateStarGraph(
const result = { const result = {
nodes: allNodes, nodes: allNodes,
links: allLinks links: allLinks,
}; };
debug("Star graph generation complete", { debug("Star graph generation complete", {
nodeCount: result.nodes.length, nodeCount: result.nodes.length,
linkCount: result.links.length, linkCount: result.links.length,
starCount: starNetworks.length, starCount: starNetworks.length,
orphanedNodes: allNodes.length - nodesInStars.size orphanedNodes: allNodes.length - nodesInStars.size,
}); });
return result; return result;

19
src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts

@ -6,9 +6,9 @@
*/ */
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData } from "../types"; import type { GraphData, NetworkLink, NetworkNode } from "../types";
import { getDisplayNameSync } from "$lib/utils/profileCache"; import { getDisplayNameSync } from "$lib/utils/npubCache";
import { SeededRandom, createDebugFunction } from "./common"; import { createDebugFunction, SeededRandom } from "./common";
// Configuration // Configuration
const TAG_ANCHOR_RADIUS = 15; const TAG_ANCHOR_RADIUS = 15;
@ -18,7 +18,6 @@ const TAG_ANCHOR_PLACEMENT_RADIUS = 1250; // Radius from center within which to
// Debug function // Debug function
const debug = createDebugFunction("TagNetworkBuilder"); const debug = createDebugFunction("TagNetworkBuilder");
/** /**
* Creates a deterministic seed from a string * Creates a deterministic seed from a string
*/ */
@ -63,7 +62,10 @@ export function extractUniqueTagsForType(
): Map<string, Set<string>> { ): Map<string, Set<string>> {
// Map of tagValue -> Set of event IDs // Map of tagValue -> Set of event IDs
const tagMap = new Map<string, Set<string>>(); const tagMap = new Map<string, Set<string>>();
debug("Extracting unique tags for type", { tagType, eventCount: events.length }); debug("Extracting unique tags for type", {
tagType,
eventCount: events.length,
});
events.forEach((event) => { events.forEach((event) => {
if (!event.tags || !event.id) return; if (!event.tags || !event.id) return;
@ -172,7 +174,10 @@ export function createTagLinks(
tagAnchors: NetworkNode[], tagAnchors: NetworkNode[],
nodes: NetworkNode[], nodes: NetworkNode[],
): NetworkLink[] { ): NetworkLink[] {
debug("Creating tag links", { anchorCount: tagAnchors.length, nodeCount: nodes.length }); debug("Creating tag links", {
anchorCount: tagAnchors.length,
nodeCount: nodes.length,
});
const links: NetworkLink[] = []; const links: NetworkLink[] = [];
const nodeMap = new Map(nodes.map((n) => [n.id, n])); const nodeMap = new Map(nodes.map((n) => [n.id, n]));
@ -242,7 +247,7 @@ export function enhanceGraphWithTags(
export function applyTagGravity( export function applyTagGravity(
nodes: NetworkNode[], nodes: NetworkNode[],
nodeToAnchors: Map<string, NetworkNode[]>, nodeToAnchors: Map<string, NetworkNode[]>,
alpha: number alpha: number,
): void { ): void {
nodes.forEach((node) => { nodes.forEach((node) => {
if (node.isTagAnchor) return; // Tag anchors don't move if (node.isTagAnchor) return; // Tag anchors don't move

430
src/lib/ndk.ts

@ -1,29 +1,30 @@
import NDK, { import NDK, {
NDKEvent,
NDKNip07Signer, NDKNip07Signer,
NDKRelay, NDKRelay,
NDKRelayAuthPolicies, NDKRelayAuthPolicies,
NDKRelaySet, NDKRelaySet,
NDKUser, NDKUser,
NDKEvent,
} from "@nostr-dev-kit/ndk"; } from "@nostr-dev-kit/ndk";
import { writable, get, type Writable } from "svelte/store"; import { get, type Writable, writable } from "svelte/store";
import { import { anonymousRelays, loginStorageKey } from "./consts.ts";
loginStorageKey,
} from "./consts.ts";
import { import {
buildCompleteRelaySet, buildCompleteRelaySet,
testRelayConnection,
deduplicateRelayUrls, deduplicateRelayUrls,
testRelayConnection,
} from "./utils/relay_management.ts"; } from "./utils/relay_management.ts";
import { userStore } from "./stores/userStore.ts";
import {
startNetworkStatusMonitoring,
stopNetworkStatusMonitoring,
} from "./stores/networkStore.ts";
import { WebSocketPool } from "./data_structures/websocket_pool.ts";
import { getContext, setContext } from "svelte";
// Re-export testRelayConnection for components that need it // Re-export testRelayConnection for components that need it
export { testRelayConnection }; export { testRelayConnection };
import { userStore } from "./stores/userStore.ts";
import { userPubkey } from "./stores/authStore.Svelte.ts";
import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring } from "./stores/networkStore.ts";
import { WebSocketPool } from "./data_structures/websocket_pool.ts";
export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false); export const ndkSignedIn = writable(false);
export const activePubkey = writable<string | null>(null); export const activePubkey = writable<string | null>(null);
export const inboxRelays = writable<string[]>([]); export const inboxRelays = writable<string[]>([]);
@ -33,10 +34,91 @@ export const outboxRelays = writable<string[]>([]);
export const activeInboxRelays = writable<string[]>([]); export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]); export const activeOutboxRelays = writable<string[]>([]);
// Subscribe to userStore changes and update ndkSignedIn accordingly const NDK_CONTEXT_KEY = "ndk";
userStore.subscribe((userState) => {
ndkSignedIn.set(userState.signedIn); export function getNdkContext(): NDK {
}); return getContext(NDK_CONTEXT_KEY) as NDK;
}
export function setNdkContext(ndk: NDK): void {
setContext(NDK_CONTEXT_KEY, ndk);
}
// AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation
let persistentRelaySet:
| { inboxRelays: string[]; outboxRelays: string[] }
| null = null;
let relaySetLastUpdated: number = 0;
const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const RELAY_SET_STORAGE_KEY = "alexandria/relay_set_cache";
/**
* Load persistent relay set from localStorage
*/
function loadPersistentRelaySet(): {
relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null;
lastUpdated: number;
} {
// Only load from localStorage on client-side
if (typeof window === "undefined") return { relaySet: null, lastUpdated: 0 };
try {
const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY);
if (!stored) return { relaySet: null, lastUpdated: 0 };
const data = JSON.parse(stored);
const now = Date.now();
// Check if cache is expired
if (now - data.timestamp > RELAY_SET_CACHE_DURATION) {
localStorage.removeItem(RELAY_SET_STORAGE_KEY);
return { relaySet: null, lastUpdated: 0 };
}
return { relaySet: data.relaySet, lastUpdated: data.timestamp };
} catch (error) {
console.warn("[NDK.ts] Failed to load persistent relay set:", error);
localStorage.removeItem(RELAY_SET_STORAGE_KEY);
return { relaySet: null, lastUpdated: 0 };
}
}
/**
* Save persistent relay set to localStorage
*/
function savePersistentRelaySet(
relaySet: { inboxRelays: string[]; outboxRelays: string[] },
): void {
// Only save to localStorage on client-side
if (typeof window === "undefined") return;
try {
const data = {
relaySet,
timestamp: Date.now(),
};
localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.warn("[NDK.ts] Failed to save persistent relay set:", error);
}
}
/**
* Clear persistent relay set from localStorage
*/
function clearPersistentRelaySet(): void {
// Only clear from localStorage on client-side
if (typeof window === "undefined") return;
try {
localStorage.removeItem(RELAY_SET_STORAGE_KEY);
} catch (error) {
console.warn("[NDK.ts] Failed to clear persistent relay set:", error);
}
}
// AI-NOTE: userStore subscription moved to initNdk function to prevent initialization errors
// The subscription will be set up after userStore is properly initialized
/** /**
* Custom authentication policy that handles NIP-42 authentication manually * Custom authentication policy that handles NIP-42 authentication manually
@ -165,8 +247,7 @@ class CustomRelayAuthPolicy {
export function checkEnvironmentForWebSocketDowngrade(): void { export function checkEnvironmentForWebSocketDowngrade(): void {
console.debug("[NDK.ts] Environment Check for WebSocket Protocol:"); console.debug("[NDK.ts] Environment Check for WebSocket Protocol:");
const isLocalhost = const isLocalhost = globalThis.location.hostname === "localhost" ||
globalThis.location.hostname === "localhost" ||
globalThis.location.hostname === "127.0.0.1"; globalThis.location.hostname === "127.0.0.1";
const isHttp = globalThis.location.protocol === "http:"; const isHttp = globalThis.location.protocol === "http:";
const isHttps = globalThis.location.protocol === "https:"; const isHttps = globalThis.location.protocol === "https:";
@ -216,8 +297,6 @@ export function checkWebSocketSupport(): void {
} }
} }
/** /**
* Gets the user's pubkey from local storage, if it exists. * Gets the user's pubkey from local storage, if it exists.
* @returns The user's pubkey, or null if there is no logged-in user. * @returns The user's pubkey, or null if there is no logged-in user.
@ -225,6 +304,9 @@ export function checkWebSocketSupport(): void {
* sessions. * sessions.
*/ */
export function getPersistedLogin(): string | null { export function getPersistedLogin(): string | null {
// Only access localStorage on client-side
if (typeof window === "undefined") return null;
const pubkey = localStorage.getItem(loginStorageKey); const pubkey = localStorage.getItem(loginStorageKey);
return pubkey; return pubkey;
} }
@ -236,6 +318,9 @@ export function getPersistedLogin(): string | null {
* time. * time.
*/ */
export function persistLogin(user: NDKUser): void { export function persistLogin(user: NDKUser): void {
// Only access localStorage on client-side
if (typeof window === "undefined") return;
localStorage.setItem(loginStorageKey, user.pubkey); localStorage.setItem(loginStorageKey, user.pubkey);
} }
@ -244,6 +329,9 @@ export function persistLogin(user: NDKUser): void {
* @remarks Use this function when the user logs out. * @remarks Use this function when the user logs out.
*/ */
export function clearLogin(): void { export function clearLogin(): void {
// Only access localStorage on client-side
if (typeof window === "undefined") return;
localStorage.removeItem(loginStorageKey); localStorage.removeItem(loginStorageKey);
} }
@ -258,6 +346,9 @@ function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
} }
export function clearPersistedRelays(user: NDKUser): void { export function clearPersistedRelays(user: NDKUser): void {
// Only access localStorage on client-side
if (typeof window === "undefined") return;
localStorage.removeItem(getRelayStorageKey(user, "inbox")); localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, "outbox")); localStorage.removeItem(getRelayStorageKey(user, "outbox"));
} }
@ -265,15 +356,21 @@ export function clearPersistedRelays(user: NDKUser): void {
/** /**
* Ensures a relay URL uses secure WebSocket protocol * Ensures a relay URL uses secure WebSocket protocol
* @param url The relay URL to secure * @param url The relay URL to secure
* @returns The URL with wss:// protocol * @returns The URL with appropriate protocol (ws:// for localhost, wss:// for remote)
*/ */
function ensureSecureWebSocket(url: string): string { function ensureSecureWebSocket(url: string): string {
// Replace ws:// with wss:// if present // For localhost, always use ws:// (never wss://)
if (url.includes("localhost") || url.includes("127.0.0.1")) {
// Convert any wss://localhost to ws://localhost
return url.replace(/^wss:\/\//, "ws://");
}
// Replace ws:// with wss:// for remote relays
const secureUrl = url.replace(/^ws:\/\//, "wss://"); const secureUrl = url.replace(/^ws:\/\//, "wss://");
if (secureUrl !== url) { if (secureUrl !== url) {
console.warn( console.warn(
`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`, `[NDK.ts] Protocol upgrade for remote relay: ${url} -> ${secureUrl}`,
); );
} }
@ -284,9 +381,13 @@ function ensureSecureWebSocket(url: string): string {
* Creates a relay with proper authentication handling * Creates a relay with proper authentication handling
*/ */
function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
try {
// Reduce verbosity in development - only log relay creation if debug mode is enabled
if (process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS) {
console.debug(`[NDK.ts] Creating relay with URL: ${url}`); console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
}
// Ensure the URL is using wss:// protocol // Ensure the URL is using appropriate protocol
const secureUrl = ensureSecureWebSocket(url); const secureUrl = ensureSecureWebSocket(url);
// Add connection timeout and error handling // Add connection timeout and error handling
@ -298,52 +399,125 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
// Set up connection timeout // Set up connection timeout
const connectionTimeout = setTimeout(() => { const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`); try {
// Only log connection timeouts if debug mode is enabled
if (
process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS
) {
console.debug(`[NDK.ts] Connection timeout for ${secureUrl}`);
}
relay.disconnect(); relay.disconnect();
} catch {
// Silently ignore disconnect errors
}
}, 5000); // 5 second timeout }, 5000); // 5 second timeout
// Set up custom authentication handling only if user is signed in // Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) { if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk); const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on("connect", () => { relay.on("connect", () => {
try {
// Only log successful connections if debug mode is enabled
if (
process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS
) {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
}
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
authPolicy.authenticate(relay); authPolicy.authenticate(relay);
} catch {
// Silently handle connect handler errors
}
}); });
} else { } else {
relay.on("connect", () => { relay.on("connect", () => {
try {
// Only log successful connections if debug mode is enabled
if (
process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS
) {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
}
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
} catch {
// Silently handle connect handler errors
}
}); });
} }
// Add error handling // Add error handling
relay.on("disconnect", () => { relay.on("disconnect", () => {
try {
console.debug(`[NDK.ts] Relay disconnected: ${secureUrl}`); console.debug(`[NDK.ts] Relay disconnected: ${secureUrl}`);
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
} catch {
// Silently handle disconnect handler errors
}
}); });
return relay; return relay;
} } catch (error) {
// If relay creation fails, try to use an anonymous relay as fallback
console.debug(
`[NDK.ts] Failed to create relay for ${url}, trying anonymous relay fallback`,
);
// Find an anonymous relay that's not the same as the failed URL
const fallbackUrl = anonymousRelays.find((relay) => relay !== url) ||
anonymousRelays[0];
if (fallbackUrl) {
console.debug(
`[NDK.ts] Using anonymous relay as fallback: ${fallbackUrl}`,
);
try {
const fallbackRelay = new NDKRelay(
fallbackUrl,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
return fallbackRelay;
} catch (fallbackError) {
console.debug(
`[NDK.ts] Fallback relay creation also failed: ${fallbackError}`,
);
}
}
// If all else fails, create a minimal relay that will fail gracefully
console.debug(
`[NDK.ts] All fallback attempts failed, creating minimal relay for ${url}`,
);
const minimalRelay = new NDKRelay(url, undefined, ndk);
return minimalRelay;
}
}
/** /**
* Gets the active relay set for the current user * Gets the active relay set for the current user
* @param ndk NDK instance * @param ndk NDK instance
* @returns Promise that resolves to object with inbox and outbox relay arrays * @returns Promise that resolves to object with inbox and outbox relay arrays
*/ */
export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> { export async function getActiveRelaySet(
ndk: NDK,
): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
const user = get(userStore); const user = get(userStore);
console.debug('[NDK.ts] getActiveRelaySet: User state:', { signedIn: user.signedIn, hasNdkUser: !!user.ndkUser, pubkey: user.pubkey }); console.debug("[NDK.ts] getActiveRelaySet: User state:", {
signedIn: user.signedIn,
hasNdkUser: !!user.ndkUser,
pubkey: user.pubkey,
});
if (user.signedIn && user.ndkUser) { if (user.signedIn && user.ndkUser) {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:', user.ndkUser.pubkey); console.debug(
"[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:",
user.ndkUser.pubkey,
);
return await buildCompleteRelaySet(ndk, user.ndkUser); return await buildCompleteRelaySet(ndk, user.ndkUser);
} else { } else {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for anonymous user'); console.debug(
"[NDK.ts] getActiveRelaySet: Building relay set for anonymous user",
);
return await buildCompleteRelaySet(ndk, null); return await buildCompleteRelaySet(ndk, null);
} }
} }
@ -351,36 +525,90 @@ export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string
/** /**
* Updates the active relay stores and NDK pool with new relay URLs * Updates the active relay stores and NDK pool with new relay URLs
* @param ndk NDK instance * @param ndk NDK instance
* @param forceUpdate Force update even if cached (default: false)
*/ */
export async function updateActiveRelayStores(ndk: NDK): Promise<void> { export async function updateActiveRelayStores(
ndk: NDK,
forceUpdate: boolean = false,
): Promise<void> {
try { try {
console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update'); // AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation
const now = Date.now();
const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION;
// Load from persistent storage if not already loaded
if (!persistentRelaySet) {
const loaded = loadPersistentRelaySet();
persistentRelaySet = loaded.relaySet;
relaySetLastUpdated = loaded.lastUpdated;
}
if (!forceUpdate && persistentRelaySet && !cacheExpired) {
console.debug("[NDK.ts] updateActiveRelayStores: Using cached relay set");
activeInboxRelays.set(persistentRelaySet.inboxRelays);
activeOutboxRelays.set(persistentRelaySet.outboxRelays);
return;
}
console.debug(
"[NDK.ts] updateActiveRelayStores: Starting relay store update",
);
// Get the active relay set from the relay management system // Get the active relay set from the relay management system
const relaySet = await getActiveRelaySet(ndk); const relaySet = await getActiveRelaySet(ndk);
console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet); console.debug("[NDK.ts] updateActiveRelayStores: Got relay set:", relaySet);
// Cache the relay set
persistentRelaySet = relaySet;
relaySetLastUpdated = now;
savePersistentRelaySet(relaySet); // Save to persistent storage
// Update the stores with the new relay configuration // Update the stores with the new relay configuration
activeInboxRelays.set(relaySet.inboxRelays); activeInboxRelays.set(relaySet.inboxRelays);
activeOutboxRelays.set(relaySet.outboxRelays); activeOutboxRelays.set(relaySet.outboxRelays);
console.debug('[NDK.ts] updateActiveRelayStores: Updated stores with inbox:', relaySet.inboxRelays.length, 'outbox:', relaySet.outboxRelays.length); console.debug(
"[NDK.ts] updateActiveRelayStores: Updated stores with inbox:",
relaySet.inboxRelays.length,
"outbox:",
relaySet.outboxRelays.length,
);
// Add relays to NDK pool (deduplicated) // Add relays to NDK pool (deduplicated)
const allRelayUrls = deduplicateRelayUrls([...relaySet.inboxRelays, ...relaySet.outboxRelays]); const allRelayUrls = deduplicateRelayUrls([
console.debug('[NDK.ts] updateActiveRelayStores: Adding', allRelayUrls.length, 'relays to NDK pool'); ...relaySet.inboxRelays,
...relaySet.outboxRelays,
]);
// Reduce verbosity in development - only log relay addition if debug mode is enabled
if (process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS) {
console.debug(
"[NDK.ts] updateActiveRelayStores: Adding",
allRelayUrls.length,
"relays to NDK pool",
);
}
for (const url of allRelayUrls) { for (const url of allRelayUrls) {
try { try {
const relay = createRelayWithAuth(url, ndk); const relay = createRelayWithAuth(url, ndk);
ndk.pool?.addRelay(relay); ndk.pool?.addRelay(relay);
} catch (error) { } catch (error) {
console.debug('[NDK.ts] updateActiveRelayStores: Failed to add relay', url, ':', error); console.debug(
"[NDK.ts] updateActiveRelayStores: Failed to add relay",
url,
":",
error,
);
} }
} }
console.debug('[NDK.ts] updateActiveRelayStores: Relay store update completed'); console.debug(
"[NDK.ts] updateActiveRelayStores: Relay store update completed",
);
} catch (error) { } catch (error) {
console.warn('[NDK.ts] updateActiveRelayStores: Error updating relay stores:', error); console.warn(
"[NDK.ts] updateActiveRelayStores: Error updating relay stores:",
error,
);
} }
} }
@ -391,10 +619,25 @@ export function logCurrentRelayConfiguration(): void {
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
console.log('🔌 Current Relay Configuration:'); console.log("🔌 Current Relay Configuration:");
console.log('📥 Inbox Relays:', inboxRelays); console.log("📥 Inbox Relays:", inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays); console.log("📤 Outbox Relays:", outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); console.log(
`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`,
);
}
/**
* Clears the relay set cache to force a rebuild
*/
export function clearRelaySetCache(): void {
console.debug("[NDK.ts] Clearing relay set cache");
persistentRelaySet = null;
relaySetLastUpdated = 0;
// Clear from localStorage as well (client-side only)
if (typeof window !== "undefined") {
localStorage.removeItem("alexandria/relay_set_cache");
}
} }
/** /**
@ -402,16 +645,21 @@ export function logCurrentRelayConfiguration(): void {
* @param ndk NDK instance * @param ndk NDK instance
*/ */
export async function refreshRelayStores(ndk: NDK): Promise<void> { export async function refreshRelayStores(ndk: NDK): Promise<void> {
console.debug('[NDK.ts] Refreshing relay stores due to user state change'); console.debug("[NDK.ts] Refreshing relay stores due to user state change");
await updateActiveRelayStores(ndk); clearRelaySetCache(); // Clear cache when user state changes
await updateActiveRelayStores(ndk, true); // Force update
} }
/** /**
* Updates relay stores when network condition changes * Updates relay stores when network condition changes
* @param ndk NDK instance * @param ndk NDK instance
*/ */
export async function refreshRelayStoresOnNetworkChange(ndk: NDK): Promise<void> { export async function refreshRelayStoresOnNetworkChange(
console.debug('[NDK.ts] Refreshing relay stores due to network condition change'); ndk: NDK,
): Promise<void> {
console.debug(
"[NDK.ts] Refreshing relay stores due to network condition change",
);
await updateActiveRelayStores(ndk); await updateActiveRelayStores(ndk);
} }
@ -431,7 +679,7 @@ export function startNetworkMonitoringForRelays(): void {
* @returns NDKRelaySet * @returns NDKRelaySet
*/ */
function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet { function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet {
const relays = relayUrls.map(url => const relays = relayUrls.map((url) =>
new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk) new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk)
); );
@ -446,7 +694,7 @@ function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet {
*/ */
export async function getActiveRelaySetAsNDKRelaySet( export async function getActiveRelaySetAsNDKRelaySet(
ndk: NDK, ndk: NDK,
useInbox: boolean = true useInbox: boolean = true,
): Promise<NDKRelaySet> { ): Promise<NDKRelaySet> {
const relaySet = await getActiveRelaySet(ndk); const relaySet = await getActiveRelaySet(ndk);
const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays; const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays;
@ -474,6 +722,12 @@ export function initNdk(): NDK {
const maxRetries = 1; // Reduce to 1 retry const maxRetries = 1; // Reduce to 1 retry
const attemptConnection = async () => { const attemptConnection = async () => {
// Only attempt connection on client-side
if (typeof window === "undefined") {
console.debug("[NDK.ts] Skipping NDK connection during SSR");
return;
}
try { try {
await ndk.connect(); await ndk.connect();
console.debug("[NDK.ts] NDK connected successfully"); console.debug("[NDK.ts] NDK connected successfully");
@ -487,10 +741,17 @@ export function initNdk(): NDK {
// Only retry a limited number of times // Only retry a limited number of times
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
retryCount++; retryCount++;
console.debug(`[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`); console.debug(
setTimeout(attemptConnection, 2000); // Reduce timeout to 2 seconds `[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`,
);
// Use a more reasonable retry delay and prevent memory leaks
setTimeout(() => {
attemptConnection();
}, 2000 * retryCount); // Exponential backoff
} else { } else {
console.warn("[NDK.ts] Max retries reached, continuing with limited functionality"); console.warn(
"[NDK.ts] Max retries reached, continuing with limited functionality",
);
// Still try to update relay stores even if connection failed // Still try to update relay stores even if connection failed
try { try {
await updateActiveRelayStores(ndk); await updateActiveRelayStores(ndk);
@ -502,11 +763,61 @@ export function initNdk(): NDK {
} }
}; };
// Only attempt connection on client-side
if (typeof window !== "undefined") {
attemptConnection(); attemptConnection();
}
// AI-NOTE: Set up userStore subscription after NDK initialization to prevent initialization errors
userStore.subscribe(async (userState) => {
ndkSignedIn.set(userState.signedIn);
// Refresh relay stores when user state changes
if (ndk) {
try {
await refreshRelayStores(ndk);
} catch (error) {
console.warn(
"[NDK.ts] Failed to refresh relay stores on user state change:",
error,
);
}
}
});
return ndk; return ndk;
} }
/**
* Cleans up NDK resources to prevent memory leaks
* Should be called when the application is shutting down or when NDK needs to be reset
*/
export function cleanupNdk(): void {
console.debug("[NDK.ts] Cleaning up NDK resources");
const ndk = getNdkContext();
if (ndk) {
try {
// Disconnect from all relays
if (ndk.pool) {
for (const relay of ndk.pool.relays.values()) {
relay.disconnect();
}
}
// Drain the WebSocket pool
WebSocketPool.instance.drain();
// Stop network monitoring
stopNetworkStatusMonitoring();
console.debug("[NDK.ts] NDK cleanup completed");
} catch (error) {
console.warn("[NDK.ts] Error during NDK cleanup:", error);
}
}
}
/** /**
* Signs in with a NIP-07 browser extension using the new relay management system * Signs in with a NIP-07 browser extension using the new relay management system
* @returns The user's profile, if it is available * @returns The user's profile, if it is available
@ -516,7 +827,7 @@ export async function loginWithExtension(
pubkey?: string, pubkey?: string,
): Promise<NDKUser | null> { ): Promise<NDKUser | null> {
try { try {
const ndk = get(ndkInstance); const ndk = getNdkContext();
const signer = new NDKNip07Signer(); const signer = new NDKNip07Signer();
const signerUser = await signer.user(); const signerUser = await signer.user();
@ -526,7 +837,6 @@ export async function loginWithExtension(
} }
activePubkey.set(signerUser.pubkey); activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const user = ndk.getUser({ pubkey: signerUser.pubkey }); const user = ndk.getUser({ pubkey: signerUser.pubkey });
@ -536,7 +846,7 @@ export async function loginWithExtension(
ndk.signer = signer; ndk.signer = signer;
ndk.activeUser = user; ndk.activeUser = user;
ndkInstance.set(ndk); setNdkContext(ndk);
ndkSignedIn.set(true); ndkSignedIn.set(true);
return user; return user;
@ -553,19 +863,21 @@ export function logout(user: NDKUser): void {
clearLogin(); clearLogin();
clearPersistedRelays(user); clearPersistedRelays(user);
activePubkey.set(null); activePubkey.set(null);
userPubkey.set(null);
ndkSignedIn.set(false); ndkSignedIn.set(false);
// Clear relay stores // Clear relay stores
activeInboxRelays.set([]); activeInboxRelays.set([]);
activeOutboxRelays.set([]); activeOutboxRelays.set([]);
// AI-NOTE: 2025-01-08 - Clear persistent relay set on logout
persistentRelaySet = null;
relaySetLastUpdated = 0;
clearPersistentRelaySet(); // Clear persistent storage
// Stop network monitoring // Stop network monitoring
stopNetworkStatusMonitoring(); stopNetworkStatusMonitoring();
// Re-initialize with anonymous instance // Re-initialize with anonymous instance
const newNdk = initNdk(); const newNdk = initNdk();
ndkInstance.set(newNdk); setNdkContext(newNdk);
} }

4
src/lib/parser.ts

@ -7,11 +7,11 @@ import type {
Block, Block,
Document, Document,
Extensions, Extensions,
Section,
ProcessorOptions, ProcessorOptions,
Section,
} from "asciidoctor"; } from "asciidoctor";
import he from "he"; import he from "he";
import { writable, type Writable } from "svelte/store"; import { type Writable, writable } from "svelte/store";
import { zettelKinds } from "./consts.ts"; import { zettelKinds } from "./consts.ts";
import { getMatchingTags } from "./utils/nostrUtils.ts"; import { getMatchingTags } from "./utils/nostrUtils.ts";

90
src/lib/services/event_search_service.ts

@ -0,0 +1,90 @@
/**
* Service class for handling event search operations
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns
*/
export class EventSearchService {
/**
* Determines the search type from a query string
*/
getSearchType(query: string): { type: string; term: string } | null {
const lowerQuery = query.toLowerCase();
if (lowerQuery.startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
return dTag ? { type: "d", term: dTag } : null;
}
if (lowerQuery.startsWith("t:")) {
const searchTerm = query.slice(2).trim();
return searchTerm ? { type: "t", term: searchTerm } : null;
}
if (lowerQuery.startsWith("n:")) {
const searchTerm = query.slice(2).trim();
return searchTerm ? { type: "n", term: searchTerm } : null;
}
if (query.includes("@")) {
return { type: "nip05", term: query };
}
return null;
}
/**
* Checks if a search value matches the current event
*/
isCurrentEventMatch(
searchValue: string,
event: any,
relays: string[],
): boolean {
const currentEventId = event.id;
let currentNaddr = null;
let currentNevent = null;
let currentNpub = null;
let currentNprofile = null;
try {
const { neventEncode, naddrEncode, nprofileEncode } = require(
"$lib/utils",
);
const { getMatchingTags, toNpub } = require("$lib/utils/nostrUtils");
currentNevent = neventEncode(event, relays);
} catch {}
try {
const { naddrEncode } = require("$lib/utils");
const { getMatchingTags } = require("$lib/utils/nostrUtils");
currentNaddr = getMatchingTags(event, "d")[0]?.[1]
? naddrEncode(event, relays)
: null;
} catch {}
try {
const { toNpub } = require("$lib/utils/nostrUtils");
currentNpub = event.kind === 0 ? toNpub(event.pubkey) : null;
} catch {}
if (
searchValue &&
searchValue.startsWith("nprofile1") &&
event.kind === 0
) {
try {
const { nprofileEncode } = require("$lib/utils");
currentNprofile = nprofileEncode(event.pubkey, relays);
} catch {}
}
return (
searchValue === currentEventId ||
(currentNaddr && searchValue === currentNaddr) ||
(currentNevent && searchValue === currentNevent) ||
(currentNpub && searchValue === currentNpub) ||
(currentNprofile && searchValue === currentNprofile)
);
}
}

68
src/lib/services/publisher.ts

@ -1,8 +1,9 @@
import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts";
import { getMimeTags } from "../utils/mime.ts"; import { getMimeTags } from "../utils/mime.ts";
import { parseAsciiDocWithMetadata, metadataToTags } from "../utils/asciidoc_metadata.ts"; import {
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; metadataToTags,
parseAsciiDocWithMetadata,
} from "../utils/asciidoc_metadata.ts";
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
export interface PublishResult { export interface PublishResult {
@ -25,6 +26,7 @@ export interface PublishOptions {
*/ */
export async function publishZettel( export async function publishZettel(
options: PublishOptions, options: PublishOptions,
ndk: NDK,
): Promise<PublishResult> { ): Promise<PublishResult> {
const { content, kind = 30041, onSuccess, onError } = options; const { content, kind = 30041, onSuccess, onError } = options;
@ -34,9 +36,6 @@ export async function publishZettel(
return { success: false, error }; return { success: false, error };
} }
// Get the current NDK instance from the store
const ndk = get(ndkInstance);
if (!ndk?.activeUser) { if (!ndk?.activeUser) {
const error = "Please log in first"; const error = "Please log in first";
onError?.(error); onError?.(error);
@ -97,8 +96,9 @@ export async function publishZettel(
throw new Error("Failed to publish to any relays"); throw new Error("Failed to publish to any relays");
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error
error instanceof Error ? error.message : "Unknown error"; ? error.message
: "Unknown error";
onError?.(errorMessage); onError?.(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} }
@ -116,10 +116,9 @@ export async function publishSingleEvent(
tags: string[][]; tags: string[][];
onError?: (error: string) => void; onError?: (error: string) => void;
}, },
ndk: NDK,
): Promise<PublishResult> { ): Promise<PublishResult> {
const { content, kind, tags, onError } = options; const { content, kind, tags, onError } = options;
const ndk = get(ndkInstance);
if (!ndk?.activeUser) { if (!ndk?.activeUser) {
const error = 'Please log in first'; const error = 'Please log in first';
onError?.(error); onError?.(error);
@ -204,18 +203,18 @@ export async function publishSingleEvent(
*/ */
export async function publishMultipleZettels( export async function publishMultipleZettels(
options: PublishOptions, options: PublishOptions,
ndk: NDK,
): Promise<PublishResult[]> { ): Promise<PublishResult[]> {
const { content, kind = 30041, onError } = options; const { content, kind = 30041, onError } = options;
if (!content.trim()) { if (!content.trim()) {
const error = 'Please enter some content'; const error = "Please enter some content";
onError?.(error); onError?.(error);
return [{ success: false, error }]; return [{ success: false, error }];
} }
const ndk = get(ndkInstance);
if (!ndk?.activeUser) { if (!ndk?.activeUser) {
const error = 'Please log in first'; const error = "Please log in first";
onError?.(error); onError?.(error);
return [{ success: false, error }]; return [{ success: false, error }];
} }
@ -223,12 +222,14 @@ export async function publishMultipleZettels(
try { try {
const parsed = parseAsciiDocWithMetadata(content); const parsed = parseAsciiDocWithMetadata(content);
if (parsed.sections.length === 0) { if (parsed.sections.length === 0) {
throw new Error('No valid sections found in content'); throw new Error("No valid sections found in content");
} }
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map((r) => r.url); const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map((r) =>
r.url
);
if (allRelayUrls.length === 0) { if (allRelayUrls.length === 0) {
throw new Error('No relays available in NDK pool'); throw new Error("No relays available in NDK pool");
} }
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk); const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
@ -257,31 +258,42 @@ export async function publishMultipleZettels(
results.push({ success: true, eventId: ndkEvent.id }); results.push({ success: true, eventId: ndkEvent.id });
publishedEvents.push(ndkEvent); publishedEvents.push(ndkEvent);
} else { } else {
results.push({ success: false, error: 'Failed to publish to any relays' }); results.push({
success: false,
error: "Failed to publish to any relays",
});
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'; const errorMessage = err instanceof Error
? err.message
: "Unknown error";
results.push({ success: false, error: errorMessage }); results.push({ success: false, error: errorMessage });
} }
} }
// Debug: extract and log 'e' and 'a' tags from all published events // Debug: extract and log 'e' and 'a' tags from all published events
publishedEvents.forEach(ev => { publishedEvents.forEach((ev) => {
// Extract d-tag from tags // Extract d-tag from tags
const dTagEntry = ev.tags.find(t => t[0] === 'd'); const dTagEntry = ev.tags.find((t) => t[0] === "d");
const dTag = dTagEntry ? dTagEntry[1] : ''; const dTag = dTagEntry ? dTagEntry[1] : "";
const aTag = `${ev.kind}:${ev.pubkey}:${dTag}`; const aTag = `${ev.kind}:${ev.pubkey}:${dTag}`;
console.log(`Event ${ev.id} tags:`); console.log(`Event ${ev.id} tags:`);
console.log(' e:', ev.id); console.log(" e:", ev.id);
console.log(' a:', aTag); console.log(" a:", aTag);
// Print nevent and naddr using nip19 // Print nevent and naddr using nip19
const nevent = nip19.neventEncode({ id: ev.id }); const nevent = nip19.neventEncode({ id: ev.id });
const naddr = nip19.naddrEncode({ kind: ev.kind, pubkey: ev.pubkey, identifier: dTag }); const naddr = nip19.naddrEncode({
console.log(' nevent:', nevent); kind: ev.kind,
console.log(' naddr:', naddr); pubkey: ev.pubkey,
identifier: dTag,
});
console.log(" nevent:", nevent);
console.log(" naddr:", naddr);
}); });
return results; return results;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error
? error.message
: "Unknown error";
onError?.(errorMessage); onError?.(errorMessage);
return [{ success: false, error: errorMessage }]; return [{ success: false, error: errorMessage }];
} }

70
src/lib/services/search_state_manager.ts

@ -0,0 +1,70 @@
/**
* Service class for managing search state operations
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns
*/
export class SearchStateManager {
/**
* Updates the search state with new values
*/
updateSearchState(
state: {
searching: boolean;
searchCompleted: boolean;
searchResultCount: number | null;
searchResultType: string | null;
},
onLoadingChange?: (loading: boolean) => void,
): void {
if (onLoadingChange) {
onLoadingChange(state.searching);
}
}
/**
* Resets all search state to initial values
*/
resetSearchState(
callbacks: {
onSearchResults: (
events: any[],
secondOrder: any[],
tTagEvents: any[],
eventIds: Set<string>,
addresses: Set<string>,
) => void;
cleanupSearch: () => void;
clearTimeout: () => void;
},
): void {
callbacks.cleanupSearch();
callbacks.onSearchResults([], [], [], new Set(), new Set());
callbacks.clearTimeout();
}
/**
* Handles search errors with consistent error handling
*/
handleSearchError(
error: unknown,
defaultMessage: string,
callbacks: {
setLocalError: (error: string | null) => void;
cleanupSearch: () => void;
updateSearchState: (state: any) => void;
resetProcessingFlags: () => void;
},
): void {
const errorMessage = error instanceof Error
? error.message
: defaultMessage;
callbacks.setLocalError(errorMessage);
callbacks.cleanupSearch();
callbacks.updateSearchState({
searching: false,
searchCompleted: false,
searchResultCount: null,
searchResultType: null,
});
callbacks.resetProcessingFlags();
}
}

17
src/lib/snippets/UserSnippets.svelte

@ -5,14 +5,7 @@
toNpub, toNpub,
getUserMetadata, getUserMetadata,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import type { UserProfile } from "$lib/models/user_profile";
// Extend NostrProfile locally to allow display_name for legacy support
type NostrProfileWithLegacy = {
displayName?: string;
display_name?: string;
name?: string;
[key: string]: any;
};
export { userBadge }; export { userBadge };
</script> </script>
@ -21,14 +14,14 @@
{@const npub = toNpub(identifier)} {@const npub = toNpub(identifier)}
{#if npub} {#if npub}
{#if !displayText || displayText.trim().toLowerCase() === "unknown"} {#if !displayText || displayText.trim().toLowerCase() === "unknown"}
{#await getUserMetadata(npub) then profile} {#await getUserMetadata(npub, undefined, false) then profile}
{@const p = profile as NostrProfileWithLegacy} {@const p = profile as UserProfile}
<span class="inline-flex items-center gap-0.5"> <span class="inline-flex items-center gap-0.5">
<button <button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer" class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)} onclick={() => goto(`/events?id=${npub}`)}
> >
@{p.displayName || @{p.display_name ||
p.display_name || p.display_name ||
p.name || p.name ||
npub.slice(0, 8) + "..." + npub.slice(-4)} npub.slice(0, 8) + "..." + npub.slice(-4)}
@ -45,7 +38,7 @@
</span> </span>
{/await} {/await}
{:else} {:else}
{#await createProfileLinkWithVerification(npub as string, displayText)} {#await createProfileLinkWithVerification(npub as string, displayText, undefined)}
<span class="inline-flex items-center gap-0.5"> <span class="inline-flex items-center gap-0.5">
<button <button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer" class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"

6
src/lib/state.ts

@ -1,5 +1,5 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { writable, type Writable } from "svelte/store"; import { type Writable, writable } from "svelte/store";
import type { Tab } from "./types.ts"; import type { Tab } from "./types.ts";
export const pathLoaded: Writable<boolean> = writable(false); export const pathLoaded: Writable<boolean> = writable(false);
@ -8,8 +8,6 @@ export const tabs: Writable<Tab[]> = writable([{ id: 0, type: "welcome" }]);
export const tabBehaviour: Writable<string> = writable( export const tabBehaviour: Writable<string> = writable(
(browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal", (browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal",
); );
export const userPublickey: Writable<string> = writable(
(browser && localStorage.getItem("wikinostr_loggedInPublicKey")) || "",
);
export const networkFetchLimit: Writable<number> = writable(50); export const networkFetchLimit: Writable<number> = writable(50);
export const levelsToRender: Writable<number> = writable(3); export const levelsToRender: Writable<number> = writable(3);

11
src/lib/stores/authStore.Svelte.ts

@ -1,11 +0,0 @@
import { writable, derived } from "svelte/store";
/**
* Stores the user's public key if logged in, or null otherwise.
*/
export const userPubkey = writable<string | null>(null);
/**
* Derived store indicating if the user is logged in.
*/
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);

22
src/lib/stores/networkStore.ts

@ -1,8 +1,14 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { detectNetworkCondition, NetworkCondition, startNetworkMonitoring } from '../utils/network_detection.ts'; import {
detectNetworkCondition,
NetworkCondition,
startNetworkMonitoring,
} from "../utils/network_detection.ts";
// Network status store // Network status store
export const networkCondition = writable<NetworkCondition>(NetworkCondition.ONLINE); export const networkCondition = writable<NetworkCondition>(
NetworkCondition.ONLINE,
);
export const isNetworkChecking = writable<boolean>(false); export const isNetworkChecking = writable<boolean>(false);
// Network monitoring state // Network monitoring state
@ -16,14 +22,16 @@ export function startNetworkStatusMonitoring(): void {
return; // Already monitoring return; // Already monitoring
} }
console.debug('[networkStore.ts] Starting network status monitoring'); console.debug("[networkStore.ts] Starting network status monitoring");
stopNetworkMonitoring = startNetworkMonitoring( stopNetworkMonitoring = startNetworkMonitoring(
(condition: NetworkCondition) => { (condition: NetworkCondition) => {
console.debug(`[networkStore.ts] Network condition changed to: ${condition}`); console.debug(
`[networkStore.ts] Network condition changed to: ${condition}`,
);
networkCondition.set(condition); networkCondition.set(condition);
}, },
60000 // Check every 60 seconds to reduce spam 60000, // Check every 60 seconds to reduce spam
); );
} }
@ -32,7 +40,7 @@ export function startNetworkStatusMonitoring(): void {
*/ */
export function stopNetworkStatusMonitoring(): void { export function stopNetworkStatusMonitoring(): void {
if (stopNetworkMonitoring) { if (stopNetworkMonitoring) {
console.debug('[networkStore.ts] Stopping network status monitoring'); console.debug("[networkStore.ts] Stopping network status monitoring");
stopNetworkMonitoring(); stopNetworkMonitoring();
stopNetworkMonitoring = null; stopNetworkMonitoring = null;
} }
@ -47,7 +55,7 @@ export async function checkNetworkStatus(): Promise<void> {
const condition = await detectNetworkCondition(); const condition = await detectNetworkCondition();
networkCondition.set(condition); networkCondition.set(condition);
} catch (error) { } catch (error) {
console.warn('[networkStore.ts] Failed to check network status:', error); console.warn("[networkStore.ts] Failed to check network status:", error);
networkCondition.set(NetworkCondition.OFFLINE); networkCondition.set(NetworkCondition.OFFLINE);
} finally { } finally {
isNetworkChecking.set(false); isNetworkChecking.set(false);

137
src/lib/stores/userStore.ts

@ -1,17 +1,21 @@
import { writable, get } from "svelte/store"; import { get, writable } from "svelte/store";
import type { NostrProfile } from "../utils/nostrUtils.ts"; import type { NostrProfile } from "../utils/nostrUtils.ts";
import type { NDKUser, NDKSigner } from "@nostr-dev-kit/ndk"; import type { NDKSigner, NDKUser } from "@nostr-dev-kit/ndk";
import NDK, { import NDK, {
NDKNip07Signer, NDKNip07Signer,
NDKRelay,
NDKRelayAuthPolicies, NDKRelayAuthPolicies,
NDKRelaySet, NDKRelaySet,
NDKRelay,
} from "@nostr-dev-kit/ndk"; } from "@nostr-dev-kit/ndk";
import { getUserMetadata } from "../utils/nostrUtils.ts"; import { getUserMetadata } from "../utils/nostrUtils.ts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from "../ndk.ts"; import {
activeInboxRelays,
activeOutboxRelays,
updateActiveRelayStores,
} from "../ndk.ts";
import { loginStorageKey } from "../consts.ts"; import { loginStorageKey } from "../consts.ts";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { userPubkey } from "../stores/authStore.Svelte.ts";
export interface UserState { export interface UserState {
pubkey: string | null; pubkey: string | null;
@ -45,6 +49,9 @@ function persistRelays(
inboxes: Set<NDKRelay>, inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>,
): void { ): void {
// Only access localStorage on client-side
if (typeof window === "undefined") return;
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, "inbox"), getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map((relay) => relay.url)), JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
@ -56,6 +63,11 @@ function persistRelays(
} }
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
// Only access localStorage on client-side
if (typeof window === "undefined") {
return [new Set<string>(), new Set<string>()];
}
const inboxes = new Set<string>( const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"), JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
); );
@ -71,7 +83,10 @@ function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
async function getUserPreferredRelays( async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)], fallbacks: readonly string[] = [
...get(activeInboxRelays),
...get(activeOutboxRelays),
],
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { ): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
@ -132,9 +147,17 @@ async function getUserPreferredRelays(
// --- Unified login/logout helpers --- // --- Unified login/logout helpers ---
// AI-NOTE: 2025-01-24 - Authentication persistence system
// The application stores login information in localStorage to persist authentication across page refreshes.
// The layout component automatically restores this authentication state on page load.
// This prevents users from being logged out when refreshing the page.
export const loginMethodStorageKey = "alexandria/login/method"; export const loginMethodStorageKey = "alexandria/login/method";
function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") { function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") {
// Only access localStorage on client-side
if (typeof window === "undefined") return;
localStorage.setItem(loginStorageKey, user.pubkey); localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method); localStorage.setItem(loginMethodStorageKey, method);
} }
@ -147,8 +170,7 @@ function clearLogin() {
/** /**
* Login with NIP-07 browser extension * Login with NIP-07 browser extension
*/ */
export async function loginWithExtension() { export async function loginWithExtension(ndk: NDK) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized"); if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login // Only clear previous login state after successful login
const signer = new NDKNip07Signer(); const signer = new NDKNip07Signer();
@ -161,7 +183,7 @@ export async function loginWithExtension() {
let profile: NostrProfile | null = null; let profile: NostrProfile | null = null;
try { try {
console.log("Login with extension - attempting to fetch profile..."); console.log("Login with extension - attempting to fetch profile...");
profile = await getUserMetadata(npub, true); // Force fresh fetch profile = await getUserMetadata(npub, ndk, true); // Force fresh fetch
console.log("Login with extension - fetched profile:", profile); console.log("Login with extension - fetched profile:", profile);
} catch (error) { } catch (error) {
console.warn("Failed to fetch user metadata during login:", error); console.warn("Failed to fetch user metadata during login:", error);
@ -201,26 +223,32 @@ export async function loginWithExtension() {
console.log("Login with extension - setting userStore with:", userState); console.log("Login with extension - setting userStore with:", userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays // Update relay stores with the new user's relays
try { try {
console.debug('[userStore.ts] loginWithExtension: Updating relay stores for authenticated user'); console.debug(
await updateActiveRelayStores(ndk); "[userStore.ts] loginWithExtension: Updating relay stores for authenticated user",
);
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user
} catch (error) { } catch (error) {
console.warn('[userStore.ts] loginWithExtension: Failed to update relay stores:', error); console.warn(
"[userStore.ts] loginWithExtension: Failed to update relay stores:",
error,
);
} }
clearLogin(); clearLogin();
// Only access localStorage on client-side
if (typeof window !== "undefined") {
localStorage.removeItem("alexandria/logout/flag"); localStorage.removeItem("alexandria/logout/flag");
}
persistLogin(user, "extension"); persistLogin(user, "extension");
} }
/** /**
* Login with Amber (NIP-46) * Login with Amber (NIP-46)
*/ */
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) { export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser, ndk: NDK) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized"); if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login // Only clear previous login state after successful login
const npub = user.npub; const npub = user.npub;
@ -229,7 +257,7 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
let profile: NostrProfile | null = null; let profile: NostrProfile | null = null;
try { try {
profile = await getUserMetadata(npub, true); // Force fresh fetch profile = await getUserMetadata(npub, ndk, true); // Force fresh fetch
console.log("Login with Amber - fetched profile:", profile); console.log("Login with Amber - fetched profile:", profile);
} catch (error) { } catch (error) {
console.warn("Failed to fetch user metadata during Amber login:", error); console.warn("Failed to fetch user metadata during Amber login:", error);
@ -268,34 +296,46 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
console.log("Login with Amber - setting userStore with:", userState); console.log("Login with Amber - setting userStore with:", userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays // Update relay stores with the new user's relays
try { try {
console.debug('[userStore.ts] loginWithAmber: Updating relay stores for authenticated user'); console.debug(
await updateActiveRelayStores(ndk); "[userStore.ts] loginWithAmber: Updating relay stores for authenticated user",
);
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user
} catch (error) { } catch (error) {
console.warn('[userStore.ts] loginWithAmber: Failed to update relay stores:', error); console.warn(
"[userStore.ts] loginWithAmber: Failed to update relay stores:",
error,
);
} }
clearLogin(); clearLogin();
// Only access localStorage on client-side
if (typeof window !== "undefined") {
localStorage.removeItem("alexandria/logout/flag"); localStorage.removeItem("alexandria/logout/flag");
}
persistLogin(user, "amber"); persistLogin(user, "amber");
} }
/** /**
* Login with npub (read-only) * Login with npub (read-only)
*/ */
export async function loginWithNpub(pubkeyOrNpub: string) { export async function loginWithNpub(pubkeyOrNpub: string, ndk: NDK) {
const ndk = get(ndkInstance); if (!ndk) {
if (!ndk) throw new Error("NDK not initialized"); throw new Error("NDK not initialized");
// Only clear previous login state after successful login }
let hexPubkey: string; let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub")) { if (pubkeyOrNpub.startsWith("npub1")) {
try { try {
hexPubkey = nip19.decode(pubkeyOrNpub).data as string; const decoded = nip19.decode(pubkeyOrNpub);
if (decoded.type !== "npub") {
throw new Error("Invalid npub format");
}
hexPubkey = decoded.data;
} catch (e) { } catch (e) {
console.error("Failed to decode hex pubkey from npub:", pubkeyOrNpub, e); console.error("Failed to decode npub:", pubkeyOrNpub, e);
throw e; throw e;
} }
} else { } else {
@ -313,8 +353,25 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
const user = ndk.getUser({ npub }); const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null; let profile: NostrProfile | null = null;
// First, update relay stores to ensure we have relays available
try {
console.debug(
"[userStore.ts] loginWithNpub: Updating relay stores for authenticated user",
);
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn(
"[userStore.ts] loginWithNpub: Failed to update relay stores:",
error,
);
}
// Wait a moment for relay stores to be properly initialized
await new Promise((resolve) => setTimeout(resolve, 500));
try { try {
profile = await getUserMetadata(npub, true); // Force fresh fetch profile = await getUserMetadata(npub, ndk, true); // Force fresh fetch
console.log("Login with npub - fetched profile:", profile); console.log("Login with npub - fetched profile:", profile);
} catch (error) { } catch (error) {
console.warn("Failed to fetch user metadata during npub login:", error); console.warn("Failed to fetch user metadata during npub login:", error);
@ -342,31 +399,30 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
console.log("Login with npub - setting userStore with:", userState); console.log("Login with npub - setting userStore with:", userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error);
}
clearLogin(); clearLogin();
// Only access localStorage on client-side
if (typeof window !== "undefined") {
localStorage.removeItem("alexandria/logout/flag"); localStorage.removeItem("alexandria/logout/flag");
}
persistLogin(user, "npub"); persistLogin(user, "npub");
} }
/** /**
* Logout and clear all user state * Logout and clear all user state
*/ */
export function logoutUser() { export function logoutUser(ndk: NDK) {
console.log("Logging out user..."); console.log("Logging out user...");
const currentUser = get(userStore); const currentUser = get(userStore);
// Only access localStorage on client-side
if (typeof window !== "undefined") {
if (currentUser.ndkUser) { if (currentUser.ndkUser) {
// Clear persisted relays for the user // Clear persisted relays for the user
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox")); localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox"));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "outbox")); localStorage.removeItem(
getRelayStorageKey(currentUser.ndkUser, "outbox"),
);
} }
// Clear all possible login states from localStorage // Clear all possible login states from localStorage
@ -404,6 +460,7 @@ export function logoutUser() {
localStorage.setItem("alexandria/logout/flag", "true"); localStorage.setItem("alexandria/logout/flag", "true");
console.log("Cleared all login data from localStorage"); console.log("Cleared all login data from localStorage");
}
userStore.set({ userStore.set({
pubkey: null, pubkey: null,
@ -415,9 +472,7 @@ export function logoutUser() {
signer: null, signer: null,
signedIn: false, signedIn: false,
}); });
userPubkey.set(null);
const ndk = get(ndkInstance);
if (ndk) { if (ndk) {
ndk.activeUser = undefined; ndk.activeUser = undefined;
ndk.signer = undefined; ndk.signer = undefined;

27
src/lib/stores/visualizationConfig.ts

@ -1,4 +1,4 @@
import { writable, derived, get } from "svelte/store"; import { derived, get, writable } from "svelte/store";
export interface EventKindConfig { export interface EventKindConfig {
kind: number; kind: number;
@ -40,7 +40,9 @@ function createVisualizationConfig() {
searchThroughFetched: true, searchThroughFetched: true,
}; };
const { subscribe, set, update } = writable<VisualizationConfig>(initialConfig); const { subscribe, set, update } = writable<VisualizationConfig>(
initialConfig,
);
function reset() { function reset() {
set(initialConfig); set(initialConfig);
@ -83,7 +85,7 @@ function createVisualizationConfig() {
update((config) => ({ update((config) => ({
...config, ...config,
eventConfigs: config.eventConfigs.map((ec) => eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === kind ? { ...ec, limit } : ec, ec.kind === kind ? { ...ec, limit } : ec
), ),
})); }));
} }
@ -92,7 +94,7 @@ function createVisualizationConfig() {
update((config) => ({ update((config) => ({
...config, ...config,
eventConfigs: config.eventConfigs.map((ec) => eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === 30040 ? { ...ec, nestedLevels: levels } : ec, ec.kind === 30040 ? { ...ec, nestedLevels: levels } : ec
), ),
})); }));
} }
@ -101,7 +103,7 @@ function createVisualizationConfig() {
update((config) => ({ update((config) => ({
...config, ...config,
eventConfigs: config.eventConfigs.map((ec) => eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === 3 ? { ...ec, depth: depth } : ec, ec.kind === 3 ? { ...ec, depth: depth } : ec
), ),
})); }));
} }
@ -110,7 +112,7 @@ function createVisualizationConfig() {
update((config) => ({ update((config) => ({
...config, ...config,
eventConfigs: config.eventConfigs.map((ec) => eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === kind ? { ...ec, showAll: !ec.showAll } : ec, ec.kind === kind ? { ...ec, showAll: !ec.showAll } : ec
), ),
})); }));
} }
@ -134,7 +136,7 @@ function createVisualizationConfig() {
update((config) => ({ update((config) => ({
...config, ...config,
eventConfigs: config.eventConfigs.map((ec) => eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === kind ? { ...ec, enabled: !ec.enabled } : ec, ec.kind === kind ? { ...ec, enabled: !ec.enabled } : ec
), ),
})); }));
} }
@ -158,7 +160,9 @@ function createVisualizationConfig() {
export const visualizationConfig = createVisualizationConfig(); export const visualizationConfig = createVisualizationConfig();
// Helper to get all enabled event kinds // Helper to get all enabled event kinds
export const enabledEventKinds = derived(visualizationConfig, ($config) => export const enabledEventKinds = derived(
visualizationConfig,
($config) =>
$config.eventConfigs $config.eventConfigs
.filter((ec) => ec.enabled !== false) .filter((ec) => ec.enabled !== false)
.map((ec) => ec.kind), .map((ec) => ec.kind),
@ -169,7 +173,10 @@ export const enabledEventKinds = derived(visualizationConfig, ($config) =>
* @param config - The VisualizationConfig object. * @param config - The VisualizationConfig object.
* @param kind - The event kind number to check. * @param kind - The event kind number to check.
*/ */
export function isKindEnabledFn(config: VisualizationConfig, kind: number): boolean { export function isKindEnabledFn(
config: VisualizationConfig,
kind: number,
): boolean {
const eventConfig = config.eventConfigs.find((ec) => ec.kind === kind); const eventConfig = config.eventConfigs.find((ec) => ec.kind === kind);
// If not found, return false. Otherwise, return true unless explicitly disabled. // If not found, return false. Otherwise, return true unless explicitly disabled.
return !!eventConfig && eventConfig.enabled !== false; return !!eventConfig && eventConfig.enabled !== false;
@ -178,5 +185,5 @@ export function isKindEnabledFn(config: VisualizationConfig, kind: number): bool
// Derived store: returns a function that checks if a kind is enabled in the current config. // Derived store: returns a function that checks if a kind is enabled in the current config.
export const isKindEnabledStore = derived( export const isKindEnabledStore = derived(
visualizationConfig, visualizationConfig,
($config) => (kind: number) => isKindEnabledFn($config, kind) ($config) => (kind: number) => isKindEnabledFn($config, kind),
); );

23
src/lib/utils.ts

@ -19,12 +19,19 @@ export class InvalidKindError extends DecodeError {
} }
export function neventEncode(event: NDKEvent, relays: string[]) { export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({ try {
const nevent = nip19.neventEncode({
id: event.id, id: event.id,
kind: event.kind, kind: event.kind,
relays, relays,
author: event.pubkey, author: event.pubkey,
}); });
return nevent;
} catch (error) {
console.error(`[neventEncode] Error encoding nevent:`, error);
throw error;
}
} }
export function naddrEncode(event: NDKEvent, relays: string[]) { export function naddrEncode(event: NDKEvent, relays: string[]) {
@ -47,7 +54,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) {
* @param relays Optional relay list for the address * @param relays Optional relay list for the address
* @returns A tag address string * @returns A tag address string
*/ */
export function createTagAddress(event: NostrEvent, relays: string[] = []): string { export function createTagAddress(
event: NostrEvent,
relays: string[] = [],
): string {
const dTag = event.tags.find((tag: string[]) => tag[0] === "d")?.[1]; const dTag = event.tags.find((tag: string[]) => tag[0] === "d")?.[1];
if (!dTag) { if (!dTag) {
throw new Error("Event does not have a d tag"); throw new Error("Event does not have a d tag");
@ -137,8 +147,7 @@ export function next(): number {
export function scrollTabIntoView(el: string | HTMLElement, wait: boolean) { export function scrollTabIntoView(el: string | HTMLElement, wait: boolean) {
function scrollTab() { function scrollTab() {
const element = const element = typeof el === "string"
typeof el === "string"
? document.querySelector(`[id^="wikitab-v0-${el}"]`) ? document.querySelector(`[id^="wikitab-v0-${el}"]`)
: el; : el;
if (!element) return; if (!element) return;
@ -159,8 +168,7 @@ export function scrollTabIntoView(el: string | HTMLElement, wait: boolean) {
} }
export function isElementInViewport(el: string | HTMLElement) { export function isElementInViewport(el: string | HTMLElement) {
const element = const element = typeof el === "string"
typeof el === "string"
? document.querySelector(`[id^="wikitab-v0-${el}"]`) ? document.querySelector(`[id^="wikitab-v0-${el}"]`)
: el; : el;
if (!element) return; if (!element) return;
@ -172,7 +180,8 @@ export function isElementInViewport(el: string | HTMLElement) {
rect.left >= 0 && rect.left >= 0 &&
rect.bottom <= rect.bottom <=
(globalThis.innerHeight || document.documentElement.clientHeight) && (globalThis.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (globalThis.innerWidth || document.documentElement.clientWidth) rect.right <=
(globalThis.innerWidth || document.documentElement.clientWidth)
); );
} }

384
src/lib/utils/asciidoc_metadata.ts

@ -43,29 +43,29 @@ export interface ParsedAsciiDoc {
// Shared attribute mapping based on Asciidoctor standard attributes // Shared attribute mapping based on Asciidoctor standard attributes
const ATTRIBUTE_MAP: Record<string, keyof AsciiDocMetadata> = { const ATTRIBUTE_MAP: Record<string, keyof AsciiDocMetadata> = {
// Standard Asciidoctor attributes // Standard Asciidoctor attributes
'author': 'authors', "author": "authors",
'description': 'summary', "description": "summary",
'keywords': 'tags', "keywords": "tags",
'revnumber': 'version', "revnumber": "version",
'revdate': 'publicationDate', "revdate": "publicationDate",
'revremark': 'edition', "revremark": "edition",
'title': 'title', "title": "title",
// Custom attributes for Alexandria // Custom attributes for Alexandria
'published_by': 'publishedBy', "published_by": "publishedBy",
'publisher': 'publisher', "publisher": "publisher",
'summary': 'summary', "summary": "summary",
'image': 'coverImage', "image": "coverImage",
'cover': 'coverImage', "cover": "coverImage",
'isbn': 'isbn', "isbn": "isbn",
'source': 'source', "source": "source",
'type': 'type', "type": "type",
'auto-update': 'autoUpdate', "auto-update": "autoUpdate",
'version': 'version', "version": "version",
'edition': 'edition', "edition": "edition",
'published_on': 'publicationDate', "published_on": "publicationDate",
'date': 'publicationDate', "date": "publicationDate",
'version-label': 'version', "version-label": "version",
}; };
/** /**
@ -104,15 +104,15 @@ function decodeHtmlEntities(text: string): string {
*/ */
function extractTagsFromAttributes(attributes: Record<string, any>): string[] { function extractTagsFromAttributes(attributes: Record<string, any>): string[] {
const tags: string[] = []; const tags: string[] = [];
const attrTags = attributes['tags']; const attrTags = attributes["tags"];
const attrKeywords = attributes['keywords']; const attrKeywords = attributes["keywords"];
if (attrTags && typeof attrTags === 'string') { if (attrTags && typeof attrTags === "string") {
tags.push(...attrTags.split(',').map(tag => tag.trim())); tags.push(...attrTags.split(",").map((tag) => tag.trim()));
} }
if (attrKeywords && typeof attrKeywords === 'string') { if (attrKeywords && typeof attrKeywords === "string") {
tags.push(...attrKeywords.split(',').map(tag => tag.trim())); tags.push(...attrKeywords.split(",").map((tag) => tag.trim()));
} }
return [...new Set(tags)]; // Remove duplicates return [...new Set(tags)]; // Remove duplicates
@ -121,36 +121,33 @@ function extractTagsFromAttributes(attributes: Record<string, any>): string[] {
/** /**
* Maps attributes to metadata with special handling for authors and tags * Maps attributes to metadata with special handling for authors and tags
*/ */
function mapAttributesToMetadata(attributes: Record<string, any>, metadata: AsciiDocMetadata, isDocument: boolean = false): void { function mapAttributesToMetadata(
// List of AsciiDoc system attributes to ignore attributes: Record<string, any>,
const systemAttributes = [ metadata: AsciiDocMetadata,
'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig', isDocument: boolean = false,
'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption', ): void {
'important-caption', 'last-update-label', 'note-caption', 'part-refsig',
'section-refsig', 'table-caption', 'tip-caption', 'toc-placement',
'toc-title', 'untitled-label', 'warning-caption', 'asciidoctor-version',
'safe-mode-name', 'backend', 'user-home', 'doctype', 'htmlsyntax',
'outfilesuffix', 'filetype', 'basebackend', 'stylesdir', 'iconsdir',
'localdate', 'localyear', 'localtime', 'localdatetime', 'docdate',
'docyear', 'doctime', 'docdatetime', 'doctitle', 'language',
'firstname', 'authorinitials', 'authors'
];
for (const [key, value] of Object.entries(attributes)) { for (const [key, value] of Object.entries(attributes)) {
const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()]; const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()];
if (metadataKey && value && typeof value === 'string') { if (metadataKey && value && typeof value === "string") {
if (metadataKey === 'authors' && isDocument) { if (metadataKey === "authors" && isDocument) {
// Skip author mapping for documents since it's handled manually // Skip author mapping for documents since it's handled manually
continue; continue;
} else if (metadataKey === 'authors' && !isDocument) { } else if (metadataKey === "authors" && !isDocument) {
// For sections, append author to existing authors array // For sections, append author to existing authors array
if (!metadata.authors) { if (!metadata.authors) {
metadata.authors = []; metadata.authors = [];
} }
metadata.authors.push(value); metadata.authors.push(value);
} else if (metadataKey === 'tags') { } else if (metadataKey === "tags") {
// Skip tags mapping since it's handled by extractTagsFromAttributes // Skip tags mapping since it's handled by extractTagsFromAttributes
continue; continue;
} else if (metadataKey === "summary") {
// Handle summary specially - combine with existing summary if present
if (metadata.summary) {
metadata.summary = `${metadata.summary} ${value}`;
} else {
metadata.summary = value;
}
} else { } else {
(metadata as any)[metadataKey] = value; (metadata as any)[metadataKey] = value;
} }
@ -165,73 +162,190 @@ function mapAttributesToMetadata(attributes: Record<string, any>, metadata: Asci
} }
/** /**
* Extracts authors from header line (document or section) * Extracts authors from document header only (not sections)
*/ */
function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = false): string[] { function extractDocumentAuthors(sourceContent: string): string[] {
const authors: string[] = []; const authors: string[] = [];
const lines = sourceContent.split(/\r?\n/); const lines = sourceContent.split(/\r?\n/);
const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
// Find the document title line
let titleLineIndex = -1;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/^=\s+/)) {
titleLineIndex = i;
break;
}
}
if (titleLineIndex === -1) {
return authors;
}
// Look for authors in the lines immediately following the title
let i = titleLineIndex + 1;
while (i < lines.length) {
const line = lines[i]; const line = lines[i];
if (line.match(headerPattern)) {
// Found title line, check subsequent lines for authors // Stop if we hit a blank line, section header, or content that's not an author
let j = i + 1; if (line.trim() === "" || line.match(/^==\s+/)) {
while (j < lines.length) {
const authorLine = lines[j];
// Stop if we hit a blank line or content that's not an author
if (authorLine.trim() === '') {
break; break;
} }
// Skip section headers at any level (they start with ==, ===, etc.) if (line.includes("<") && !line.startsWith(":")) {
if (authorLine.match(/^==+\s+/)) { // This is an author line like "John Doe <john@example.com>"
// This is a section header, stop looking for authors const authorName = line.split("<")[0].trim();
if (authorName) {
authors.push(authorName);
}
} else if (line.startsWith(":")) {
// This is an attribute line, skip it
// Don't break here, continue to next line
} else {
// Not an author line, stop looking
break; break;
} }
if (authorLine.includes('<') && !authorLine.startsWith(':')) { i++;
}
return authors;
}
/**
* Extracts authors from section header only
*/
function extractSectionAuthors(sectionContent: string): string[] {
const authors: string[] = [];
const lines = sectionContent.split(/\r?\n/);
// Find the section title line
let titleLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/^==\s+/)) {
titleLineIndex = i;
break;
}
}
if (titleLineIndex === -1) {
return authors;
}
// Look for authors in the lines immediately following the section title
let i = titleLineIndex + 1;
while (i < lines.length) {
const line = lines[i];
// Stop if we hit a blank line, another section header, or content that's not an author
if (line.trim() === "" || line.match(/^==\s+/)) {
break;
}
if (line.includes("<") && !line.startsWith(":")) {
// This is an author line like "John Doe <john@example.com>" // This is an author line like "John Doe <john@example.com>"
const authorName = authorLine.split('<')[0].trim(); const authorName = line.split("<")[0].trim();
if (authorName) { if (authorName) {
authors.push(authorName); authors.push(authorName);
} }
} else if (isSection && authorLine.match(/^[A-Za-z\s]+$/) && authorLine.trim() !== '' && } else if (
authorLine.trim().split(/\s+/).length <= 2) { line.match(/^[A-Za-z\s]+$/) &&
line.trim() !== "" &&
line.trim().split(/\s+/).length <= 2 &&
!line.startsWith(":")
) {
// This is a simple author name without email (for sections) // This is a simple author name without email (for sections)
authors.push(authorLine.trim()); authors.push(line.trim());
} else if (authorLine.startsWith(':')) { } else if (line.startsWith(":")) {
// This is an attribute line, skip it - attributes are handled by mapAttributesToMetadata // This is an attribute line, skip it
// Don't break here, continue to next line // Don't break here, continue to next line
} else { } else {
// Not an author line, stop looking // Not an author line, stop looking
break; break;
} }
j++; i++;
} }
return authors;
}
// System attributes to filter out when adding custom attributes as tags
const systemAttributes = [
'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig',
'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption',
'important-caption', 'last-update-label', 'manname-title', 'note-caption',
'part-refsig', 'preface-title', 'section-refsig', 'table-caption',
'tip-caption', 'toc-title', 'untitled-label', 'version-label', 'warning-caption'
];
/**
* Strips section header and attribute lines from content
*/
function stripSectionHeader(sectionContent: string): string {
const lines = sectionContent.split(/\r?\n/);
let contentStart = 0;
// Find where the section header ends
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip section title line and attribute lines
if (
!line.match(/^=+\s+/) &&
!line.includes("<") &&
!line.match(/^.+,\s*.+:\s*.+$/) &&
!line.match(/^:[^:]+:\s*.+$/) &&
line.trim() !== ""
) {
contentStart = i;
break; break;
} }
} }
return authors; const processedLines: string[] = [];
let lastWasEmpty = false;
for (let i = contentStart; i < lines.length; i++) {
const line = lines[i];
// Skip attribute lines within content
if (line.match(/^:[^:]+:\s*.+$/)) {
continue;
}
// Handle empty lines - don't add more than one consecutive empty line
if (line.trim() === '') {
if (!lastWasEmpty) {
processedLines.push('');
}
lastWasEmpty = true;
} else {
processedLines.push(line);
lastWasEmpty = false;
}
}
// Remove extra blank lines and normalize newlines
return processedLines.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n').trim();
} }
/** /**
* Strips header and attribute lines from content * Strips document header and attribute lines from content
*/ */
function stripHeaderAndAttributes(content: string, isSection: boolean = false): string { function stripDocumentHeader(content: string): string {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
let contentStart = 0; let contentStart = 0;
const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
// Find the first line that is actual content (not header, author, or attribute) // Find the first line that is actual content (not header, author, or attribute)
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
// Skip title line, author line, revision line, and attribute lines // Skip title line, author line, revision line, and attribute lines
if (!line.match(headerPattern) && !line.includes('<') && !line.match(/^.+,\s*.+:\s*.+$/) && if (
!line.match(/^:[^:]+:\s*.+$/) && line.trim() !== '') { !line.match(/^=\s+/) &&
!line.includes("<") &&
!line.match(/^.+,\s*.+:\s*.+$/) &&
!line.match(/^:[^:]+:\s*.+$/) &&
line.trim() !== ""
) {
contentStart = i; contentStart = i;
break; break;
} }
@ -239,15 +353,11 @@ function stripHeaderAndAttributes(content: string, isSection: boolean = false):
// Filter out all attribute lines and author lines from the content // Filter out all attribute lines and author lines from the content
const contentLines = lines.slice(contentStart); const contentLines = lines.slice(contentStart);
const filteredLines = contentLines.filter(line => { const filteredLines = contentLines.filter((line) => {
// Skip attribute lines // Skip attribute lines
if (line.match(/^:[^:]+:\s*.+$/)) { if (line.match(/^:[^:]+:\s*.+$/)) {
return false; return false;
} }
// Skip author lines (simple names without email)
if (isSection && line.match(/^[A-Za-z\s]+$/) && line.trim() !== '' && line.trim().split(/\s+/).length <= 2) {
return false;
}
return true; return true;
}); });
@ -310,10 +420,6 @@ export function parseSimpleAttributes(content: string): [string, string][] {
return tags; return tags;
} }
/** /**
* Extracts metadata from AsciiDoc document using Asciidoctor * Extracts metadata from AsciiDoc document using Asciidoctor
*/ */
@ -322,7 +428,9 @@ export function extractDocumentMetadata(inputContent: string): {
content: string; content: string;
} { } {
const asciidoctor = createProcessor(); const asciidoctor = createProcessor();
const document = asciidoctor.load(inputContent, { standalone: false }) as Document; const document = asciidoctor.load(inputContent, {
standalone: false,
}) as Document;
const metadata: AsciiDocMetadata = {}; const metadata: AsciiDocMetadata = {};
const attributes = document.getAttributes(); const attributes = document.getAttributes();
@ -332,12 +440,28 @@ export function extractDocumentMetadata(inputContent: string): {
if (title) metadata.title = decodeHtmlEntities(title); if (title) metadata.title = decodeHtmlEntities(title);
// Handle multiple authors - combine header line and attributes // Handle multiple authors - combine header line and attributes
const authors = extractAuthorsFromHeader(document.getSource()); const authors = extractDocumentAuthors(document.getSource());
// Get authors from attributes (but avoid duplicates) // Get authors from attributes in the document header only (including multiple :author: lines)
const attrAuthor = attributes['author']; const lines = document.getSource().split(/\r?\n/);
if (attrAuthor && typeof attrAuthor === 'string' && !authors.includes(attrAuthor)) { let inDocumentHeader = true;
authors.push(attrAuthor); for (const line of lines) {
// Stop scanning when we hit a section header
if (line.match(/^==\s+/)) {
inDocumentHeader = false;
break;
}
// Process :author: attributes regardless of other content
if (inDocumentHeader) {
const match = line.match(/^:author:\s*(.+)$/);
if (match) {
const authorName = match[1].trim();
if (authorName && !authors.includes(authorName)) {
authors.push(authorName);
}
}
}
} }
if (authors.length > 0) { if (authors.length > 0) {
@ -379,7 +503,7 @@ export function extractDocumentMetadata(inputContent: string): {
metadata.tags = tags; metadata.tags = tags;
} }
const content = stripHeaderAndAttributes(document.getSource()); const content = stripDocumentHeader(document.getSource());
return { metadata, content }; return { metadata, content };
} }
@ -401,7 +525,20 @@ export function extractSectionMetadata(inputSectionContent: string): {
const metadata: SectionMetadata = { title }; const metadata: SectionMetadata = { title };
// Extract authors from section content // Extract authors from section content
const authors = extractAuthorsFromHeader(inputSectionContent, true); const authors = extractSectionAuthors(inputSectionContent);
// Get authors from attributes (including multiple :author: lines)
const lines = inputSectionContent.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^:author:\s*(.+)$/);
if (match) {
const authorName = match[1].trim();
if (authorName && !authors.includes(authorName)) {
authors.push(authorName);
}
}
}
if (authors.length > 0) { if (authors.length > 0) {
metadata.authors = authors; metadata.authors = authors;
} }
@ -413,7 +550,7 @@ export function extractSectionMetadata(inputSectionContent: string): {
metadata.tags = tags; metadata.tags = tags;
} }
const content = stripHeaderAndAttributes(inputSectionContent, true); const content = stripSectionHeader(inputSectionContent);
return { metadata, content, title }; return { metadata, content, title };
} }
@ -439,7 +576,7 @@ export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
if (line.match(/^==\s+/)) { if (line.match(/^==\s+/)) {
// Save previous section if exists // Save previous section if exists
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sectionsWithMetadata.push(extractSectionMetadata(sectionContent)); sectionsWithMetadata.push(extractSectionMetadata(sectionContent));
} }
@ -453,7 +590,7 @@ export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
// Save the last section // Save the last section
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sectionsWithMetadata.push(extractSectionMetadata(sectionContent)); sectionsWithMetadata.push(extractSectionMetadata(sectionContent));
} }
@ -468,42 +605,33 @@ export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
/** /**
* Converts metadata to Nostr event tags * Converts metadata to Nostr event tags
*/ */
export function metadataToTags(metadata: AsciiDocMetadata | SectionMetadata): [string, string][] { export function metadataToTags(
metadata: AsciiDocMetadata | SectionMetadata,
): [string, string][] {
const tags: [string, string][] = []; const tags: [string, string][] = [];
if (metadata.title) tags.push(['title', metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
if (metadata.authors?.length) { if (metadata.authors?.length) {
metadata.authors.forEach(author => tags.push(['author', author])); metadata.authors.forEach((author) => tags.push(["author", author]));
} }
if (metadata.version) tags.push(['version', metadata.version]); if (metadata.version) tags.push(["version", metadata.version]);
if (metadata.edition) tags.push(['edition', metadata.edition]); if (metadata.edition) tags.push(["edition", metadata.edition]);
if (metadata.publicationDate) tags.push(['published_on', metadata.publicationDate]); if (metadata.publicationDate) {
if (metadata.publishedBy) tags.push(['published_by', metadata.publishedBy]); tags.push(["published_on", metadata.publicationDate]);
if (metadata.summary) tags.push(['summary', metadata.summary]); }
if (metadata.coverImage) tags.push(['image', metadata.coverImage]); if (metadata.publishedBy) tags.push(["published_by", metadata.publishedBy]);
if (metadata.isbn) tags.push(['i', metadata.isbn]); if (metadata.summary) tags.push(["summary", metadata.summary]);
if (metadata.source) tags.push(['source', metadata.source]); if (metadata.coverImage) tags.push(["image", metadata.coverImage]);
if (metadata.type) tags.push(['type', metadata.type]); if (metadata.isbn) tags.push(["i", metadata.isbn]);
if (metadata.autoUpdate) tags.push(['auto-update', metadata.autoUpdate]); if (metadata.source) tags.push(["source", metadata.source]);
if (metadata.type) tags.push(["type", metadata.type]);
if (metadata.autoUpdate) tags.push(["auto-update", metadata.autoUpdate]);
if (metadata.tags?.length) { if (metadata.tags?.length) {
metadata.tags.forEach(tag => tags.push(['t', tag])); metadata.tags.forEach((tag) => tags.push(["t", tag]));
} }
// Add custom attributes as tags, but filter out system attributes // Add custom attributes as tags, but filter out system attributes
if (metadata.customAttributes) { if (metadata.customAttributes) {
const systemAttributes = [
'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig',
'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption',
'important-caption', 'last-update-label', 'note-caption', 'part-refsig',
'section-refsig', 'table-caption', 'tip-caption', 'toc-placement',
'toc-title', 'untitled-label', 'warning-caption', 'asciidoctor-version',
'safe-mode-name', 'backend', 'user-home', 'doctype', 'htmlsyntax',
'outfilesuffix', 'filetype', 'basebackend', 'stylesdir', 'iconsdir',
'localdate', 'localyear', 'localtime', 'localdatetime', 'docdate',
'docyear', 'doctime', 'docdatetime', 'doctitle', 'language',
'firstname', 'authorinitials', 'authors'
];
Object.entries(metadata.customAttributes).forEach(([key, value]) => { Object.entries(metadata.customAttributes).forEach(([key, value]) => {
if (!systemAttributes.includes(key)) { if (!systemAttributes.includes(key)) {
tags.push([key, value]); tags.push([key, value]);
@ -545,7 +673,7 @@ export function extractMetadataFromSectionsOnly(content: string): {
if (line.match(/^==\s+/)) { if (line.match(/^==\s+/)) {
// Save previous section if exists // Save previous section if exists
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sections.push(extractSectionMetadata(sectionContent)); sections.push(extractSectionMetadata(sectionContent));
} }
@ -559,7 +687,7 @@ export function extractMetadataFromSectionsOnly(content: string): {
// Save the last section // Save the last section
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sections.push(extractSectionMetadata(sectionContent)); sections.push(extractSectionMetadata(sectionContent));
} }
@ -1018,9 +1146,9 @@ export function extractSmartMetadata(content: string): {
if (hasDocumentHeader) { if (hasDocumentHeader) {
// Check if it's a minimal document header (just title, no other metadata) // Check if it's a minimal document header (just title, no other metadata)
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const titleLine = lines.find(line => line.match(/^=\s+/)); const titleLine = lines.find((line) => line.match(/^=\s+/));
const hasOtherMetadata = lines.some(line => const hasOtherMetadata = lines.some((line) =>
line.includes('<') || // author line line.includes("<") || // author line
line.match(/^.+,\s*.+:\s*.+$/) // revision line line.match(/^.+,\s*.+:\s*.+$/) // revision line
); );
@ -1029,7 +1157,7 @@ export function extractSmartMetadata(content: string): {
return extractDocumentMetadata(content); return extractDocumentMetadata(content);
} else { } else {
// Minimal document header (just title) - preserve the title line for 30040 events // Minimal document header (just title) - preserve the title line for 30040 events
const title = titleLine?.replace(/^=\s+/, '').trim(); const title = titleLine?.replace(/^=\s+/, "").trim();
const metadata: AsciiDocMetadata = {}; const metadata: AsciiDocMetadata = {};
if (title) { if (title) {
metadata.title = title; metadata.title = title;

85
src/lib/utils/cache_manager.ts

@ -0,0 +1,85 @@
import { unifiedProfileCache } from './npubCache';
import { searchCache } from './searchCache';
import { indexEventCache } from './indexEventCache';
import { clearRelaySetCache } from '../ndk';
/**
* Clears all application caches
*
* Clears:
* - unifiedProfileCache (profile metadata)
* - searchCache (search results)
* - indexEventCache (index events)
* - relaySetCache (relay configuration)
*/
export function clearAllCaches(): void {
console.log('[CacheManager] Clearing all application caches...');
// Clear in-memory caches
unifiedProfileCache.clear();
searchCache.clear();
indexEventCache.clear();
clearRelaySetCache();
// Clear localStorage caches
clearLocalStorageCaches();
console.log('[CacheManager] All caches cleared successfully');
}
/**
* Clears profile-specific caches to force fresh profile data
* This is useful when profile pictures or metadata are stale
*/
export function clearProfileCaches(): void {
console.log('[CacheManager] Clearing profile-specific caches...');
// Clear unified profile cache
unifiedProfileCache.clear();
// Clear profile-related search results
// Note: searchCache doesn't have a way to clear specific types, so we clear all
// This is acceptable since profile searches are the most common
searchCache.clear();
console.log('[CacheManager] Profile caches cleared successfully');
}
/**
* Clears localStorage caches
*/
function clearLocalStorageCaches(): void {
if (typeof window === 'undefined') return;
const keysToRemove: string[] = [];
// Find all localStorage keys that start with 'alexandria'
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('alexandria')) {
keysToRemove.push(key);
}
}
// Remove the keys
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
console.log(`[CacheManager] Cleared ${keysToRemove.length} localStorage items`);
}
/**
* Gets statistics about all caches
*/
export function getCacheStats(): {
profileCacheSize: number;
searchCacheSize: number;
indexEventCacheSize: number;
} {
return {
profileCacheSize: unifiedProfileCache.size(),
searchCacheSize: searchCache.size(),
indexEventCacheSize: indexEventCache.size(),
};
}

33
src/lib/utils/displayLimits.ts

@ -1,7 +1,7 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { VisualizationConfig } from '$lib/stores/visualizationConfig'; import type { VisualizationConfig } from "$lib/stores/visualizationConfig";
import { isEventId, isCoordinate, parseCoordinate } from './nostr_identifiers'; import { isCoordinate, isEventId, parseCoordinate } from "./nostr_identifiers";
import type { NostrEventId } from './nostr_identifiers'; import type { NostrEventId } from "./nostr_identifiers";
/** /**
* Filters events based on visualization configuration * Filters events based on visualization configuration
@ -9,7 +9,10 @@ import type { NostrEventId } from './nostr_identifiers';
* @param config - Visualization configuration * @param config - Visualization configuration
* @returns Filtered events that should be displayed * @returns Filtered events that should be displayed
*/ */
export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationConfig): NDKEvent[] { export function filterByDisplayLimits(
events: NDKEvent[],
config: VisualizationConfig,
): NDKEvent[] {
const result: NDKEvent[] = []; const result: NDKEvent[] = [];
const kindCounts = new Map<number, number>(); const kindCounts = new Map<number, number>();
@ -18,7 +21,7 @@ export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationC
if (kind === undefined) continue; if (kind === undefined) continue;
// Get the config for this event kind // Get the config for this event kind
const eventConfig = config.eventConfigs.find(ec => ec.kind === kind); const eventConfig = config.eventConfigs.find((ec) => ec.kind === kind);
// Skip if the kind is disabled // Skip if the kind is disabled
if (eventConfig && eventConfig.enabled === false) { if (eventConfig && eventConfig.enabled === false) {
@ -60,13 +63,13 @@ export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationC
export function detectMissingEvents( export function detectMissingEvents(
events: NDKEvent[], events: NDKEvent[],
existingIds: Set<NostrEventId>, existingIds: Set<NostrEventId>,
existingCoordinates?: Map<string, NDKEvent> existingCoordinates?: Map<string, NDKEvent>,
): Set<string> { ): Set<string> {
const missing = new Set<string>(); const missing = new Set<string>();
for (const event of events) { for (const event of events) {
// Check 'e' tags for direct event references (hex IDs) // Check 'e' tags for direct event references (hex IDs)
const eTags = event.getMatchingTags('e'); const eTags = event.getMatchingTags("e");
for (const eTag of eTags) { for (const eTag of eTags) {
if (eTag.length < 2) continue; if (eTag.length < 2) continue;
@ -74,7 +77,7 @@ export function detectMissingEvents(
// Type check: ensure it's a valid hex event ID // Type check: ensure it's a valid hex event ID
if (!isEventId(eventId)) { if (!isEventId(eventId)) {
console.warn('Invalid event ID in e tag:', eventId); console.warn("Invalid event ID in e tag:", eventId);
continue; continue;
} }
@ -84,7 +87,7 @@ export function detectMissingEvents(
} }
// Check 'a' tags for NIP-33 references (kind:pubkey:d-tag) // Check 'a' tags for NIP-33 references (kind:pubkey:d-tag)
const aTags = event.getMatchingTags('a'); const aTags = event.getMatchingTags("a");
for (const aTag of aTags) { for (const aTag of aTags) {
if (aTag.length < 2) continue; if (aTag.length < 2) continue;
@ -92,7 +95,7 @@ export function detectMissingEvents(
// Type check: ensure it's a valid coordinate // Type check: ensure it's a valid coordinate
if (!isCoordinate(identifier)) { if (!isCoordinate(identifier)) {
console.warn('Invalid coordinate in a tag:', identifier); console.warn("Invalid coordinate in a tag:", identifier);
continue; continue;
} }
@ -108,7 +111,10 @@ export function detectMissingEvents(
} else { } else {
// Without coordinate map, we can't detect missing NIP-33 events // Without coordinate map, we can't detect missing NIP-33 events
// This is a limitation when we only have hex IDs // This is a limitation when we only have hex IDs
console.debug('Cannot detect missing NIP-33 events without coordinate map:', identifier); console.debug(
"Cannot detect missing NIP-33 events without coordinate map:",
identifier,
);
} }
} }
} }
@ -127,7 +133,7 @@ export function buildCoordinateMap(events: NDKEvent[]): Map<string, NDKEvent> {
for (const event of events) { for (const event of events) {
// Only process replaceable events (kinds 30000-39999) // Only process replaceable events (kinds 30000-39999)
if (event.kind && event.kind >= 30000 && event.kind < 40000) { if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tagValue('d'); const dTag = event.tagValue("d");
const author = event.pubkey; const author = event.pubkey;
if (dTag && author) { if (dTag && author) {
@ -139,4 +145,3 @@ export function buildCoordinateMap(events: NDKEvent[]): Map<string, NDKEvent> {
return coordinateMap; return coordinateMap;
} }

95
src/lib/utils/eventColors.ts

@ -28,55 +28,54 @@ export function getEventKindColor(kind: number): string {
*/ */
export function getEventKindName(kind: number): string { export function getEventKindName(kind: number): string {
const kindNames: Record<number, string> = { const kindNames: Record<number, string> = {
0: 'Metadata', 0: "Metadata",
1: 'Text Note', 1: "Text Note",
2: 'Recommend Relay', 2: "Recommend Relay",
3: 'Contact List', 3: "Contact List",
4: 'Encrypted DM', 4: "Encrypted DM",
5: 'Event Deletion', 5: "Event Deletion",
6: 'Repost', 6: "Repost",
7: 'Reaction', 7: "Reaction",
8: 'Badge Award', 8: "Badge Award",
16: 'Generic Repost', 16: "Generic Repost",
40: 'Channel Creation', 40: "Channel Creation",
41: 'Channel Metadata', 41: "Channel Metadata",
42: 'Channel Message', 42: "Channel Message",
43: 'Channel Hide Message', 43: "Channel Hide Message",
44: 'Channel Mute User', 44: "Channel Mute User",
1984: 'Reporting', 1984: "Reporting",
9734: 'Zap Request', 9734: "Zap Request",
9735: 'Zap', 9735: "Zap",
10000: 'Mute List', 10000: "Mute List",
10001: 'Pin List', 10001: "Pin List",
10002: 'Relay List', 10002: "Relay List",
22242: 'Client Authentication', 22242: "Client Authentication",
24133: 'Nostr Connect', 24133: "Nostr Connect",
27235: 'HTTP Auth', 27235: "HTTP Auth",
30000: 'Categorized People List', 30000: "Categorized People List",
30001: 'Categorized Bookmark List', 30001: "Categorized Bookmark List",
30008: 'Profile Badges', 30008: "Profile Badges",
30009: 'Badge Definition', 30009: "Badge Definition",
30017: 'Create or update a stall', 30017: "Create or update a stall",
30018: 'Create or update a product', 30018: "Create or update a product",
30023: 'Long-form Content', 30023: "Long-form Content",
30024: 'Draft Long-form Content', 30024: "Draft Long-form Content",
30040: 'Publication Index', 30040: "Publication Index",
30041: 'Publication Content', 30041: "Publication Content",
30078: 'Application-specific Data', 30078: "Application-specific Data",
30311: 'Live Event', 30311: "Live Event",
30402: 'Classified Listing', 30402: "Classified Listing",
30403: 'Draft Classified Listing', 30403: "Draft Classified Listing",
30617: 'Repository', 30617: "Repository",
30818: 'Wiki Page', 30818: "Wiki Page",
31922: 'Date-Based Calendar Event', 31922: "Date-Based Calendar Event",
31923: 'Time-Based Calendar Event', 31923: "Time-Based Calendar Event",
31924: 'Calendar', 31924: "Calendar",
31925: 'Calendar Event RSVP', 31925: "Calendar Event RSVP",
31989: 'Handler recommendation', 31989: "Handler recommendation",
31990: 'Handler information', 31990: "Handler information",
34550: 'Community Definition', 34550: "Community Definition",
}; };
return kindNames[kind] || `Kind ${kind}`; return kindNames[kind] || `Kind ${kind}`;
} }

94
src/lib/utils/eventDeduplication.ts

@ -1,20 +1,24 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
/** /**
* Deduplicate content events by keeping only the most recent version * Deduplicate content events by keeping only the most recent version
* @param contentEventSets Array of event sets from different sources * @param contentEventSets Array of event sets from different sources
* @returns Map of coordinate to most recent event * @returns Map of coordinate to most recent event
*/ */
export function deduplicateContentEvents(contentEventSets: Set<NDKEvent>[]): Map<string, NDKEvent> { export function deduplicateContentEvents(
contentEventSets: Set<NDKEvent>[],
): Map<string, NDKEvent> {
const eventsByCoordinate = new Map<string, NDKEvent>(); const eventsByCoordinate = new Map<string, NDKEvent>();
// Track statistics for debugging // Track statistics for debugging
let totalEvents = 0; let totalEvents = 0;
let duplicateCoordinates = 0; let duplicateCoordinates = 0;
const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = []; const duplicateDetails: Array<
{ coordinate: string; count: number; events: string[] }
> = [];
contentEventSets.forEach((eventSet) => { contentEventSets.forEach((eventSet) => {
eventSet.forEach(event => { eventSet.forEach((event) => {
totalEvents++; totalEvents++;
const dTag = event.tagValue("d"); const dTag = event.tagValue("d");
const author = event.pubkey; const author = event.pubkey;
@ -30,25 +34,34 @@ export function deduplicateContentEvents(contentEventSets: Set<NDKEvent>[]): Map
// Track details for the first few duplicates // Track details for the first few duplicates
if (duplicateDetails.length < 5) { if (duplicateDetails.length < 5) {
const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate); const existingDetails = duplicateDetails.find((d) =>
d.coordinate === coordinate
);
if (existingDetails) { if (existingDetails) {
existingDetails.count++; existingDetails.count++;
existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`); existingDetails.events.push(
`${event.id} (created_at: ${event.created_at})`,
);
} else { } else {
duplicateDetails.push({ duplicateDetails.push({
coordinate, coordinate,
count: 2, // existing + current count: 2, // existing + current
events: [ events: [
`${existing.id} (created_at: ${existing.created_at})`, `${existing.id} (created_at: ${existing.created_at})`,
`${event.id} (created_at: ${event.created_at})` `${event.id} (created_at: ${event.created_at})`,
] ],
}); });
} }
} }
} }
// Keep the most recent event (highest created_at) // Keep the most recent event (highest created_at)
if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) { if (
!existing ||
(event.created_at !== undefined &&
existing.created_at !== undefined &&
event.created_at > existing.created_at)
) {
eventsByCoordinate.set(coordinate, event); eventsByCoordinate.set(coordinate, event);
} }
} }
@ -57,11 +70,17 @@ export function deduplicateContentEvents(contentEventSets: Set<NDKEvent>[]): Map
// Log deduplication results if any duplicates were found // Log deduplication results if any duplicates were found
if (duplicateCoordinates > 0) { if (duplicateCoordinates > 0) {
console.log(`[eventDeduplication] Found ${duplicateCoordinates} duplicate events out of ${totalEvents} total events`); console.log(
console.log(`[eventDeduplication] Reduced to ${eventsByCoordinate.size} unique coordinates`); `[eventDeduplication] Found ${duplicateCoordinates} duplicate events out of ${totalEvents} total events`,
);
console.log(
`[eventDeduplication] Reduced to ${eventsByCoordinate.size} unique coordinates`,
);
console.log(`[eventDeduplication] Duplicate details:`, duplicateDetails); console.log(`[eventDeduplication] Duplicate details:`, duplicateDetails);
} else if (totalEvents > 0) { } else if (totalEvents > 0) {
console.log(`[eventDeduplication] No duplicates found in ${totalEvents} events`); console.log(
`[eventDeduplication] No duplicates found in ${totalEvents} events`,
);
} }
return eventsByCoordinate; return eventsByCoordinate;
@ -77,24 +96,27 @@ export function deduplicateContentEvents(contentEventSets: Set<NDKEvent>[]): Map
export function deduplicateAndCombineEvents( export function deduplicateAndCombineEvents(
nonPublicationEvents: NDKEvent[], nonPublicationEvents: NDKEvent[],
validIndexEvents: Set<NDKEvent>, validIndexEvents: Set<NDKEvent>,
contentEvents: Set<NDKEvent> contentEvents: Set<NDKEvent>,
): NDKEvent[] { ): NDKEvent[] {
// Track statistics for debugging // Track statistics for debugging
const initialCount = nonPublicationEvents.length + validIndexEvents.size + contentEvents.size; const initialCount = nonPublicationEvents.length + validIndexEvents.size +
contentEvents.size;
let replaceableEventsProcessed = 0; let replaceableEventsProcessed = 0;
let duplicateCoordinatesFound = 0; let duplicateCoordinatesFound = 0;
const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = []; const duplicateDetails: Array<
{ coordinate: string; count: number; events: string[] }
> = [];
// First, build coordinate map for replaceable events // First, build coordinate map for replaceable events
const coordinateMap = new Map<string, NDKEvent>(); const coordinateMap = new Map<string, NDKEvent>();
const allEventsToProcess = [ const allEventsToProcess = [
...nonPublicationEvents, // Non-publication events fetched earlier ...nonPublicationEvents, // Non-publication events fetched earlier
...Array.from(validIndexEvents), ...Array.from(validIndexEvents),
...Array.from(contentEvents) ...Array.from(contentEvents),
]; ];
// First pass: identify the most recent version of each replaceable event // First pass: identify the most recent version of each replaceable event
allEventsToProcess.forEach(event => { allEventsToProcess.forEach((event) => {
if (!event.id) return; if (!event.id) return;
// For replaceable events (30000-39999), track by coordinate // For replaceable events (30000-39999), track by coordinate
@ -113,25 +135,34 @@ export function deduplicateAndCombineEvents(
// Track details for the first few duplicates // Track details for the first few duplicates
if (duplicateDetails.length < 5) { if (duplicateDetails.length < 5) {
const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate); const existingDetails = duplicateDetails.find((d) =>
d.coordinate === coordinate
);
if (existingDetails) { if (existingDetails) {
existingDetails.count++; existingDetails.count++;
existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`); existingDetails.events.push(
`${event.id} (created_at: ${event.created_at})`,
);
} else { } else {
duplicateDetails.push({ duplicateDetails.push({
coordinate, coordinate,
count: 2, // existing + current count: 2, // existing + current
events: [ events: [
`${existing.id} (created_at: ${existing.created_at})`, `${existing.id} (created_at: ${existing.created_at})`,
`${event.id} (created_at: ${event.created_at})` `${event.id} (created_at: ${event.created_at})`,
] ],
}); });
} }
} }
} }
// Keep the most recent version // Keep the most recent version
if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) { if (
!existing ||
(event.created_at !== undefined &&
existing.created_at !== undefined &&
event.created_at > existing.created_at)
) {
coordinateMap.set(coordinate, event); coordinateMap.set(coordinate, event);
} }
} }
@ -142,7 +173,7 @@ export function deduplicateAndCombineEvents(
const finalEventMap = new Map<string, NDKEvent>(); const finalEventMap = new Map<string, NDKEvent>();
const seenCoordinates = new Set<string>(); const seenCoordinates = new Set<string>();
allEventsToProcess.forEach(event => { allEventsToProcess.forEach((event) => {
if (!event.id) return; if (!event.id) return;
// For replaceable events, only add if it's the chosen version // For replaceable events, only add if it's the chosen version
@ -174,11 +205,20 @@ export function deduplicateAndCombineEvents(
// Log deduplication results if any duplicates were found // Log deduplication results if any duplicates were found
if (duplicateCoordinatesFound > 0) { if (duplicateCoordinatesFound > 0) {
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Found ${duplicateCoordinatesFound} duplicate coordinates out of ${replaceableEventsProcessed} replaceable events`); console.log(
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Reduced from ${initialCount} to ${finalCount} events (${reduction} removed)`); `[eventDeduplication] deduplicateAndCombineEvents: Found ${duplicateCoordinatesFound} duplicate coordinates out of ${replaceableEventsProcessed} replaceable events`,
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Duplicate details:`, duplicateDetails); );
console.log(
`[eventDeduplication] deduplicateAndCombineEvents: Reduced from ${initialCount} to ${finalCount} events (${reduction} removed)`,
);
console.log(
`[eventDeduplication] deduplicateAndCombineEvents: Duplicate details:`,
duplicateDetails,
);
} else if (replaceableEventsProcessed > 0) { } else if (replaceableEventsProcessed > 0) {
console.log(`[eventDeduplication] deduplicateAndCombineEvents: No duplicates found in ${replaceableEventsProcessed} replaceable events`); console.log(
`[eventDeduplication] deduplicateAndCombineEvents: No duplicates found in ${replaceableEventsProcessed} replaceable events`,
);
} }
return Array.from(finalEventMap.values()); return Array.from(finalEventMap.values());

61
src/lib/utils/event_input_utils.ts

@ -1,15 +1,11 @@
import type { NDKEvent } from "./nostrUtils.ts"; import type { NDKEvent } from "./nostrUtils.ts";
import { get } from "svelte/store"; import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "../ndk.ts";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants"; import { EVENT_KINDS } from "./search_constants";
import { import {
extractDocumentMetadata, extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
metadataToTags, metadataToTags,
removeMetadataFromContent parseAsciiDocWithMetadata,
} from "./asciidoc_metadata"; } from "./asciidoc_metadata.ts";
// ========================= // =========================
// Validation // Validation
@ -92,7 +88,9 @@ export function validate30040EventSet(content: string): {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const { metadata } = extractDocumentMetadata(content); const { metadata } = extractDocumentMetadata(content);
const documentTitle = metadata.title; const documentTitle = metadata.title;
const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim()); const nonEmptyLines = lines.filter((line) => line.trim() !== "").map((line) =>
line.trim()
);
const isIndexCardFormat = documentTitle && const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 && nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") && nonEmptyLines[0].startsWith("=") &&
@ -125,7 +123,8 @@ export function validate30040EventSet(content: string): {
if (documentHeaderMatches && documentHeaderMatches.length > 1) { if (documentHeaderMatches && documentHeaderMatches.length > 1) {
return { return {
valid: false, valid: false,
reason: '30040 events must have exactly one document title ("="). Found multiple document headers.', reason:
'30040 events must have exactly one document title ("="). Found multiple document headers.',
}; };
} }
@ -136,7 +135,8 @@ export function validate30040EventSet(content: string): {
if (!hasSections) { if (!hasSections) {
return { return {
valid: true, valid: true,
warning: "No section headers (==) found. This will create a 30040 index event and a single 30041 preamble section. Continue?", warning:
"No section headers (==) found. This will create a 30040 index event and a single 30041 preamble section. Continue?",
}; };
} }
@ -147,7 +147,9 @@ export function validate30040EventSet(content: string): {
} }
// Check for empty sections // Check for empty sections
const emptySections = parsed.sections.filter(section => section.content.trim() === ""); const emptySections = parsed.sections.filter((section: any) =>
section.content.trim() === ""
);
if (emptySections.length > 0) { if (emptySections.length > 0) {
return { return {
valid: true, valid: true,
@ -168,6 +170,14 @@ export function validate30040EventSet(content: string): {
function normalizeDTagValue(header: string): string { function normalizeDTagValue(header: string): string {
return header return header
.toLowerCase() .toLowerCase()
// Decode common HTML entities first
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/g, " ")
// Then normalize as before
.replace(/[^\p{L}\p{N}]+/gu, "-") .replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
} }
@ -194,12 +204,6 @@ function extractMarkdownTopHeader(content: string): string | null {
// Event Construction // Event Construction
// ========================= // =========================
/**
* Returns the current NDK instance from the store.
*/
function getNdk() {
return get(ndkInstance);
}
/** /**
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section. * Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section.
@ -210,15 +214,8 @@ export function build30040EventSet(
content: string, content: string,
tags: [string, string][], tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number }, baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number },
ndk: NDK,
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { ): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } {
console.log("=== build30040EventSet called ===");
console.log("Input content:", content);
console.log("Input tags:", tags);
console.log("Input baseEvent:", baseEvent);
const ndk = getNdk();
console.log("NDK instance:", ndk);
// Parse the AsciiDoc content with metadata extraction // Parse the AsciiDoc content with metadata extraction
const parsed = parseAsciiDocWithMetadata(content); const parsed = parseAsciiDocWithMetadata(content);
console.log("Parsed AsciiDoc:", parsed); console.log("Parsed AsciiDoc:", parsed);
@ -228,7 +225,9 @@ export function build30040EventSet(
const documentTitle = parsed.metadata.title; const documentTitle = parsed.metadata.title;
// For index card format, the content should be exactly: title + "index card" // For index card format, the content should be exactly: title + "index card"
const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim()); const nonEmptyLines = lines.filter((line) => line.trim() !== "").map((line) =>
line.trim()
);
const isIndexCardFormat = documentTitle && const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 && nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") && nonEmptyLines[0].startsWith("=") &&
@ -254,8 +253,6 @@ export function build30040EventSet(
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
}); });
console.log("Final index event (index card):", indexEvent);
console.log("=== build30040EventSet completed (index card) ===");
return { indexEvent, sectionEvents: [] }; return { indexEvent, sectionEvents: [] };
} }
@ -264,13 +261,13 @@ export function build30040EventSet(
console.log("Index event:", { documentTitle, indexDTag }); console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata // Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section, i) => { const sectionEvents: NDKEvent[] = parsed.sections.map((section: any, i: number) => {
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`; const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
console.log(`Creating section ${i}:`, { console.log(`Creating section ${i}:`, {
title: section.title, title: section.title,
dTag: sectionDTag, dTag: sectionDTag,
content: section.content, content: section.content,
metadata: section.metadata metadata: section.metadata,
}); });
// Convert section metadata to tags // Convert section metadata to tags
@ -283,7 +280,7 @@ export function build30040EventSet(
...tags, ...tags,
...sectionMetadataTags, ...sectionMetadataTags,
["d", sectionDTag], ["d", sectionDTag],
["title", section.title] ["title", section.title],
], ],
pubkey: baseEvent.pubkey, pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
@ -291,7 +288,7 @@ export function build30040EventSet(
}); });
// Create proper a tags with format: kind:pubkey:d-tag // Create proper a tags with format: kind:pubkey:d-tag
const aTags = sectionEvents.map(event => { const aTags = sectionEvents.map((event) => {
const dTag = event.tags.find(([k]) => k === "d")?.[1]; const dTag = event.tags.find(([k]) => k === "d")?.[1];
return ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string]; return ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string];
}); });

55
src/lib/utils/event_kind_utils.ts

@ -1,4 +1,4 @@
import type { EventKindConfig } from '$lib/stores/visualizationConfig'; import type { EventKindConfig } from "$lib/stores/visualizationConfig";
/** /**
* Validates an event kind input value. * Validates an event kind input value.
@ -8,28 +8,28 @@ import type { EventKindConfig } from '$lib/stores/visualizationConfig';
*/ */
export function validateEventKind( export function validateEventKind(
value: string | number, value: string | number,
existingKinds: number[] existingKinds: number[],
): { kind: number | null; error: string } { ): { kind: number | null; error: string } {
// Convert to string for consistent handling // Convert to string for consistent handling
const strValue = String(value); const strValue = String(value);
if (strValue === null || strValue === undefined || strValue.trim() === '') { if (strValue === null || strValue === undefined || strValue.trim() === "") {
return { kind: null, error: '' }; return { kind: null, error: "" };
} }
const kind = parseInt(strValue.trim()); const kind = parseInt(strValue.trim());
if (isNaN(kind)) { if (isNaN(kind)) {
return { kind: null, error: 'Must be a number' }; return { kind: null, error: "Must be a number" };
} }
if (kind < 0) { if (kind < 0) {
return { kind: null, error: 'Must be non-negative' }; return { kind: null, error: "Must be non-negative" };
} }
if (existingKinds.includes(kind)) { if (existingKinds.includes(kind)) {
return { kind: null, error: 'Already added' }; return { kind: null, error: "Already added" };
} }
return { kind, error: '' }; return { kind, error: "" };
} }
/** /**
@ -44,20 +44,20 @@ export function handleAddEventKind(
newKind: string, newKind: string,
existingKinds: number[], existingKinds: number[],
addKindFunction: (kind: number) => void, addKindFunction: (kind: number) => void,
resetStateFunction: () => void resetStateFunction: () => void,
): { success: boolean; error: string } { ): { success: boolean; error: string } {
console.log('[handleAddEventKind] called with:', newKind); console.log("[handleAddEventKind] called with:", newKind);
const validation = validateEventKind(newKind, existingKinds); const validation = validateEventKind(newKind, existingKinds);
console.log('[handleAddEventKind] Validation result:', validation); console.log("[handleAddEventKind] Validation result:", validation);
if (validation.kind !== null) { if (validation.kind !== null) {
console.log('[handleAddEventKind] Adding event kind:', validation.kind); console.log("[handleAddEventKind] Adding event kind:", validation.kind);
addKindFunction(validation.kind); addKindFunction(validation.kind);
resetStateFunction(); resetStateFunction();
return { success: true, error: '' }; return { success: true, error: "" };
} else { } else {
console.log('[handleAddEventKind] Validation failed:', validation.error); console.log("[handleAddEventKind] Validation failed:", validation.error);
return { success: false, error: validation.error }; return { success: false, error: validation.error };
} }
} }
@ -71,11 +71,11 @@ export function handleAddEventKind(
export function handleEventKindKeydown( export function handleEventKindKeydown(
e: KeyboardEvent, e: KeyboardEvent,
onEnter: () => void, onEnter: () => void,
onEscape: () => void onEscape: () => void,
): void { ): void {
if (e.key === 'Enter') { if (e.key === "Enter") {
onEnter(); onEnter();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
onEscape(); onEscape();
} }
} }
@ -87,12 +87,19 @@ export function handleEventKindKeydown(
*/ */
export function getEventKindDisplayName(kind: number): string { export function getEventKindDisplayName(kind: number): string {
switch (kind) { switch (kind) {
case 30040: return 'Publication Index'; case 30040:
case 30041: return 'Publication Content'; return "Publication Index";
case 30818: return 'Wiki'; case 30041:
case 1: return 'Text Note'; return "Publication Content";
case 0: return 'Metadata'; case 30818:
case 3: return 'Follow List'; return "Wiki";
default: return `Kind ${kind}`; case 1:
return "Text Note";
case 0:
return "Metadata";
case 3:
return "Follow List";
default:
return `Kind ${kind}`;
} }
} }

146
src/lib/utils/event_search.ts

@ -1,16 +1,60 @@
import { ndkInstance } from "../ndk.ts"; import { fetchEventWithFallback, NDKRelaySetFromNDK } from "./nostrUtils.ts";
import { fetchEventWithFallback } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts"; import type { Filter } from "./search_types.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts"; import { isValidNip05Address, wellKnownUrl } from "./search_utils.ts";
import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; import { TIMEOUTS, VALIDATION } from "./search_constants.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
/** /**
* Search for a single event by ID or filter * Search for a single event by ID or filter
*/ */
export async function searchEvent(query: string): Promise<NDKEvent | null> { export async function searchEvent(query: string, ndk: NDK): Promise<NDKEvent | null> {
if (!ndk) {
console.warn("[Search] No NDK instance available");
return null;
}
// AI-NOTE: 2025-01-24 - Wait for any relays to be available, not just pool relays
// This ensures searches can proceed even if some relay types are not available
let attempts = 0;
const maxAttempts = 5; // Reduced since we'll use fallback relays
while (attempts < maxAttempts) {
// Check if we have any relays in the pool
if (ndk.pool.relays.size > 0) {
console.log(`[Search] Found ${ndk.pool.relays.size} relays in NDK pool`);
break;
}
// Also check if we have any active relays
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
console.log(
`[Search] Found active relays - inbox: ${inboxRelays.length}, outbox: ${outboxRelays.length}`,
);
break;
}
console.log(
`[Search] Waiting for relays to be available (attempt ${
attempts + 1
}/${maxAttempts})`,
);
await new Promise((resolve) => setTimeout(resolve, 500));
attempts++;
}
// AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let fetchEventWithFallback handle fallbacks
// The fetchEventWithFallback function will use all available relays including fallback relays
if (ndk.pool.relays.size === 0) {
console.warn(
"[Search] No relays in pool, but proceeding with search - fallback relays will be used",
);
}
// Clean the query and normalize to lowercase // Clean the query and normalize to lowercase
const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase(); const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: Filter | string = cleanedQuery; let filterOrId: Filter | string = cleanedQuery;
@ -22,14 +66,14 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
// Try as event id // Try as event id
filterOrId = cleanedQuery; filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback( const eventResult = await fetchEventWithFallback(
get(ndkInstance), ndk,
filterOrId, filterOrId,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
// Always try as pubkey (profile event) as well // Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback( const profileEvent = await fetchEventWithFallback(
get(ndkInstance), ndk,
profileFilter, profileFilter,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
@ -51,8 +95,70 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
try { try {
const decoded = nip19.decode(cleanedQuery); const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error("Invalid identifier"); if (!decoded) throw new Error("Invalid identifier");
console.log(`[Search] Decoded identifier:`, {
type: decoded.type,
data: decoded.data,
query: cleanedQuery,
});
switch (decoded.type) { switch (decoded.type) {
case "nevent": case "nevent":
console.log(`[Search] Processing nevent:`, {
id: decoded.data.id,
kind: decoded.data.kind,
relays: decoded.data.relays,
});
// Use the relays from the nevent if available
if (decoded.data.relays && decoded.data.relays.length > 0) {
console.log(
`[Search] Using relays from nevent:`,
decoded.data.relays,
);
// Try to fetch the event using the nevent's relays
try {
// Create a temporary relay set for this search
const neventRelaySet = NDKRelaySetFromNDK.fromRelayUrls(
decoded.data.relays,
ndk,
);
if (neventRelaySet.relays.size > 0) {
console.log(
`[Search] Created relay set with ${neventRelaySet.relays.size} relays from nevent`,
);
// Try to fetch the event using the nevent's relays
const event = await ndk
.fetchEvent(
{ ids: [decoded.data.id] },
undefined,
neventRelaySet,
)
.withTimeout(TIMEOUTS.EVENT_FETCH);
if (event) {
console.log(
`[Search] Found event using nevent relays:`,
event.id,
);
return event;
} else {
console.log(
`[Search] Event not found on nevent relays, trying default relays`,
);
}
}
} catch (error) {
console.warn(
`[Search] Error fetching from nevent relays:`,
error,
);
}
}
filterOrId = decoded.data.id; filterOrId = decoded.data.id;
break; break;
case "note": case "note":
@ -88,7 +194,7 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
try { try {
const event = await fetchEventWithFallback( const event = await fetchEventWithFallback(
get(ndkInstance), ndk,
filterOrId, filterOrId,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
@ -110,6 +216,7 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
*/ */
export async function searchNip05( export async function searchNip05(
nip05Address: string, nip05Address: string,
ndk: NDK,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain // NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) { if (!isValidNip05Address(nip05Address)) {
@ -127,11 +234,27 @@ export async function searchNip05(
const data = await res.json(); const data = await res.json();
const pubkey = data.names?.[name]; // Try exact match first
let pubkey = data.names?.[name];
// If not found, try case-insensitive search
if (!pubkey && data.names) {
const names = Object.keys(data.names);
const matchingName = names.find(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (matchingName) {
pubkey = data.names[matchingName];
console.log(
`[searchNip05] Found case-insensitive match: ${name} -> ${matchingName}`,
);
}
}
if (pubkey) { if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] }; const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback( const profileEvent = await fetchEventWithFallback(
get(ndkInstance), ndk,
profileFilter, profileFilter,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
@ -162,6 +285,7 @@ export async function searchNip05(
*/ */
export async function findContainingIndexEvents( export async function findContainingIndexEvents(
contentEvent: NDKEvent, contentEvent: NDKEvent,
ndk: NDK,
): Promise<NDKEvent[]> { ): Promise<NDKEvent[]> {
// Support all content event kinds that can be contained in indexes // Support all content event kinds that can be contained in indexes
const contentEventKinds = [30041, 30818, 30040, 30023]; const contentEventKinds = [30041, 30818, 30040, 30023];
@ -170,8 +294,6 @@ export async function findContainingIndexEvents(
} }
try { try {
const ndk = get(ndkInstance);
// Search for 30040 events that reference this content event // Search for 30040 events that reference this content event
// We need to search for events that have an 'a' tag or 'e' tag referencing this event // We need to search for events that have an 'a' tag or 'e' tag referencing this event
const contentEventId = contentEvent.id; const contentEventId = contentEvent.id;

4
src/lib/utils/image_utils.ts

@ -18,7 +18,9 @@ export function generateDarkPastelColor(seed: string): string {
const g = Math.abs(hash >> 8) % 80 + 120; // 120-200 range const g = Math.abs(hash >> 8) % 80 + 120; // 120-200 range
const b = Math.abs(hash >> 16) % 80 + 120; // 120-200 range const b = Math.abs(hash >> 16) % 80 + 120; // 120-200 range
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; return `#${r.toString(16).padStart(2, "0")}${
g.toString(16).padStart(2, "0")
}${b.toString(16).padStart(2, "0")}`;
} }
/** /**

147
src/lib/utils/kind24_utils.ts

@ -0,0 +1,147 @@
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { createSignedEvent } from "./nostrEventService.ts";
import { anonymousRelays } from "../consts.ts";
import { buildCompleteRelaySet } from "./relay_management.ts";
// AI-NOTE: Using existing relay utilities from relay_management.ts instead of duplicating functionality
/**
* Gets optimal relay set for kind 24 messages between two users
* @param senderPubkey The sender's pubkey
* @param recipientPubkey The recipient's pubkey
* @returns Promise resolving to relay URLs prioritized by commonality
*/
export async function getKind24RelaySet(
senderPubkey: string,
recipientPubkey: string,
ndk: NDK,
): Promise<string[]> {
const senderPrefix = senderPubkey.slice(0, 8);
const recipientPrefix = recipientPubkey.slice(0, 8);
console.log(
`[getKind24RelaySet] Getting relays for ${senderPrefix} -> ${recipientPrefix}`,
);
try {
// Fetch both users' complete relay sets using existing utilities
const [senderRelaySet, recipientRelaySet] = await Promise.all([
buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: senderPubkey })),
buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: recipientPubkey })),
]);
// Use sender's outbox relays and recipient's inbox relays
const senderOutboxRelays = senderRelaySet.outboxRelays;
const recipientInboxRelays = recipientRelaySet.inboxRelays;
// Prioritize common relays for better privacy
const commonRelays = senderOutboxRelays.filter((relay: any) =>
recipientInboxRelays.includes(relay)
);
const senderOnlyRelays = senderOutboxRelays.filter((relay: any) =>
!recipientInboxRelays.includes(relay)
);
const recipientOnlyRelays = recipientInboxRelays.filter((relay: any) =>
!senderOutboxRelays.includes(relay)
);
// Prioritize: common relays first, then sender outbox, then recipient inbox
const finalRelays = [
...commonRelays,
...senderOnlyRelays,
...recipientOnlyRelays,
];
console.log(
`[getKind24RelaySet] ${senderPrefix}->${recipientPrefix} - Common: ${commonRelays.length}, Sender-only: ${senderOnlyRelays.length}, Recipient-only: ${recipientOnlyRelays.length}, Total: ${finalRelays.length}`,
);
return finalRelays;
} catch (error) {
console.error(
`[getKind24RelaySet] Error getting relay set for ${senderPrefix}->${recipientPrefix}:`,
error,
);
throw error;
}
}
/**
* Creates a kind 24 public message reply according to NIP-A4
* @param content The message content
* @param recipientPubkey The recipient's pubkey
* @param originalEvent The original event being replied to (optional)
* @returns Promise resolving to publish result with relay information
*/
export async function createKind24Reply(
content: string,
recipientPubkey: string,
ndk: NDK,
originalEvent?: NDKEvent,
): Promise<
{ success: boolean; eventId?: string; error?: string; relays?: string[] }
> {
if (!ndk?.activeUser) {
return { success: false, error: "Not logged in" };
}
if (!content.trim()) {
return { success: false, error: "Message content cannot be empty" };
}
try {
// Get optimal relay set for this sender-recipient pair
const targetRelays = await getKind24RelaySet(
ndk.activeUser.pubkey,
recipientPubkey,
ndk,
);
if (targetRelays.length === 0) {
return { success: false, error: "No relays available for publishing" };
}
// Build tags for the kind 24 event
const tags: string[][] = [
["p", recipientPubkey, targetRelays[0]], // Use first relay as primary
];
// Add q tag if replying to an original event
if (originalEvent) {
tags.push(["q", originalEvent.id, targetRelays[0] || anonymousRelays[0]]);
}
// Create and sign the event
const { event: signedEventData } = await createSignedEvent(
content,
ndk.activeUser.pubkey,
24,
tags,
);
// Create NDKEvent and publish
const event = new NDKEvent(ndk, signedEventData);
const relaySet = NDKRelaySet.fromRelayUrls(targetRelays, ndk);
const publishedToRelays = await event.publish(relaySet);
if (publishedToRelays.size > 0) {
console.log(
`[createKind24Reply] Successfully published to ${publishedToRelays.size} relays`,
);
return { success: true, eventId: event.id, relays: targetRelays };
} else {
console.warn(`[createKind24Reply] Failed to publish to any relays`);
return {
success: false,
error: "Failed to publish to any relays",
relays: targetRelays,
};
}
} catch (error) {
console.error("[createKind24Reply] Error creating kind 24 reply:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

58
src/lib/utils/markup/MarkupInfo.md

@ -1,10 +1,14 @@
# Markup Support in Alexandria # Markup Support in Alexandria
Alexandria supports multiple markup formats for different use cases. Below is a summary of the supported tags and features for each parser, as well as the formats used for publications and wikis. Alexandria supports multiple markup formats for different use cases. Below is a
summary of the supported tags and features for each parser, as well as the
formats used for publications and wikis.
## Basic Markup Parser ## Basic Markup Parser
The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports: The **basic markup parser** follows the
[Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146)
and supports:
- **Headers:** - **Headers:**
- ATX-style: `# H1` through `###### H6` - ATX-style: `# H1` through `###### H6`
@ -18,7 +22,8 @@ The **basic markup parser** follows the [Nostr best-practice guidelines](https:/
- **Links:** `[text](url)` - **Links:** `[text](url)`
- **Images:** `![alt](url)` - **Images:** `![alt](url)`
- **Hashtags:** `#hashtag` - **Hashtags:** `#hashtag`
- **Nostr identifiers:** npub, nprofile, nevent, naddr, note, with or without `nostr:` prefix (note is deprecated) - **Nostr identifiers:** npub, nprofile, nevent, naddr, note, with or without
`nostr:` prefix (note is deprecated)
- **Emoji shortcodes:** `:smile:` will render as 😄 - **Emoji shortcodes:** `:smile:` will render as 😄
## Advanced Markup Parser ## Advanced Markup Parser
@ -26,17 +31,25 @@ The **basic markup parser** follows the [Nostr best-practice guidelines](https:/
The **advanced markup parser** includes all features of the basic parser, plus: The **advanced markup parser** includes all features of the basic parser, plus:
- **Inline code:** `` `code` `` - **Inline code:** `` `code` ``
- **Syntax highlighting:** for code blocks in many programming languages (from [highlight.js](https://highlightjs.org/)) - **Syntax highlighting:** for code blocks in many programming languages (from
[highlight.js](https://highlightjs.org/))
- **Tables:** Pipe-delimited tables with or without headers - **Tables:** Pipe-delimited tables with or without headers
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers - **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended shall be placed, and will be displayed as unique, consecutive numbers
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](./events?d=nip-54) - **Footnote References:** `[^1]: footnote text` or
`[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in
order, at the bottom of the event, with back-reference links to the footnote,
and text footnote labels appended
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to
[NIP-54](./events?d=nip-54)
## Publications and Wikis ## Publications and Wikis
**Publications** and **wikis** in Alexandria use **AsciiDoc** as their primary markup language, not Markdown. **Publications** and **wikis** in Alexandria use **AsciiDoc** as their primary
markup language, not Markdown.
AsciiDoc supports a much broader set of formatting, semantic, and structural features, including: AsciiDoc supports a much broader set of formatting, semantic, and structural
features, including:
- Section and document structure - Section and document structure
- Advanced tables, callouts, admonitions - Advanced tables, callouts, admonitions
@ -48,7 +61,8 @@ AsciiDoc supports a much broader set of formatting, semantic, and structural fea
### Advanced Content Types ### Advanced Content Types
Alexandria supports rendering of advanced content types commonly used in academic, technical, and business documents: Alexandria supports rendering of advanced content types commonly used in
academic, technical, and business documents:
#### Math Rendering #### Math Rendering
@ -113,18 +127,26 @@ TikZ diagrams for mathematical illustrations:
### Rendering Features ### Rendering Features
- **Automatic Detection**: Content types are automatically detected based on syntax - **Automatic Detection**: Content types are automatically detected based on
- **Fallback Display**: If rendering fails, the original source code is displayed syntax
- **Fallback Display**: If rendering fails, the original source code is
displayed
- **Source Code**: Click "Show source" to view the original code - **Source Code**: Click "Show source" to view the original code
- **Responsive Design**: All rendered content is responsive and works on mobile devices - **Responsive Design**: All rendered content is responsive and works on mobile
devices
For more information on AsciiDoc, see the [AsciiDoc documentation](https://asciidoc.org/). For more information on AsciiDoc, see the
[AsciiDoc documentation](https://asciidoc.org/).
--- ---
**Note:** **Note:**
- The markdown parsers are primarily used for comments, issues, and other user-generated content. - The markdown parsers are primarily used for comments, issues, and other
- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility. user-generated content.
- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format. - Publications and wikis are rendered using AsciiDoc for maximum expressiveness
- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted. and compatibility.
- All URLs are sanitized to remove tracking parameters, and YouTube links are
presented in a clean, privacy-friendly format.
- [Here is a test markup file](/tests/integration/markupTestfile.md) that you
can use to test out the parser and see how things should be formatted.

3
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -188,7 +188,8 @@ function processPlantUMLBlocks(html: string): string {
try { try {
const rawContent = decodeHTMLEntities(content); const rawContent = decodeHTMLEntities(content);
const encoded = plantumlEncoder.encode(rawContent); const encoded = plantumlEncoder.encode(rawContent);
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`; const plantUMLUrl =
`https://www.plantuml.com/plantuml/svg/${encoded}`;
return `<div class="plantuml-block my-4"> return `<div class="plantuml-block my-4">
<img src="${plantUMLUrl}" alt="PlantUML diagram" <img src="${plantUMLUrl}" alt="PlantUML diagram"
class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg" class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg"

381
src/lib/utils/markup/advancedMarkupParser.ts

@ -10,8 +10,9 @@ hljs.configure({
// Escapes HTML characters for safe display // Escapes HTML characters for safe display
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const div = const div = typeof document !== "undefined"
typeof document !== "undefined" ? document.createElement("div") : null; ? document.createElement("div")
: null;
if (div) { if (div) {
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
@ -29,6 +30,7 @@ function escapeHtml(text: string): string {
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm; const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g; const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
const MULTILINE_CODE_REGEX = /`([\s\S]*?)`/g;
const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm; const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm;
const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
@ -100,8 +102,8 @@ function processTables(content: string): string {
}; };
// Check if second row is a delimiter row (only hyphens) // Check if second row is a delimiter row (only hyphens)
const hasHeader = const hasHeader = rows.length > 1 &&
rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows // Extract header and body rows
let headerCells: string[] = []; let headerCells: string[] = [];
@ -124,7 +126,8 @@ function processTables(content: string): string {
if (hasHeader) { if (hasHeader) {
html += "<thead>\n<tr>\n"; html += "<thead>\n<tr>\n";
headerCells.forEach((cell) => { headerCells.forEach((cell) => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`; html +=
`<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
}); });
html += "</tr>\n</thead>\n"; html += "</tr>\n</thead>\n";
} }
@ -135,7 +138,8 @@ function processTables(content: string): string {
const cells = processCells(row); const cells = processCells(row);
html += "<tr>\n"; html += "<tr>\n";
cells.forEach((cell) => { cells.forEach((cell) => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`; html +=
`<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
}); });
html += "</tr>\n"; html += "</tr>\n";
}); });
@ -197,7 +201,9 @@ function processFootnotes(content: string): string {
if (!referenceMap.has(id)) referenceMap.set(id, []); if (!referenceMap.has(id)) referenceMap.set(id, []);
referenceMap.get(id)!.push(refNum); referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id }); referenceOrder.push({ id, refNum, label: id });
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`; return `<sup><a href="#fn-${id}" id="fnref-${id}-${
referenceMap.get(id)!.length
}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
}, },
); );
@ -216,12 +222,15 @@ function processFootnotes(content: string): string {
const backrefs = refs const backrefs = refs
.map( .map(
(num, i) => (num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`, `<a href=\"#fnref-${id}-${
i + 1
}\" class=\"text-primary-600 hover:underline footnote-backref\">${num}</a>`,
) )
.join(" "); .join(" ");
// If label is not a number, show it after all backrefs // If label is not a number, show it after all backrefs
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : ""; const labelSuffix = isNaN(Number(label)) ? ` ${label}` : "";
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`; processedContent +=
`<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`;
} }
processedContent += "</ol>"; processedContent += "</ol>";
} }
@ -233,25 +242,6 @@ function processFootnotes(content: string): string {
} }
} }
/**
* Process blockquotes
*/
function processBlockquotes(content: string): string {
// Match blockquotes that might span multiple lines
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm;
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
.split("\n")
.map((line) => line.replace(/^>[ \t]?/, ""))
.join("\n")
.trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
});
}
/** /**
* Process code blocks by finding consecutive code lines and preserving their content * Process code blocks by finding consecutive code lines and preserving their content
*/ */
@ -374,13 +364,17 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
language, language,
ignoreIllegals: true, ignoreIllegals: true,
}).value; }).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`; html =
`<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) { } catch (e: unknown) {
console.warn("Failed to highlight code block:", e); console.warn("Failed to highlight code block:", e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ""}">${code}</code></pre>`; html = `<pre class="code-block"><code class="hljs ${
language ? `language-${language}` : ""
}">${code}</code></pre>`;
} }
} else { } else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`; html =
`<pre class="code-block"><code class="hljs">${code}</code></pre>`;
} }
result = result.replace(id, html); result = result.replace(id, html);
@ -397,296 +391,41 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
} }
/** /**
* Process $...$ and $$...$$ math blocks: render as LaTeX if recognized, otherwise as AsciiMath * Process math expressions inside inline code blocks
* This must run BEFORE any paragraph or inline code formatting. * Only processes math that is inside backticks and contains $...$ or $$...$$ markings
*/ */
function processDollarMath(content: string): string { function processInlineCodeMath(content: string): string {
// Display math: $$...$$ (multi-line, not empty) return content.replace(MULTILINE_CODE_REGEX, (match, codeContent) => {
content = content.replace(/\$\$([\s\S]*?\S[\s\S]*?)\$\$/g, (_match, expr) => { // Check if the code content contains math expressions
if (isLaTeXContent(expr)) { const hasInlineMath = /\$((?:[^$\\]|\\.)*?)\$/.test(codeContent);
return `<div class="math-block">$$${expr}$$</div>`; const hasDisplayMath = /\$\$[\s\S]*?\$\$/.test(codeContent);
} else {
// Strip all $ or $$ from AsciiMath if (!hasInlineMath && !hasDisplayMath) {
const clean = expr.replace(/\$+/g, "").trim(); // No math found, return the original inline code
return `<div class="math-block" data-math-type="asciimath">${clean}</div>`; return match;
}
});
// Inline math: $...$ (not empty, not just whitespace)
content = content.replace(/\$([^\s$][^$\n]*?)\$/g, (_match, expr) => {
if (isLaTeXContent(expr)) {
return `<span class="math-inline">$${expr}$</span>`;
} else {
const clean = expr.replace(/\$+/g, "").trim();
return `<span class="math-inline" data-math-type="asciimath">${clean}</span>`;
}
});
return content;
} }
/** // Process display math ($$...$$) first to avoid conflicts with inline math
* Process LaTeX math expressions only within inline code blocks let processedContent = codeContent.replace(/\$\$([\s\S]*?)\$\$/g, (mathMatch: string, mathContent: string) => {
*/ // Skip empty math expressions
function processMathExpressions(content: string): string { if (!mathContent.trim()) {
// Only process LaTeX within inline code blocks (backticks) return mathMatch;
return content.replace(INLINE_CODE_REGEX, (_match, code) => {
const trimmedCode = code.trim();
// Check for unsupported LaTeX environments (like tabular) first
if (/\\begin\{tabular\}|\\\\begin\{tabular\}/.test(trimmedCode)) {
return `<div class="unrendered-latex">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
Unrendered, as it is LaTeX typesetting, not a formula:
</p>
<pre class="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto">
<code>${escapeHtml(trimmedCode)}</code>
</pre>
</div>`;
}
// Check if the code contains LaTeX syntax
if (isLaTeXContent(trimmedCode)) {
// Detect LaTeX display math (\\[...\\])
if (/^\\\[[\s\S]*\\\]$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\\\[|\\\]$/g, "");
return `<div class="math-block">$$${inner}$$</div>`;
}
// Detect display math ($$...$$)
if (/^\$\$[\s\S]*\$\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$\$|\$\$$/g, "");
return `<div class="math-block">$$${inner}$$</div>`;
}
// Detect inline math ($...$)
if (/^\$[\s\S]*\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$|\$$/g, "");
return `<span class="math-inline">$${inner}$</span>`;
}
// Default to inline math for any other LaTeX content
return `<span class="math-inline">$${trimmedCode}$</span>`;
} else {
// Check for edge cases that should remain as code, not math
// These patterns indicate code that contains dollar signs but is not math
const codePatterns = [
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=/, // Variable assignment like "const price ="
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/, // Function call like "echo("
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\{/, // Object literal like "const obj = {"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\[/, // Array literal like "const arr = ["
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*</, // JSX or HTML like "const element = <"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*`/, // Template literal like "const str = `"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*'/, // String literal like "const str = '"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*"/, // String literal like "const str = \""
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*;/, // Statement ending like "const x = 1;"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*$/, // Just a variable name
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Operator like "const x = 1 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Two identifiers like "const price = amount"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]/, // Number like "const x = 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Complex expression like "const price = amount +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Three identifiers like "const price = amount + tax"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]/, // Two identifiers and number like "const price = amount + 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Identifier, number, operator like "const x = 1 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Identifier, number, identifier like "const x = 1 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[0-9]/, // Identifier, number, number like "const x = 1 + 2"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Complex like "const x = 1 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Complex like "const x = 1 + 2"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Very complex like "const x = 1 + y +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Very complex like "const x = 1 + y + z"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Very complex like "const x = 1 + y + 2"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Very complex like "const x = 1 + 2 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Very complex like "const x = 1 + 2 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Very complex like "const x = 1 + 2 + 3"
// Additional patterns for JavaScript template literals and other code
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*`/, // Template literal assignment like "const str = `"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*'/, // String assignment like "const str = '"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*"/, // String assignment like "const str = \""
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]/, // Number assignment like "const x = 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Variable assignment like "const x = y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[+\-*/%=<>!&|^~]/, // Assignment with operator like "const x = +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Assignment with variable and operator like "const x = y +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Assignment with two variables and operator like "const x = y + z"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Assignment with number and operator like "const x = 1 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Assignment with number, operator, variable like "const x = 1 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Assignment with variable, operator, number like "const x = y + 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Assignment with number, operator, number like "const x = 1 + 2"
];
// If it matches code patterns, treat as regular code
if (codePatterns.some((pattern) => pattern.test(trimmedCode))) {
const escapedCode = trimmedCode
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
} }
return `<span class="math-display">\\[${mathContent}\\]</span>`;
});
// Return as regular inline code // Process inline math ($...$) after display math
const escapedCode = trimmedCode // Use a more sophisticated regex that handles escaped dollar signs
.replace(/&/g, "&amp;") processedContent = processedContent.replace(/\$((?:[^$\\]|\\.)*?)\$/g, (mathMatch: string, mathContent: string) => {
.replace(/</g, "&lt;") // Skip empty math expressions
.replace(/>/g, "&gt;") if (!mathContent.trim()) {
.replace(/"/g, "&quot;") return mathMatch;
.replace(/'/g, "&#039;");
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
} }
return `<span class="math-inline">\\(${mathContent}\\)</span>`;
}); });
}
/** return `\`${processedContent}\``;
* Checks if content contains LaTeX syntax });
*/
function isLaTeXContent(content: string): boolean {
const trimmed = content.trim();
// Check for simple math expressions first (like AsciiMath)
if (/^\$[^$]+\$$/.test(trimmed)) {
return true;
}
// Check for display math
if (/^\$\$[\s\S]*\$\$$/.test(trimmed)) {
return true;
}
// Check for LaTeX display math
if (/^\\\[[\s\S]*\\\]$/.test(trimmed)) {
return true;
}
// Check for LaTeX environments with double backslashes (like tabular)
if (/\\\\begin\{[^}]+\}/.test(trimmed) || /\\\\end\{[^}]+\}/.test(trimmed)) {
return true;
}
// Check for common LaTeX patterns
const latexPatterns = [
/\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
/\\\\[a-zA-Z]+/, // LaTeX commands with double backslashes like \\frac, \\sum, etc.
/\\[\(\)\[\]]/, // LaTeX delimiters like \(, \), \[, \]
/\\\\[\(\)\[\]]/, // LaTeX delimiters with double backslashes like \\(, \\), \\[, \\]
/\\\[[\s\S]*?\\\]/, // LaTeX display math \[ ... \]
/\\\\\[[\s\S]*?\\\\\]/, // LaTeX display math with double backslashes \\[ ... \\]
/\\begin\{/, // LaTeX environments
/\\\\begin\{/, // LaTeX environments with double backslashes
/\\end\{/, // LaTeX environments
/\\\\end\{/, // LaTeX environments with double backslashes
/\\begin\{array\}/, // LaTeX array environment
/\\\\begin\{array\}/, // LaTeX array environment with double backslashes
/\\end\{array\}/,
/\\\\end\{array\}/,
/\\begin\{matrix\}/, // LaTeX matrix environment
/\\\\begin\{matrix\}/, // LaTeX matrix environment with double backslashes
/\\end\{matrix\}/,
/\\\\end\{matrix\}/,
/\\begin\{bmatrix\}/, // LaTeX bmatrix environment
/\\\\begin\{bmatrix\}/, // LaTeX bmatrix environment with double backslashes
/\\end\{bmatrix\}/,
/\\\\end\{bmatrix\}/,
/\\begin\{pmatrix\}/, // LaTeX pmatrix environment
/\\\\begin\{pmatrix\}/, // LaTeX pmatrix environment with double backslashes
/\\end\{pmatrix\}/,
/\\\\end\{pmatrix\}/,
/\\begin\{tabular\}/, // LaTeX tabular environment
/\\\\begin\{tabular\}/, // LaTeX tabular environment with double backslashes
/\\end\{tabular\}/,
/\\\\end\{tabular\}/,
/\$\$/, // Display math delimiters
/\$[^$]+\$/, // Inline math delimiters
/\\text\{/, // LaTeX text command
/\\\\text\{/, // LaTeX text command with double backslashes
/\\mathrm\{/, // LaTeX mathrm command
/\\\\mathrm\{/, // LaTeX mathrm command with double backslashes
/\\mathbf\{/, // LaTeX bold command
/\\\\mathbf\{/, // LaTeX bold command with double backslashes
/\\mathit\{/, // LaTeX italic command
/\\\\mathit\{/, // LaTeX italic command with double backslashes
/\\sqrt/, // Square root
/\\\\sqrt/, // Square root with double backslashes
/\\frac/, // Fraction
/\\\\frac/, // Fraction with double backslashes
/\\sum/, // Sum
/\\\\sum/, // Sum with double backslashes
/\\int/, // Integral
/\\\\int/, // Integral with double backslashes
/\\lim/, // Limit
/\\\\lim/, // Limit with double backslashes
/\\infty/, // Infinity
/\\\\infty/, // Infinity with double backslashes
/\\alpha/, // Greek letters
/\\\\alpha/, // Greek letters with double backslashes
/\\beta/,
/\\\\beta/,
/\\gamma/,
/\\\\gamma/,
/\\delta/,
/\\\\delta/,
/\\theta/,
/\\\\theta/,
/\\lambda/,
/\\\\lambda/,
/\\mu/,
/\\\\mu/,
/\\pi/,
/\\\\pi/,
/\\sigma/,
/\\\\sigma/,
/\\phi/,
/\\\\phi/,
/\\omega/,
/\\\\omega/,
/\\partial/, // Partial derivative
/\\\\partial/, // Partial derivative with double backslashes
/\\nabla/, // Nabla
/\\\\nabla/, // Nabla with double backslashes
/\\cdot/, // Dot product
/\\\\cdot/, // Dot product with double backslashes
/\\times/, // Times
/\\\\times/, // Times with double backslashes
/\\div/, // Division
/\\\\div/, // Division with double backslashes
/\\pm/, // Plus-minus
/\\\\pm/, // Plus-minus with double backslashes
/\\mp/, // Minus-plus
/\\\\mp/, // Minus-plus with double backslashes
/\\leq/, // Less than or equal
/\\\\leq/, // Less than or equal with double backslashes
/\\geq/, // Greater than or equal
/\\\\geq/, // Greater than or equal with double backslashes
/\\neq/, // Not equal
/\\\\neq/, // Not equal with double backslashes
/\\approx/, // Approximately equal
/\\\\approx/, // Approximately equal with double backslashes
/\\equiv/, // Equivalent
/\\\\equiv/, // Equivalent with double backslashes
/\\propto/, // Proportional
/\\\\propto/, // Proportional with double backslashes
/\\in/, // Element of
/\\\\in/, // Element of with double backslashes
/\\notin/, // Not element of
/\\\\notin/, // Not element of with double backslashes
/\\subset/, // Subset
/\\\\subset/, // Subset with double backslashes
/\\supset/, // Superset
/\\\\supset/, // Superset with double backslashes
/\\cup/, // Union
/\\\\cup/, // Union with double backslashes
/\\cap/, // Intersection
/\\\\cap/, // Intersection with double backslashes
/\\emptyset/, // Empty set
/\\\\emptyset/, // Empty set with double backslashes
/\\mathbb\{/, // Blackboard bold
/\\\\mathbb\{/, // Blackboard bold with double backslashes
/\\mathcal\{/, // Calligraphic
/\\\\mathcal\{/, // Calligraphic with double backslashes
/\\mathfrak\{/, // Fraktur
/\\\\mathfrak\{/, // Fraktur with double backslashes
/\\mathscr\{/, // Script
/\\\\mathscr\{/, // Script with double backslashes
];
return latexPatterns.some((pattern) => pattern.test(trimmed));
} }
/** /**
@ -700,15 +439,13 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
const { text: withoutCode, blocks } = processCodeBlocks(text); const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode; let processedText = withoutCode;
// Step 2: Process $...$ and $$...$$ math blocks (LaTeX or AsciiMath) // Step 2: Process math inside inline code blocks
processedText = processDollarMath(processedText); processedText = processInlineCodeMath(processedText);
// Step 3: Process LaTeX math expressions ONLY within inline code blocks (legacy support)
processedText = processMathExpressions(processedText);
// Step 4: Process block-level elements (tables, blockquotes, headings, horizontal rules) // Step 4: Process block-level elements (tables, headings, horizontal rules)
// AI-NOTE: 2025-01-24 - Removed duplicate processBlockquotes call to fix image rendering issues
// Blockquotes are now processed only by parseBasicmarkup to avoid double-processing conflicts
processedText = processTables(processedText); processedText = processTables(processedText);
processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText); processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText); processedText = processHorizontalRules(processedText);
@ -725,6 +462,8 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {
console.error("Error in parseAdvancedmarkup:", e); console.error("Error in parseAdvancedmarkup:", e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`; return `<div class="text-red-500">Error processing markup: ${
(e as Error)?.message ?? "Unknown error"
}</div>`;
} }
} }

90
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -1,48 +1,9 @@
import { processNostrIdentifiers } from "../nostrUtils"; import {
processAsciiDocAnchors,
/** processImageWithReveal,
* Normalizes a string for use as a d-tag by converting to lowercase, processNostrIdentifiersInText,
* replacing non-alphanumeric characters with dashes, and removing processWikilinks,
* leading/trailing dashes. } from "./markupServices.ts";
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Replaces wikilinks in the format [[target]] or [[target|display]] with
* clickable links to the events page.
*/
function replaceWikilinks(html: string): string {
// [[target page]] or [[target page|display text]]
return html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
// Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${display}</a>`;
},
);
}
/**
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/
function replaceAsciiDocAnchors(html: string): string {
return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `/events?d=${normalized}`;
// Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${id}</a>`;
});
}
/** /**
* Processes nostr addresses in HTML content, but skips addresses that are * Processes nostr addresses in HTML content, but skips addresses that are
@ -80,11 +41,10 @@ async function processNostrAddresses(html: string): Promise<string> {
} }
// Process the nostr identifier // Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch); const processedMatch = await processNostrIdentifiersInText(fullMatch);
// Replace the match in the HTML // Replace the match in the HTML
processedHtml = processedHtml = processedHtml.slice(0, matchIndex) +
processedHtml.slice(0, matchIndex) +
processedMatch + processedMatch +
processedHtml.slice(matchIndex + fullMatch.length); processedHtml.slice(matchIndex + fullMatch.length);
} }
@ -92,6 +52,32 @@ async function processNostrAddresses(html: string): Promise<string> {
return processedHtml; return processedHtml;
} }
/**
* Processes AsciiDoc image blocks to add reveal/enlarge functionality
*/
function processImageBlocks(html: string): string {
// Process image blocks with reveal functionality
return html.replace(
/<div class="imageblock">\s*<div class="content">\s*<img([^>]+)>\s*<\/div>\s*(?:<div class="title">([^<]+)<\/div>)?\s*<\/div>/g,
(match, imgAttributes, title) => {
// Extract src and alt from img attributes
const srcMatch = imgAttributes.match(/src="([^"]+)"/);
const altMatch = imgAttributes.match(/alt="([^"]*)"/);
const src = srcMatch ? srcMatch[1] : "";
const alt = altMatch ? altMatch[1] : "";
const titleHtml = title ? `<div class="title">${title}</div>` : "";
return `<div class="imageblock">
<div class="content">
${processImageWithReveal(src, alt)}
</div>
${titleHtml}
</div>`;
},
);
}
/** /**
* Fixes AsciiDoctor stem blocks for MathJax rendering. * Fixes AsciiDoctor stem blocks for MathJax rendering.
* Joins split spans and wraps content in $$...$$ for block math. * Joins split spans and wraps content in $$...$$ for block math.
@ -120,12 +106,14 @@ export async function postProcessAsciidoctorHtml(
try { try {
// First process AsciiDoctor-generated anchors // First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html); let processedHtml = processAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain) // Then process wikilinks in [[...]] format (if any remain)
processedHtml = replaceWikilinks(processedHtml); processedHtml = processWikilinks(processedHtml);
// Then process nostr addresses (but not those already in links) // Then process nostr addresses (but not those already in links)
processedHtml = await processNostrAddresses(processedHtml); processedHtml = await processNostrIdentifiersInText(processedHtml);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
// Process image blocks to add reveal/enlarge functionality
processedHtml = processImageBlocks(processedHtml);
return processedHtml; return processedHtml;
} catch (error) { } catch (error) {

259
src/lib/utils/markup/basicMarkupParser.ts

@ -1,30 +1,24 @@
import { processNostrIdentifiers } from "../nostrUtils.ts";
import * as emoji from "node-emoji";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import {
processBasicTextFormatting,
processBlockquotes,
processEmojiShortcodes,
processHashtags,
processImageWithReveal,
processMediaUrl,
processNostrIdentifiersInText,
processWebSocketUrls,
processWikilinks,
stripTrackingParams,
} from "./markupServices.ts";
/* Regex constants for basic markup parsing */ /* Regex constants for basic markup parsing */
// Text formatting
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g;
const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g;
const HASHTAG_REGEX = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
// Block elements
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
// Links and media // Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
const WSS_URL = /wss:\/\/[^\s<>"]+/g; // AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g; const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX =
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
// Add this helper function near the top: // Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string { function replaceAlexandriaNostrLinks(text: string): string {
@ -78,96 +72,13 @@ function replaceAlexandriaNostrLinks(text: string): string {
return `nostr:${bech32Match[0]}`; return `nostr:${bech32Match[0]}`;
} }
} }
// For non-Alexandria/localhost URLs, append (View here: nostr:<id>) if a Nostr identifier is present // For non-Alexandria/localhost URLs, just return the URL as-is
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `${url} (View here: nostr:${nevent})`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `${url} (View here: nostr:${bech32Match[0]})`;
}
return url; return url;
}); });
return text; return text;
} }
// Utility to strip tracking parameters from URLs
function stripTrackingParams(url: string): string {
// List of tracking params to remove
const trackingParams = [
/^utm_/i,
/^fbclid$/i,
/^gclid$/i,
/^tracking$/i,
/^ref$/i,
];
try {
// Absolute URL
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
const parsed = new URL(url);
trackingParams.forEach((pattern) => {
for (const key of Array.from(parsed.searchParams.keys())) {
if (pattern.test(key)) {
parsed.searchParams.delete(key);
}
}
});
const queryString = parsed.searchParams.toString();
return (
parsed.origin +
parsed.pathname +
(queryString ? "?" + queryString : "") +
(parsed.hash || "")
);
} else {
// Relative URL: parse query string manually
const [path, queryAndHash = ""] = url.split("?");
const [query = "", hash = ""] = queryAndHash.split("#");
if (!query) return url;
const params = query.split("&").filter(Boolean);
const filtered = params.filter((param) => {
const [key] = param.split("=");
return !trackingParams.some((pattern) => pattern.test(key));
});
const queryString = filtered.length ? "?" + filtered.join("&") : "";
const hashString = hash ? "#" + hash : "";
return path + queryString + hashString;
}
} catch {
return url;
}
}
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function replaceWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
// Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${display}</a>`;
},
);
}
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList( function parseList(
start: number, start: number,
@ -176,7 +87,9 @@ function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
): [string, number] { ): [string, number] {
let html = ""; let html = "";
let i = start; let i = start;
html += `<${type} class="${type === "ol" ? "list-decimal" : "list-disc"} ml-6 mb-2">`; html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
@ -237,84 +150,35 @@ function processBasicFormatting(content: string): string {
processedText = replaceAlexandriaNostrLinks(processedText); processedText = replaceAlexandriaNostrLinks(processedText);
// Process markup images first // Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (_match, alt, url) => { processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => {
url = stripTrackingParams(url); // Clean the URL and alt text
if (YOUTUBE_URL_REGEX.test(url)) { const cleanUrl = url.trim();
const videoId = extractYouTubeVideoId(url); const cleanAlt = alt ? alt.trim() : "";
if (videoId) { return processImageWithReveal(cleanUrl, cleanAlt);
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || "YouTube video"}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(url)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || "Video"}</video>`;
}
if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || "Audio"}</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split("?")[0])) {
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
// Otherwise, render as a clickable link
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`;
}); });
// Process markup links // Process markup links
processedText = processedText.replace( processedText = processedText.replace(
MARKUP_LINK, MARKUP_LINK,
(_match, text, url) => (_match, text, url) =>
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`, `<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
); );
// Process WebSocket URLs // Process WebSocket URLs using shared services
processedText = processedText.replace(WSS_URL, (match) => { processedText = processWebSocketUrls(processedText);
// Remove 'wss://' from the start and any trailing slashes
const cleanUrl = match.slice(6).replace(/\/+$/, "");
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
// Process direct media URLs and auto-link all URLs // Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => { processedText = processedText.replace(DIRECT_LINK, (match) => {
const clean = stripTrackingParams(match); return processMediaUrl(match);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">Your browser does not support the video tag.</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return `<img src="${clean}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
// Otherwise, render as a clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
}); });
// Process text formatting // Process text formatting using shared services
processedText = processedText.replace(BOLD_REGEX, "<strong>$2</strong>"); processedText = processBasicTextFormatting(processedText);
processedText = processedText.replace(ITALIC_REGEX, (match) => {
const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`;
});
processedText = processedText.replace(
STRIKETHROUGH_REGEX,
(_match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
},
);
// Process hashtags // Process hashtags using shared services
processedText = processedText.replace( processedText = processHashtags(processedText);
HASHTAG_REGEX,
'<span class="text-primary-600 dark:text-primary-500">#$1</span>',
);
// --- Improved List Grouping and Parsing --- // --- Improved List Grouping and Parsing ---
const lines = processedText.split("\n"); const lines = processedText.split("\n");
@ -351,47 +215,6 @@ function processBasicFormatting(content: string): string {
return processedText; return processedText;
} }
// Helper function to extract YouTube video ID
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
);
return match ? match[1] : null;
}
function processBlockquotes(content: string): string {
try {
if (!content) return "";
return content.replace(BLOCKQUOTE_REGEX, (match) => {
const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, "").trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${lines.join(
"\n",
)}</blockquote>`;
});
} catch (e: unknown) {
console.error("Error in processBlockquotes:", e);
return content;
}
}
function processEmojiShortcuts(content: string): string {
try {
return emoji.emojify(content, {
fallback: (name: string) => {
const emojiChar = emoji.get(name);
return emojiChar || `:${name}:`;
},
});
} catch (e: unknown) {
console.error("Error in processEmojiShortcuts:", e);
return content;
}
}
export async function parseBasicmarkup(text: string): Promise<string> { export async function parseBasicmarkup(text: string): Promise<string> {
if (!text) return ""; if (!text) return "";
@ -400,7 +223,7 @@ export async function parseBasicmarkup(text: string): Promise<string> {
let processedText = processBasicFormatting(text); let processedText = processBasicFormatting(text);
// Process emoji shortcuts // Process emoji shortcuts
processedText = processEmojiShortcuts(processedText); processedText = processEmojiShortcodes(processedText);
// Process blockquotes // Process blockquotes
processedText = processBlockquotes(processedText); processedText = processBlockquotes(processedText);
@ -412,9 +235,11 @@ export async function parseBasicmarkup(text: string): Promise<string> {
.map((para) => para.trim()) .map((para) => para.trim())
.filter((para) => para.length > 0) .filter((para) => para.length > 0)
.map((para) => { .map((para) => {
// Skip wrapping if para already contains block-level elements or math blocks // AI-NOTE: 2025-01-24 - Added img tag to skip wrapping to prevent image rendering issues
// Skip wrapping if para already contains block-level elements, math blocks, or images
if ( if (
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test( /(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i
.test(
para, para,
) )
) { ) {
@ -425,14 +250,16 @@ export async function parseBasicmarkup(text: string): Promise<string> {
.join("\n"); .join("\n");
// Process Nostr identifiers last // Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText); processedText = await processNostrIdentifiersInText(processedText);
// Replace wikilinks // Replace wikilinks
processedText = replaceWikilinks(processedText); processedText = processWikilinks(processedText);
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {
console.error("Error in parseBasicmarkup:", e); console.error("Error in parseBasicmarkup:", e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`; return `<div class="text-red-500">Error processing markup: ${
(e as Error)?.message ?? "Unknown error"
}</div>`;
} }
} }

277
src/lib/utils/markup/embeddedMarkupParser.ts

@ -0,0 +1,277 @@
import { nip19 } from "nostr-tools";
import {
processBasicTextFormatting,
processBlockquotes,
processEmojiShortcodes,
processHashtags,
processImageWithReveal,
processMediaUrl,
processNostrIdentifiersInText,
processNostrIdentifiersWithEmbeddedEvents,
processWebSocketUrls,
processWikilinks,
stripTrackingParams,
} from "./markupServices.ts";
/* Regex constants for basic markup parsing */
// Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs
const alexandriaPattern =
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return match;
},
);
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return url;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, just return the URL as-is
return url;
});
return text;
}
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList(
start: number,
indent: number,
type: "ol" | "ul",
): [string, number] {
let html = "";
let i = start;
html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break;
const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) {
// Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${match[3]}`;
// Check for next line being a nested list
if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml;
i = consumed - 1;
}
}
}
html += "</li>";
i++;
}
html += `</${type}>`;
return [html, i];
}
if (!lines.length) return "";
const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type);
return html;
}
function processBasicFormatting(content: string): string {
if (!content) return "";
let processedText = content;
try {
// Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText);
// Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => {
// Clean the URL and alt text
const cleanUrl = url.trim();
const cleanAlt = alt ? alt.trim() : "";
return processImageWithReveal(cleanUrl, cleanAlt);
});
// Process markup links
processedText = processedText.replace(
MARKUP_LINK,
(_match, text, url) =>
`<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
);
// Process WebSocket URLs using shared services
processedText = processWebSocketUrls(processedText);
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => {
return processMediaUrl(match);
});
// Process text formatting using shared services
processedText = processBasicTextFormatting(processedText);
// Process hashtags using shared services
processedText = processHashtags(processedText);
// --- Improved List Grouping and Parsing ---
const lines = processedText.split("\n");
let output = "";
let buffer: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) {
buffer.push(line);
inList = true;
} else {
if (inList) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = [];
inList = false;
}
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
}
}
if (buffer.length) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
}
processedText = output;
// --- End Improved List Grouping and Parsing ---
} catch (e: unknown) {
console.error("Error in processBasicFormatting:", e);
}
return processedText;
}
/**
* Parse markup with support for embedded Nostr events
* AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding
* Up to 3 levels of nesting are supported, after which events are shown as links
*/
export async function parseEmbeddedMarkup(
text: string,
nestingLevel: number = 0,
): Promise<string> {
if (!text) return "";
try {
// Process basic text formatting first
let processedText = processBasicFormatting(text);
// Process emoji shortcuts
processedText = processEmojiShortcodes(processedText);
// Process blockquotes
processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags
// Skip wrapping if content already contains block-level elements
const blockLevelEls =
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i;
processedText = processedText
.split(/\n\n+/)
.map((para) => para.trim())
.filter((para) => para.length > 0)
.map((para) => {
// Skip wrapping if para already contains block-level elements, math blocks, or images
if (blockLevelEls.test(para)) {
return para;
}
return `<p class="my-1">${para}</p>`;
})
.join("\n");
// Process profile identifiers (npub, nprofile) first using the regular processor
processedText = await processNostrIdentifiersInText(processedText);
// Then process event identifiers with embedded events (only event-related identifiers)
processedText = processNostrIdentifiersWithEmbeddedEvents(
processedText,
nestingLevel,
);
// Replace wikilinks
processedText = processWikilinks(processedText);
return processedText;
} catch (e: unknown) {
console.error("Error in parseEmbeddedMarkup:", e);
return `<div class="text-red-500">Error processing markup: ${
(e as Error)?.message ?? "Unknown error"
}</div>`;
}
}

321
src/lib/utils/markup/markupServices.ts

@ -0,0 +1,321 @@
import NDK from "@nostr-dev-kit/ndk";
import {
createProfileLink,
getUserMetadata,
NOSTR_PROFILE_REGEX,
} from "../nostrUtils.ts";
import * as emoji from "node-emoji";
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX =
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
/**
* Shared service for processing images with expand functionality
*/
export function processImageWithReveal(
src: string,
alt: string = "Image",
): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`;
}
return `<div class="relative inline-block w-[300px] h-48 my-2 group">
<img
src="${src}"
alt="${alt}"
class="w-full h-full object-contain rounded-lg shadow-lg"
loading="lazy"
decoding="async"
/>
<!-- Expand button -->
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 z-20"
onclick="window.open('${src}', '_blank')"
title="Open image in full size"
aria-label="Open image in full size">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
</div>`;
}
/**
* Shared service for processing media URLs
*/
export function processMediaUrl(url: string, alt?: string): string {
const clean = stripTrackingParams(url);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${
alt || "YouTube video"
}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${
alt || "Video"
}</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${
alt || "Audio"
}</audio>`;
}
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return processImageWithReveal(clean, alt || "Embedded media");
}
// Default to clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
}
/**
* Shared service for processing nostr identifiers
*/
export async function processNostrIdentifiersInText(
text: string,
ndk?: NDK,
): Promise<string> {
let processedText = text;
// Find all profile-related nostr addresses (only npub and nprofile)
const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if part of a URL
const before = processedText.slice(
Math.max(0, matchIndex - 12),
matchIndex,
);
if (/https?:\/\/$|www\.$/i.test(before)) {
continue;
}
// Process the nostr identifier directly
let identifier = fullMatch;
if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier;
}
// Get user metadata and create link
let metadata;
if (ndk) {
metadata = await getUserMetadata(identifier, ndk);
} else {
// Fallback when NDK is not available - just use the identifier
metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) };
}
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + link +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing nostr identifiers with embedded events
* Replaces nostr: links with embedded event placeholders
* Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile)
*/
export function processNostrIdentifiersWithEmbeddedEvents(
text: string,
nestingLevel: number = 0,
): string {
const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text;
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// Find all event-related nostr addresses
const matches = Array.from(processedText.matchAll(eventPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
let replacement: string;
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link
replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`;
} else {
// Create a placeholder for embedded event
const componentId = `embedded-event-${
Math.random().toString(36).substr(2, 9)
}`;
replacement =
`<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`;
}
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing emoji shortcodes
*/
export function processEmojiShortcodes(text: string): string {
return emoji.emojify(text);
}
/**
* Shared service for processing WebSocket URLs
*/
export function processWebSocketUrls(text: string): string {
const wssUrlRegex = /wss:\/\/[^\s<>"]+/g;
return text.replace(wssUrlRegex, (match) => {
const cleanUrl = match.slice(6).replace(/\/+$/, "");
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
}
/**
* Shared service for processing hashtags
*/
export function processHashtags(text: string): string {
const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
return text.replace(
hashtagRegex,
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>',
);
}
/**
* Shared service for processing basic text formatting
*/
export function processBasicTextFormatting(text: string): string {
// Bold: **text** or *text*
text = text.replace(
/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g,
"<strong>$2</strong>",
);
// Italic: _text_ or __text__
text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => {
const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`;
});
// Strikethrough: ~~text~~ or ~text~
text = text.replace(
/~~([^~\n]+)~~|~([^~\n]+)~/g,
(_match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
},
);
return text;
}
/**
* Shared service for processing blockquotes
*/
export function processBlockquotes(text: string): string {
const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
return text.replace(blockquoteRegex, (match) => {
const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, "").trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join("\n")
}</blockquote>`;
});
}
// Helper functions
export function stripTrackingParams(url: string): string {
try {
const urlObj = new URL(url);
// Remove common tracking parameters
const trackingParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
];
trackingParams.forEach((param) => urlObj.searchParams.delete(param));
return urlObj.toString();
} catch {
return url;
}
}
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
);
return match ? match[1] : null;
}
/**
* Normalizes a string for use as a d-tag by converting to lowercase,
* replacing non-alphanumeric characters with dashes, and removing
* leading/trailing dashes.
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Shared service for processing wikilinks in the format [[target]] or [[target|display]]
*/
export function processWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Shared service for processing AsciiDoc anchor tags
*/
export function processAsciiDocAnchors(text: string): string {
return text.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}

4
src/lib/utils/markup/tikzRenderer.ts

@ -44,7 +44,9 @@ function createBasicSVG(tikzCode: string): string {
</text> </text>
<foreignObject x="10" y="60" width="${width - 20}" height="${height - 70}"> <foreignObject x="10" y="60" width="${width - 20}" height="${height - 70}">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-family: monospace; font-size: 10px; color: #666; overflow: hidden;"> <div xmlns="http://www.w3.org/1999/xhtml" style="font-family: monospace; font-size: 10px; color: #666; overflow: hidden;">
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(tikzCode)}</pre> <pre style="margin: 0; white-space: pre-wrap; word-break: break-all;">${
escapeHtml(tikzCode)
}</pre>
</div> </div>
</foreignObject> </foreignObject>
</svg>`; </svg>`;

86
src/lib/utils/network_detection.ts

@ -4,18 +4,18 @@ import { deduplicateRelayUrls } from "./relay_management.ts";
* Network conditions for relay selection * Network conditions for relay selection
*/ */
export enum NetworkCondition { export enum NetworkCondition {
ONLINE = 'online', ONLINE = "online",
SLOW = 'slow', SLOW = "slow",
OFFLINE = 'offline' OFFLINE = "offline",
} }
/** /**
* Network connectivity test endpoints * Network connectivity test endpoints
*/ */
const NETWORK_ENDPOINTS = [ const NETWORK_ENDPOINTS = [
'https://www.google.com/favicon.ico', "https://www.google.com/favicon.ico",
'https://httpbin.org/status/200', "https://httpbin.org/status/200",
'https://api.github.com/zen' "https://api.github.com/zen",
]; ];
/** /**
@ -27,20 +27,23 @@ export async function isNetworkOnline(): Promise<boolean> {
try { try {
// Use a simple fetch without HEAD method to avoid CORS issues // Use a simple fetch without HEAD method to avoid CORS issues
await fetch(endpoint, { await fetch(endpoint, {
method: 'GET', method: "GET",
cache: 'no-cache', cache: "no-cache",
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
mode: 'no-cors' // Use no-cors mode to avoid CORS issues mode: "no-cors", // Use no-cors mode to avoid CORS issues
}); });
// With no-cors mode, we can't check response.ok, so we assume success if no error // With no-cors mode, we can't check response.ok, so we assume success if no error
return true; return true;
} catch (error) { } catch (error) {
console.debug(`[network_detection.ts] Failed to reach ${endpoint}:`, error); console.debug(
`[network_detection.ts] Failed to reach ${endpoint}:`,
error,
);
continue; continue;
} }
} }
console.debug('[network_detection.ts] All network endpoints failed'); console.debug("[network_detection.ts] All network endpoints failed");
return false; return false;
} }
@ -54,21 +57,26 @@ export async function testNetworkSpeed(): Promise<number> {
for (const endpoint of NETWORK_ENDPOINTS) { for (const endpoint of NETWORK_ENDPOINTS) {
try { try {
await fetch(endpoint, { await fetch(endpoint, {
method: 'GET', method: "GET",
cache: 'no-cache', cache: "no-cache",
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
mode: 'no-cors' // Use no-cors mode to avoid CORS issues mode: "no-cors", // Use no-cors mode to avoid CORS issues
}); });
const endTime = performance.now(); const endTime = performance.now();
return endTime - startTime; return endTime - startTime;
} catch (error) { } catch (error) {
console.debug(`[network_detection.ts] Speed test failed for ${endpoint}:`, error); console.debug(
`[network_detection.ts] Speed test failed for ${endpoint}:`,
error,
);
continue; continue;
} }
} }
console.debug('[network_detection.ts] Network speed test failed for all endpoints'); console.debug(
"[network_detection.ts] Network speed test failed for all endpoints",
);
return Infinity; // Very slow if it fails return Infinity; // Very slow if it fails
} }
@ -80,7 +88,7 @@ export async function detectNetworkCondition(): Promise<NetworkCondition> {
const isOnline = await isNetworkOnline(); const isOnline = await isNetworkOnline();
if (!isOnline) { if (!isOnline) {
console.debug('[network_detection.ts] Network condition: OFFLINE'); console.debug("[network_detection.ts] Network condition: OFFLINE");
return NetworkCondition.OFFLINE; return NetworkCondition.OFFLINE;
} }
@ -88,11 +96,15 @@ export async function detectNetworkCondition(): Promise<NetworkCondition> {
// Consider network slow if response time > 2000ms // Consider network slow if response time > 2000ms
if (speed > 2000) { if (speed > 2000) {
console.debug(`[network_detection.ts] Network condition: SLOW (${speed.toFixed(0)}ms)`); console.debug(
`[network_detection.ts] Network condition: SLOW (${speed.toFixed(0)}ms)`,
);
return NetworkCondition.SLOW; return NetworkCondition.SLOW;
} }
console.debug(`[network_detection.ts] Network condition: ONLINE (${speed.toFixed(0)}ms)`); console.debug(
`[network_detection.ts] Network condition: ONLINE (${speed.toFixed(0)}ms)`,
);
return NetworkCondition.ONLINE; return NetworkCondition.ONLINE;
} }
@ -108,39 +120,49 @@ export function getRelaySetForNetworkCondition(
networkCondition: NetworkCondition, networkCondition: NetworkCondition,
discoveredLocalRelays: string[], discoveredLocalRelays: string[],
lowbandwidthRelays: string[], lowbandwidthRelays: string[],
fullRelaySet: { inboxRelays: string[]; outboxRelays: string[] } fullRelaySet: { inboxRelays: string[]; outboxRelays: string[] },
): { inboxRelays: string[]; outboxRelays: string[] } { ): { inboxRelays: string[]; outboxRelays: string[] } {
switch (networkCondition) { switch (networkCondition) {
case NetworkCondition.OFFLINE: case NetworkCondition.OFFLINE:
// When offline, use local relays if available, otherwise rely on cache // When offline, use local relays if available, otherwise rely on cache
// This will be improved when IndexedDB local relay is implemented // This will be improved when IndexedDB local relay is implemented
if (discoveredLocalRelays.length > 0) { if (discoveredLocalRelays.length > 0) {
console.debug('[network_detection.ts] Using local relays (offline)'); console.debug("[network_detection.ts] Using local relays (offline)");
return { return {
inboxRelays: discoveredLocalRelays, inboxRelays: discoveredLocalRelays,
outboxRelays: discoveredLocalRelays outboxRelays: discoveredLocalRelays,
}; };
} else { } else {
console.debug('[network_detection.ts] No local relays available, will rely on cache (offline)'); console.debug(
"[network_detection.ts] No local relays available, will rely on cache (offline)",
);
return { return {
inboxRelays: [], inboxRelays: [],
outboxRelays: [] outboxRelays: [],
}; };
} }
case NetworkCondition.SLOW: { case NetworkCondition.SLOW: {
// Local relays + low bandwidth relays when slow (deduplicated) // Local relays + low bandwidth relays when slow (deduplicated)
console.debug('[network_detection.ts] Using local + low bandwidth relays (slow network)'); console.debug(
const slowInboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...lowbandwidthRelays]); "[network_detection.ts] Using local + low bandwidth relays (slow network)",
const slowOutboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...lowbandwidthRelays]); );
const slowInboxRelays = deduplicateRelayUrls([
...discoveredLocalRelays,
...lowbandwidthRelays,
]);
const slowOutboxRelays = deduplicateRelayUrls([
...discoveredLocalRelays,
...lowbandwidthRelays,
]);
return { return {
inboxRelays: slowInboxRelays, inboxRelays: slowInboxRelays,
outboxRelays: slowOutboxRelays outboxRelays: slowOutboxRelays,
}; };
} }
case NetworkCondition.ONLINE: case NetworkCondition.ONLINE:
default: default:
// Full relay set when online // Full relay set when online
console.debug('[network_detection.ts] Using full relay set (online)'); console.debug("[network_detection.ts] Using full relay set (online)");
return fullRelaySet; return fullRelaySet;
} }
} }
@ -163,12 +185,14 @@ export function startNetworkMonitoring(
const currentCondition = await detectNetworkCondition(); const currentCondition = await detectNetworkCondition();
if (currentCondition !== lastCondition) { if (currentCondition !== lastCondition) {
console.debug(`[network_detection.ts] Network condition changed: ${lastCondition} -> ${currentCondition}`); console.debug(
`[network_detection.ts] Network condition changed: ${lastCondition} -> ${currentCondition}`,
);
lastCondition = currentCondition; lastCondition = currentCondition;
onNetworkChange(currentCondition); onNetworkChange(currentCondition);
} }
} catch (error) { } catch (error) {
console.warn('[network_detection.ts] Network monitoring error:', error); console.warn("[network_detection.ts] Network monitoring error:", error);
} }
}; };

47
src/lib/utils/nostrEventService.ts

@ -1,10 +1,9 @@
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils.ts"; import { getEventHash, prefixNostrAddresses, signEvent } from "./nostrUtils.ts";
import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { EVENT_KINDS, TIME_CONSTANTS } from "./search_constants.ts"; import { EVENT_KINDS, TIME_CONSTANTS } from "./search_constants.ts";
import { ndkInstance } from "../ndk.ts"; import { EXPIRATION_DURATION } from "../consts.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
export interface RootEventInfo { export interface RootEventInfo {
rootId: string; rootId: string;
@ -95,21 +94,21 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
rootInfo.rootId = rootE[1]; rootInfo.rootId = rootE[1];
rootInfo.rootRelay = getRelayString(rootE[2]); rootInfo.rootRelay = getRelayString(rootE[2]);
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey); rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey);
rootInfo.rootKind = rootInfo.rootKind = Number(getTagValue(parent.tags, "K")) ||
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind; rootInfo.rootKind;
} else if (rootA) { } else if (rootA) {
rootInfo.rootAddress = rootA[1]; rootInfo.rootAddress = rootA[1];
rootInfo.rootRelay = getRelayString(rootA[2]); rootInfo.rootRelay = getRelayString(rootA[2]);
rootInfo.rootPubkey = getPubkeyString( rootInfo.rootPubkey = getPubkeyString(
getTagValue(parent.tags, "P") || rootInfo.rootPubkey, getTagValue(parent.tags, "P") || rootInfo.rootPubkey,
); );
rootInfo.rootKind = rootInfo.rootKind = Number(getTagValue(parent.tags, "K")) ||
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind; rootInfo.rootKind;
} else if (rootI) { } else if (rootI) {
rootInfo.rootIValue = rootI[1]; rootInfo.rootIValue = rootI[1];
rootInfo.rootIRelay = getRelayString(rootI[2]); rootInfo.rootIRelay = getRelayString(rootI[2]);
rootInfo.rootKind = rootInfo.rootKind = Number(getTagValue(parent.tags, "K")) ||
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind; rootInfo.rootKind;
} }
return rootInfo; return rootInfo;
@ -223,7 +222,8 @@ export function buildReplyTags(
if (isParentReplaceable) { if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], "d"); const dTag = getTagValue(parent.tags || [], "d");
if (dTag) { if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`; const parentAddress =
`${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
addTags(tags, createTag("a", parentAddress, "", "root")); addTags(tags, createTag("a", parentAddress, "", "root"));
} }
} }
@ -232,7 +232,8 @@ export function buildReplyTags(
if (isParentReplaceable) { if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], "d"); const dTag = getTagValue(parent.tags || [], "d");
if (dTag) { if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`; const parentAddress =
`${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
if (isReplyToComment) { if (isReplyToComment) {
// Root scope (uppercase) - use the original article // Root scope (uppercase) - use the original article
@ -320,12 +321,21 @@ export async function createSignedEvent(
): Promise<{ id: string; sig: string; event: any }> { ): Promise<{ id: string; sig: string; event: any }> {
const prefixedContent = prefixNostrAddresses(content); const prefixedContent = prefixNostrAddresses(content);
// Add expiration tag for kind 24 events (NIP-40)
const finalTags = [...tags];
if (kind === 24) {
const expirationTimestamp =
Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR) +
EXPIRATION_DURATION;
finalTags.push(["expiration", String(expirationTimestamp)]);
}
const eventToSign = { const eventToSign = {
kind: Number(kind), kind: Number(kind),
created_at: Number( created_at: Number(
Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR), Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR),
), ),
tags: tags.map((tag) => [ tags: finalTags.map((tag: any) => [
String(tag[0]), String(tag[0]),
String(tag[1]), String(tag[1]),
String(tag[2] || ""), String(tag[2] || ""),
@ -336,7 +346,10 @@ export async function createSignedEvent(
}; };
let sig, id; let sig, id;
if (typeof window !== "undefined" && globalThis.nostr && globalThis.nostr.signEvent) { if (
typeof window !== "undefined" && globalThis.nostr &&
globalThis.nostr.signEvent
) {
const signed = await globalThis.nostr.signEvent(eventToSign); const signed = await globalThis.nostr.signEvent(eventToSign);
sig = signed.sig as string; sig = signed.sig as string;
id = "id" in signed ? (signed.id as string) : getEventHash(eventToSign); id = "id" in signed ? (signed.id as string) : getEventHash(eventToSign);
@ -365,9 +378,9 @@ export async function createSignedEvent(
export async function publishEvent( export async function publishEvent(
event: NDKEvent, event: NDKEvent,
relayUrls: string[], relayUrls: string[],
ndk: NDK,
): Promise<string[]> { ): Promise<string[]> {
const successfulRelays: string[] = []; const successfulRelays: string[] = [];
const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
throw new Error("NDK instance not available"); throw new Error("NDK instance not available");
@ -379,7 +392,7 @@ export async function publishEvent(
try { try {
// If event is a plain object, create an NDKEvent from it // If event is a plain object, create an NDKEvent from it
let ndkEvent: NDKEvent; let ndkEvent: NDKEvent;
if (event.publish && typeof event.publish === 'function') { if (event.publish && typeof event.publish === "function") {
// It's already an NDKEvent // It's already an NDKEvent
ndkEvent = event; ndkEvent = event;
} else { } else {
@ -397,7 +410,7 @@ export async function publishEvent(
console.debug("[nostrEventService] Published event successfully:", { console.debug("[nostrEventService] Published event successfully:", {
eventId: ndkEvent.id, eventId: ndkEvent.id,
relayCount: relayUrls.length, relayCount: relayUrls.length,
successfulRelays successfulRelays,
}); });
} catch (error) { } catch (error) {
console.error("[nostrEventService] Failed to publish event:", error); console.error("[nostrEventService] Failed to publish event:", error);

215
src/lib/utils/nostrUtils.ts

@ -1,11 +1,16 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { ndkInstance } from "../ndk.ts"; import { unifiedProfileCache } from "./npubCache.ts";
import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk"; import type { NostrEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts"; import type { Filter } from "./search_types.ts";
import { communityRelays, secondaryRelays } from "../consts.ts"; import {
anonymousRelays,
communityRelays,
searchRelays,
secondaryRelays,
localRelays,
} from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
@ -51,88 +56,23 @@ function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]); return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
} }
/**
* Escape regex special characters
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** /**
* Get user metadata for a nostr identifier (npub or nprofile) * Get user metadata for a nostr identifier (npub or nprofile)
*/ */
export async function getUserMetadata( export async function getUserMetadata(
identifier: string, identifier: string,
ndk?: NDK,
force = false, force = false,
): Promise<NostrProfile> { ): Promise<NostrProfile> {
// Remove nostr: prefix if present // Use the unified profile cache which handles all relay searching and caching
const cleanId = identifier.replace(/^nostr:/, ""); return unifiedProfileCache.getProfile(identifier, ndk, force);
console.log("getUserMetadata called with identifier:", identifier, "force:", force);
if (!force && npubCache.has(cleanId)) {
const cached = npubCache.get(cleanId)!;
console.log("getUserMetadata returning cached profile:", cached);
return cached;
}
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
try {
const ndk = get(ndkInstance);
if (!ndk) {
console.warn("getUserMetadata: No NDK instance available");
npubCache.set(cleanId, fallback);
return fallback;
}
const decoded = nip19.decode(cleanId);
if (!decoded) {
console.warn("getUserMetadata: Failed to decode identifier:", cleanId);
npubCache.set(cleanId, fallback);
return fallback;
}
// Handle different identifier types
let pubkey: string;
if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("getUserMetadata: Unsupported identifier type:", decoded.type);
npubCache.set(cleanId, fallback);
return fallback;
}
console.log("getUserMetadata: Fetching profile for pubkey:", pubkey);
const profileEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
console.log("getUserMetadata: Profile event found:", profileEvent);
const profile =
profileEvent && profileEvent.content
? JSON.parse(profileEvent.content)
: null;
console.log("getUserMetadata: Parsed profile:", profile);
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05,
picture: profile?.picture || profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16,
};
console.log("getUserMetadata: Final metadata:", metadata);
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
console.error("getUserMetadata: Error fetching profile:", e);
npubCache.set(cleanId, fallback);
return fallback;
}
} }
/** /**
@ -157,8 +97,8 @@ export function createProfileLink(
export async function createProfileLinkWithVerification( export async function createProfileLinkWithVerification(
identifier: string, identifier: string,
displayText: string | undefined, displayText: string | undefined,
ndk?: NDK,
): Promise<string> { ): Promise<string> {
const ndk = get(ndkInstance) as NDK;
if (!ndk) { if (!ndk) {
return createProfileLink(identifier, displayText); return createProfileLink(identifier, displayText);
} }
@ -192,6 +132,7 @@ export async function createProfileLinkWithVerification(
}; };
const allRelays = [ const allRelays = [
...searchRelays, // Include search relays for profile searches
...communityRelays, ...communityRelays,
...userRelays, ...userRelays,
...secondaryRelays, ...secondaryRelays,
@ -215,8 +156,7 @@ export async function createProfileLinkWithVerification(
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
const displayIdentifier = const displayIdentifier = profile?.displayName ??
profile?.displayName ??
profile?.display_name ?? profile?.display_name ??
profile?.name ?? profile?.name ??
escapedText; escapedText;
@ -253,6 +193,7 @@ function createNoteLink(identifier: string): string {
*/ */
export async function processNostrIdentifiers( export async function processNostrIdentifiers(
content: string, content: string,
ndk: NDK,
): Promise<string> { ): Promise<string> {
let processedContent = content; let processedContent = content;
@ -275,10 +216,14 @@ export async function processNostrIdentifiers(
if (!identifier.startsWith("nostr:")) { if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier; identifier = "nostr:" + identifier;
} }
const metadata = await getUserMetadata(identifier); const metadata = await getUserMetadata(identifier, ndk);
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText); const link = createProfileLink(identifier, displayText);
processedContent = processedContent.replace(fullMatch, link); // Replace all occurrences of this exact match
processedContent = processedContent.replace(
new RegExp(escapeRegExp(fullMatch), "g"),
link,
);
} }
// Process notes (nevent, note, naddr) // Process notes (nevent, note, naddr)
@ -294,7 +239,11 @@ export async function processNostrIdentifiers(
identifier = "nostr:" + identifier; identifier = "nostr:" + identifier;
} }
const link = createNoteLink(identifier); const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link); // Replace all occurrences of this exact match
processedContent = processedContent.replace(
new RegExp(escapeRegExp(fullMatch), "g"),
link,
);
} }
return processedContent; return processedContent;
@ -399,7 +348,7 @@ export function withTimeout<T>(
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs), setTimeout(() => reject(new Error("Timeout")), timeoutMs)
), ),
]); ]);
} }
@ -410,7 +359,7 @@ export function withTimeout<T>(
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs), setTimeout(() => reject(new Error("Timeout")), timeoutMs)
), ),
]); ]);
} }
@ -441,20 +390,44 @@ Promise.prototype.withTimeout = function <T>(
export async function fetchEventWithFallback( export async function fetchEventWithFallback(
ndk: NDK, ndk: NDK,
filterOrId: string | Filter, filterOrId: string | Filter,
timeoutMs: number = 3000, timeoutMs: number = 10000,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// Use both inbox and outbox relays for better event discovery // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive event discovery
// This ensures we don't miss events that might be on any available relay
// Get all relays from NDK pool first (most comprehensive)
const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) =>
r.url
);
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
const allRelays = [...inboxRelays, ...outboxRelays];
// Combine all available relays, prioritizing pool relays
let allRelays = [
...new Set([...poolRelays, ...inboxRelays, ...outboxRelays]),
];
console.log("fetchEventWithFallback: Using pool relays:", poolRelays);
console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays); console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays); console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays);
console.log("fetchEventWithFallback: Total unique relays:", allRelays.length);
// Check if we have any relays available // Check if we have any relays available
if (allRelays.length === 0) { if (allRelays.length === 0) {
console.warn("fetchEventWithFallback: No relays available for event fetch"); console.warn(
return null; "fetchEventWithFallback: No relays available for event fetch, using fallback relays",
);
// Use fallback relays when no relays are available
// AI-NOTE: 2025-01-24 - Include ALL available relays for comprehensive event discovery
// This ensures we don't miss events that might be on any available relay
allRelays = [
...secondaryRelays,
...searchRelays,
...anonymousRelays,
...inboxRelays, // Include user's inbox relays
...outboxRelays, // Include user's outbox relays
];
console.log("fetchEventWithFallback: Using fallback relays:", allRelays);
} }
// Create relay set from all available relays // Create relay set from all available relays
@ -462,13 +435,21 @@ export async function fetchEventWithFallback(
try { try {
if (relaySet.relays.size === 0) { if (relaySet.relays.size === 0) {
console.warn("fetchEventWithFallback: No relays in relay set for event fetch"); console.warn(
"fetchEventWithFallback: No relays in relay set for event fetch",
);
return null; return null;
} }
console.log("fetchEventWithFallback: Relay set size:", relaySet.relays.size); console.log(
"fetchEventWithFallback: Relay set size:",
relaySet.relays.size,
);
console.log("fetchEventWithFallback: Filter:", filterOrId); console.log("fetchEventWithFallback: Filter:", filterOrId);
console.log("fetchEventWithFallback: Relay URLs:", Array.from(relaySet.relays).map((r) => r.url)); console.log(
"fetchEventWithFallback: Relay URLs:",
Array.from(relaySet.relays).map((r) => r.url),
);
let found: NDKEvent | null = null; let found: NDKEvent | null = null;
@ -480,8 +461,9 @@ export async function fetchEventWithFallback(
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet) .fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs); .withTimeout(timeoutMs);
} else { } else {
const filter = const filter = typeof filterOrId === "string"
typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId; ? { ids: [filterOrId] }
: filterOrId;
const results = await ndk const results = await ndk
.fetchEvents(filter, undefined, relaySet) .fetchEvents(filter, undefined, relaySet)
.withTimeout(timeoutMs); .withTimeout(timeoutMs);
@ -492,7 +474,9 @@ export async function fetchEventWithFallback(
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(", "); const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(
", ",
);
console.warn( console.warn(
`fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`, `fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
); );
@ -503,29 +487,49 @@ export async function fetchEventWithFallback(
// Always wrap as NDKEvent // Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === 'Timeout') { if (err instanceof Error && err.message === "Timeout") {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(", "); const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(
", ",
);
console.warn( console.warn(
`fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`, `fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
); );
} else { } else {
console.error("fetchEventWithFallback: Error in fetchEventWithFallback:", err); console.error(
"fetchEventWithFallback: Error in fetchEventWithFallback:",
err,
);
} }
return null; return null;
} }
} }
/** /**
* Converts a hex pubkey to npub, or returns npub if already encoded. * Converts various Nostr identifiers to npub format.
* Handles hex pubkeys, npub strings, and nprofile strings.
*/ */
export function toNpub(pubkey: string | undefined): string | null { export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null; if (!pubkey) return null;
try { try {
// If it's already an npub, return it
if (pubkey.startsWith("npub")) return pubkey;
// If it's a hex pubkey, convert to npub
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) { if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) {
return nip19.npubEncode(pubkey); return nip19.npubEncode(pubkey);
} }
if (pubkey.startsWith("npub1")) return pubkey;
// If it's an nprofile, decode and extract npub
if (pubkey.startsWith("nprofile")) {
const decoded = nip19.decode(pubkey);
if (decoded.type === "nprofile") {
return decoded.data.pubkey
? nip19.npubEncode(decoded.data.pubkey)
: null;
}
}
return null; return null;
} catch { } catch {
return null; return null;
@ -540,7 +544,10 @@ export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk); return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk);
} }
export function createNDKEvent(ndk: NDK, rawEvent: NDKEvent | NostrEvent | undefined) { export function createNDKEvent(
ndk: NDK,
rawEvent: NDKEvent | NostrEvent | undefined,
) {
return new NDKEvent(ndk, rawEvent); return new NDKEvent(ndk, rawEvent);
} }

24
src/lib/utils/nostr_identifiers.ts

@ -1,4 +1,4 @@
import { VALIDATION } from './search_constants'; import { VALIDATION } from "./search_constants";
/** /**
* Nostr identifier types * Nostr identifier types
@ -22,7 +22,7 @@ export interface ParsedCoordinate {
* @returns True if it's a valid hex event ID * @returns True if it's a valid hex event ID
*/ */
export function isEventId(id: string): id is NostrEventId { export function isEventId(id: string): id is NostrEventId {
return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(id); return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(id);
} }
/** /**
@ -30,8 +30,10 @@ export function isEventId(id: string): id is NostrEventId {
* @param coordinate The string to check * @param coordinate The string to check
* @returns True if it's a valid coordinate * @returns True if it's a valid coordinate
*/ */
export function isCoordinate(coordinate: string): coordinate is NostrCoordinate { export function isCoordinate(
const parts = coordinate.split(':'); coordinate: string,
): coordinate is NostrCoordinate {
const parts = coordinate.split(":");
if (parts.length < 3) return false; if (parts.length < 3) return false;
const [kindStr, pubkey, ...dTagParts] = parts; const [kindStr, pubkey, ...dTagParts] = parts;
@ -57,13 +59,13 @@ export function isCoordinate(coordinate: string): coordinate is NostrCoordinate
export function parseCoordinate(coordinate: string): ParsedCoordinate | null { export function parseCoordinate(coordinate: string): ParsedCoordinate | null {
if (!isCoordinate(coordinate)) return null; if (!isCoordinate(coordinate)) return null;
const parts = coordinate.split(':'); const parts = coordinate.split(":");
const [kindStr, pubkey, ...dTagParts] = parts; const [kindStr, pubkey, ...dTagParts] = parts;
return { return {
kind: parseInt(kindStr, 10), kind: parseInt(kindStr, 10),
pubkey, pubkey,
dTag: dTagParts.join(':') // Rejoin in case d-tag contains colons dTag: dTagParts.join(":"), // Rejoin in case d-tag contains colons
}; };
} }
@ -74,7 +76,11 @@ export function parseCoordinate(coordinate: string): ParsedCoordinate | null {
* @param dTag The d-tag value * @param dTag The d-tag value
* @returns The coordinate string * @returns The coordinate string
*/ */
export function createCoordinate(kind: number, pubkey: string, dTag: string): NostrCoordinate { export function createCoordinate(
kind: number,
pubkey: string,
dTag: string,
): NostrCoordinate {
return `${kind}:${pubkey}:${dTag}`; return `${kind}:${pubkey}:${dTag}`;
} }
@ -83,6 +89,8 @@ export function createCoordinate(kind: number, pubkey: string, dTag: string): No
* @param identifier The string to check * @param identifier The string to check
* @returns True if it's a valid Nostr identifier * @returns True if it's a valid Nostr identifier
*/ */
export function isNostrIdentifier(identifier: string): identifier is NostrIdentifier { export function isNostrIdentifier(
identifier: string,
): identifier is NostrIdentifier {
return isEventId(identifier) || isCoordinate(identifier); return isEventId(identifier) || isCoordinate(identifier);
} }

391
src/lib/utils/npubCache.ts

@ -1,51 +1,398 @@
import type { NostrProfile } from "./nostrUtils"; import type { NostrProfile } from "./search_types";
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchEventWithFallback } from "./nostrUtils";
import { nip19 } from "nostr-tools";
export type NpubMetadata = NostrProfile; export type NpubMetadata = NostrProfile;
class NpubCache { interface CacheEntry {
private cache: Record<string, NpubMetadata> = {}; profile: NpubMetadata;
timestamp: number;
pubkey: string;
relaySource?: string;
}
class UnifiedProfileCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly storageKey = "alexandria_unified_profile_cache";
private readonly maxAge = 2 * 60 * 60 * 1000; // 2 hours in milliseconds - shorter for fresher data
constructor() {
this.loadFromStorage();
}
private loadFromStorage(): void {
try {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const data = JSON.parse(stored) as Record<string, CacheEntry>;
const now = Date.now();
// Filter out expired entries
for (const [key, entry] of Object.entries(data)) {
if (entry.timestamp && (now - entry.timestamp) < this.maxAge) {
this.cache.set(key, entry);
}
}
}
}
} catch (error) {
console.warn("Failed to load unified profile cache from storage:", error);
}
}
get(key: string): NpubMetadata | undefined { private saveToStorage(): void {
return this.cache[key]; try {
if (typeof window !== "undefined") {
const data: Record<string, CacheEntry> = {};
for (const [key, entry] of this.cache.entries()) {
data[key] = entry;
} }
localStorage.setItem(this.storageKey, JSON.stringify(data));
}
} catch (error) {
console.warn("Failed to save unified profile cache to storage:", error);
}
}
/**
* Get profile data, fetching fresh data if needed
*/
async getProfile(identifier: string, ndk?: NDK, force = false): Promise<NpubMetadata> {
const cleanId = identifier.replace(/^nostr:/, "");
// Check cache first (unless forced)
if (!force && this.cache.has(cleanId)) {
const entry = this.cache.get(cleanId)!;
const now = Date.now();
// Return cached data if not expired
if ((now - entry.timestamp) < this.maxAge) {
console.log("UnifiedProfileCache: Returning cached profile:", cleanId);
return entry.profile;
}
}
// Fetch fresh data
return this.fetchAndCacheProfile(cleanId, ndk);
}
/**
* Fetch profile from all available relays and cache it
*/
private async fetchAndCacheProfile(identifier: string, ndk?: NDK): Promise<NpubMetadata> {
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` };
set(key: string, value: NpubMetadata): void { try {
this.cache[key] = value; if (!ndk) {
console.warn("UnifiedProfileCache: No NDK instance available");
return fallback;
} }
has(key: string): boolean { const decoded = nip19.decode(identifier);
return key in this.cache; if (!decoded) {
console.warn("UnifiedProfileCache: Failed to decode identifier:", identifier);
return fallback;
} }
delete(key: string): boolean { // Handle different identifier types
if (key in this.cache) { let pubkey: string;
delete this.cache[key]; if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("UnifiedProfileCache: Unsupported identifier type:", decoded.type);
return fallback;
}
console.log("UnifiedProfileCache: Fetching fresh profile for pubkey:", pubkey);
// Use fetchEventWithFallback to search ALL available relays
const profileEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
if (!profileEvent || !profileEvent.content) {
console.warn("UnifiedProfileCache: No profile event found for:", pubkey);
return fallback;
}
const profile = JSON.parse(profileEvent.content);
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05,
picture: profile?.picture || profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16,
};
// Cache the fresh data
const entry: CacheEntry = {
profile: metadata,
timestamp: Date.now(),
pubkey: pubkey,
relaySource: profileEvent.relay?.url,
};
this.cache.set(identifier, entry);
this.cache.set(pubkey, entry); // Also cache by pubkey for convenience
this.saveToStorage();
console.log("UnifiedProfileCache: Cached fresh profile:", metadata);
return metadata;
} catch (e) {
console.error("UnifiedProfileCache: Error fetching profile:", e);
return fallback;
}
}
/**
* Get cached profile without fetching (synchronous)
*/
getCached(identifier: string): NpubMetadata | undefined {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) {
return entry.profile;
} else {
// Remove expired entry
this.cache.delete(cleanId);
}
}
return undefined;
}
/**
* Set profile data in cache
*/
set(identifier: string, profile: NpubMetadata, pubkey?: string, relaySource?: string): void {
const cleanId = identifier.replace(/^nostr:/, "");
const entry: CacheEntry = {
profile,
timestamp: Date.now(),
pubkey: pubkey || cleanId,
relaySource,
};
this.cache.set(cleanId, entry);
if (pubkey && pubkey !== cleanId) {
this.cache.set(pubkey, entry);
}
this.saveToStorage();
}
/**
* Check if profile is cached and valid
*/
has(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) {
return true; return true;
} else {
// Remove expired entry
this.cache.delete(cleanId);
} }
}
return false; return false;
} }
deleteMany(keys: string[]): number { /**
let deleted = 0; * Remove profile from cache
for (const key of keys) { */
if (this.delete(key)) { delete(identifier: string): boolean {
deleted++; const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
this.cache.delete(cleanId);
if (entry.pubkey && entry.pubkey !== cleanId) {
this.cache.delete(entry.pubkey);
} }
this.saveToStorage();
return true;
} }
return deleted;
return false;
} }
/**
* Clear all cached profiles
*/
clear(): void { clear(): void {
this.cache = {}; this.cache.clear();
this.saveToStorage();
} }
/**
* Get cache size
*/
size(): number { size(): number {
return Object.keys(this.cache).length; return this.cache.size;
} }
/**
* Get all cached profiles
*/
getAll(): Record<string, NpubMetadata> { getAll(): Record<string, NpubMetadata> {
return { ...this.cache }; const result: Record<string, NpubMetadata> = {};
for (const [key, entry] of this.cache.entries()) {
result[key] = entry.profile;
}
return result;
}
/**
* Clean up expired entries
*/
cleanup(): void {
const now = Date.now();
const expiredKeys: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if ((now - entry.timestamp) >= this.maxAge) {
expiredKeys.push(key);
}
}
expiredKeys.forEach(key => this.cache.delete(key));
if (expiredKeys.length > 0) {
this.saveToStorage();
console.log(`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`);
}
}
}
// Export the unified cache instance
export const unifiedProfileCache = new UnifiedProfileCache();
// Clean up expired entries every 30 minutes
if (typeof window !== "undefined") {
setInterval(() => {
unifiedProfileCache.cleanup();
}, 30 * 60 * 1000);
}
// Legacy compatibility - keep the old npubCache for backward compatibility
// but make it use the unified cache internally
export const npubCache = {
get: (key: string) => unifiedProfileCache.getCached(key),
set: (key: string, value: NpubMetadata) => unifiedProfileCache.set(key, value),
has: (key: string) => unifiedProfileCache.has(key),
delete: (key: string) => unifiedProfileCache.delete(key),
clear: () => unifiedProfileCache.clear(),
size: () => unifiedProfileCache.size(),
getAll: () => unifiedProfileCache.getAll(),
};
// Legacy compatibility for old profileCache functions
export async function getDisplayName(pubkey: string, ndk: NDK): Promise<string> {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
return profile.displayName || profile.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
export function getDisplayNameSync(pubkey: string): string {
const profile = unifiedProfileCache.getCached(pubkey);
return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
export async function batchFetchProfiles(
pubkeys: string[],
ndk: NDK,
onProgress?: (fetched: number, total: number) => void,
): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = [];
if (onProgress) onProgress(0, pubkeys.length);
// Fetch profiles in parallel using the unified cache
const fetchPromises = pubkeys.map(async (pubkey, index) => {
try {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
if (onProgress) onProgress(index + 1, pubkeys.length);
// Create a mock NDKEvent for compatibility
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = pubkey;
return event;
} catch (e) {
console.error(`Failed to fetch profile for ${pubkey}:`, e);
return null;
} }
});
const results = await Promise.allSettled(fetchPromises);
results.forEach(result => {
if (result.status === 'fulfilled' && result.value) {
allProfileEvents.push(result.value);
}
});
return allProfileEvents;
}
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> {
const pubkeys = new Set<string>();
events.forEach((event) => {
// Add author pubkey
if (event.pubkey) {
pubkeys.add(event.pubkey);
} }
export const npubCache = new NpubCache(); // Add pubkeys from p tags
const pTags = event.getMatchingTags("p");
pTags.forEach((tag) => {
if (tag[1]) {
pubkeys.add(tag[1]);
}
});
// Extract pubkeys from content (nostr:npub1... format)
const npubPattern = /nostr:npub1[a-z0-9]{58}/g;
const matches = event.content?.match(npubPattern) || [];
matches.forEach((match) => {
try {
const npub = match.replace("nostr:", "");
const decoded = nip19.decode(npub);
if (decoded.type === "npub") {
pubkeys.add(decoded.data as string);
}
} catch (e) {
// Invalid npub, ignore
}
});
});
return pubkeys;
}
export function clearProfileCache(): void {
unifiedProfileCache.clear();
}
export function replacePubkeysWithDisplayNames(text: string): string {
// Match hex pubkeys (64 characters)
const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g;
return text.replace(pubkeyRegex, (match) => {
return getDisplayNameSync(match);
});
}

252
src/lib/utils/profileCache.ts

@ -1,252 +0,0 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "$lib/ndk";
import { get } from "svelte/store";
import { nip19 } from "nostr-tools";
interface ProfileData {
display_name?: string;
name?: string;
picture?: string;
about?: string;
}
// Cache for user profiles
const profileCache = new Map<string, ProfileData>();
/**
* Fetches profile data for a pubkey
* @param pubkey - The public key to fetch profile for
* @returns Profile data or null if not found
*/
async function fetchProfile(pubkey: string): Promise<ProfileData | null> {
try {
const ndk = get(ndkInstance);
const profileEvents = await ndk.fetchEvents({
kinds: [0],
authors: [pubkey],
limit: 1
});
if (profileEvents.size === 0) {
return null;
}
// Get the most recent profile event
const profileEvent = Array.from(profileEvents)[0];
try {
const content = JSON.parse(profileEvent.content);
return content as ProfileData;
} catch (e) {
console.error("Failed to parse profile content:", e);
return null;
}
} catch (e) {
console.error("Failed to fetch profile:", e);
return null;
}
}
/**
* Gets the display name for a pubkey, using cache
* @param pubkey - The public key to get display name for
* @returns Display name, name, or shortened pubkey
*/
export async function getDisplayName(pubkey: string): Promise<string> {
// Check cache first
if (profileCache.has(pubkey)) {
const profile = profileCache.get(pubkey)!;
return profile.display_name || profile.name || shortenPubkey(pubkey);
}
// Fetch profile
const profile = await fetchProfile(pubkey);
if (profile) {
profileCache.set(pubkey, profile);
return profile.display_name || profile.name || shortenPubkey(pubkey);
}
// Fallback to shortened pubkey
return shortenPubkey(pubkey);
}
/**
* Batch fetches profiles for multiple pubkeys
* @param pubkeys - Array of public keys to fetch profiles for
* @param onProgress - Optional callback for progress updates
* @returns Array of profile events
*/
export async function batchFetchProfiles(
pubkeys: string[],
onProgress?: (fetched: number, total: number) => void
): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = [];
// Filter out already cached pubkeys
const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk));
if (uncachedPubkeys.length === 0) {
if (onProgress) onProgress(pubkeys.length, pubkeys.length);
return allProfileEvents;
}
try {
const ndk = get(ndkInstance);
// Report initial progress
const cachedCount = pubkeys.length - uncachedPubkeys.length;
if (onProgress) onProgress(cachedCount, pubkeys.length);
// Batch fetch in chunks to avoid overwhelming relays
const CHUNK_SIZE = 50;
let fetchedCount = cachedCount;
for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) {
const chunk = uncachedPubkeys.slice(i, Math.min(i + CHUNK_SIZE, uncachedPubkeys.length));
const profileEvents = await ndk.fetchEvents({
kinds: [0],
authors: chunk
});
// Process each profile event
profileEvents.forEach((event: NDKEvent) => {
try {
const content = JSON.parse(event.content);
profileCache.set(event.pubkey, content as ProfileData);
allProfileEvents.push(event);
fetchedCount++;
} catch (e) {
console.error("Failed to parse profile content:", e);
}
});
// Update progress
if (onProgress) {
onProgress(fetchedCount, pubkeys.length);
}
}
// Final progress update
if (onProgress) onProgress(pubkeys.length, pubkeys.length);
} catch (e) {
console.error("Failed to batch fetch profiles:", e);
}
return allProfileEvents;
}
/**
* Gets display name synchronously from cache
* @param pubkey - The public key to get display name for
* @returns Display name, name, or shortened pubkey
*/
export function getDisplayNameSync(pubkey: string): string {
if (profileCache.has(pubkey)) {
const profile = profileCache.get(pubkey)!;
return profile.display_name || profile.name || shortenPubkey(pubkey);
}
return shortenPubkey(pubkey);
}
/**
* Shortens a pubkey for display
* @param pubkey - The public key to shorten
* @returns Shortened pubkey (first 8 chars...last 4 chars)
*/
function shortenPubkey(pubkey: string): string {
if (pubkey.length <= 12) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
/**
* Clears the profile cache
*/
export function clearProfileCache(): void {
profileCache.clear();
}
/**
* Extracts all pubkeys from events (authors and p tags)
* @param events - Array of events to extract pubkeys from
* @returns Set of unique pubkeys
*/
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> {
const pubkeys = new Set<string>();
events.forEach(event => {
// Add author pubkey
if (event.pubkey) {
pubkeys.add(event.pubkey);
}
// Add pubkeys from p tags
const pTags = event.getMatchingTags("p");
pTags.forEach(tag => {
if (tag[1]) {
pubkeys.add(tag[1]);
}
});
// Extract pubkeys from content (nostr:npub1... format)
const npubPattern = /nostr:npub1[a-z0-9]{58}/g;
const matches = event.content?.match(npubPattern) || [];
matches.forEach(match => {
try {
const npub = match.replace('nostr:', '');
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
pubkeys.add(decoded.data as string);
}
} catch (e) {
// Invalid npub, ignore
}
});
});
return pubkeys;
}
/**
* Replaces pubkeys in content with display names
* @param content - The content to process
* @returns Content with pubkeys replaced by display names
*/
export function replaceContentPubkeys(content: string): string {
if (!content) return content;
// Replace nostr:npub1... references
const npubPattern = /nostr:npub[a-z0-9]{58}/g;
let result = content;
const matches = content.match(npubPattern) || [];
matches.forEach(match => {
try {
const npub = match.replace('nostr:', '');
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
const pubkey = decoded.data as string;
const displayName = getDisplayNameSync(pubkey);
result = result.replace(match, `@${displayName}`);
}
} catch (e) {
// Invalid npub, leave as is
}
});
return result;
}
/**
* Replaces pubkey references in text with display names
* @param text - Text that may contain pubkey references
* @returns Text with pubkeys replaced by display names
*/
export function replacePubkeysWithDisplayNames(text: string): string {
// Match hex pubkeys (64 characters)
const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g;
return text.replace(pubkeyRegex, (match) => {
return getDisplayNameSync(match);
});
}

137
src/lib/utils/profile_search.ts

@ -1,22 +1,24 @@
import { ndkInstance } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts"; import { getNpubFromNip05, getUserMetadata, fetchEventWithFallback } from "./nostrUtils.ts";
import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts"; import { searchCache } from "./searchCache.ts";
import { communityRelays, secondaryRelays } from "../consts.ts"; import { communityRelays, searchRelays, secondaryRelays, anonymousRelays } from "../consts.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import { import {
createProfileFromEvent,
fieldMatches, fieldMatches,
nip05Matches, nip05Matches,
normalizeSearchTerm, normalizeSearchTerm,
createProfileFromEvent,
} from "./search_utils.ts"; } from "./search_utils.ts";
import { nip19 } from "nostr-tools";
/** /**
* Search for profiles by various criteria (display name, name, NIP-05, npub) * Search for profiles by various criteria (display name, name, NIP-05, npub)
*/ */
export async function searchProfiles( export async function searchProfiles(
searchTerm: string, searchTerm: string,
ndk: NDK,
): Promise<ProfileSearchResult> { ): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
@ -46,7 +48,6 @@ export async function searchProfiles(
return { profiles, Status: {} }; return { profiles, Status: {} };
} }
const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.error("NDK not initialized"); console.error("NDK not initialized");
throw new Error("NDK not initialized"); throw new Error("NDK not initialized");
@ -63,7 +64,7 @@ export async function searchProfiles(
normalizedSearchTerm.startsWith("nprofile") normalizedSearchTerm.startsWith("nprofile")
) { ) {
try { try {
const metadata = await getUserMetadata(normalizedSearchTerm); const metadata = await getUserMetadata(normalizedSearchTerm, ndk);
if (metadata) { if (metadata) {
foundProfiles = [metadata]; foundProfiles = [metadata];
} }
@ -76,10 +77,30 @@ export async function searchProfiles(
try { try {
const npub = await getNpubFromNip05(normalizedNip05); const npub = await getNpubFromNip05(normalizedNip05);
if (npub) { if (npub) {
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub, ndk);
const profile: NostrProfile = {
// AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined;
try {
const decoded = nip19.decode(npub);
if (decoded.type === "npub") {
const pubkey = decoded.data as string;
const originalEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
if (originalEvent && originalEvent.created_at) {
created_at = originalEvent.created_at;
}
}
} catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e);
}
const profile: NostrProfile & { created_at?: number } = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
created_at: created_at,
}; };
foundProfiles = [profile]; foundProfiles = [profile];
} }
@ -89,7 +110,7 @@ export async function searchProfiles(
} else { } else {
// Try NIP-05 search first (faster than relay search) // Try NIP-05 search first (faster than relay search)
console.log("Starting NIP-05 search for:", normalizedSearchTerm); console.log("Starting NIP-05 search for:", normalizedSearchTerm);
foundProfiles = await searchNip05Domains(normalizedSearchTerm); foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk);
console.log( console.log(
"NIP-05 search completed, found:", "NIP-05 search completed, found:",
foundProfiles.length, foundProfiles.length,
@ -142,6 +163,7 @@ export async function searchProfiles(
*/ */
async function searchNip05Domains( async function searchNip05Domains(
searchTerm: string, searchTerm: string,
ndk: NDK,
): Promise<NostrProfile[]> { ): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = []; const foundProfiles: NostrProfile[] = [];
@ -184,10 +206,30 @@ async function searchNip05Domains(
"NIP-05 search: SUCCESS! found npub for gitcitadel.com:", "NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub, npub,
); );
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub, ndk);
const profile: NostrProfile = {
// AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined;
try {
const decoded = nip19.decode(npub);
if (decoded.type === "npub") {
const pubkey = decoded.data as string;
const originalEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
if (originalEvent && originalEvent.created_at) {
created_at = originalEvent.created_at;
}
}
} catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e);
}
const profile: NostrProfile & { created_at?: number } = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
created_at: created_at,
}; };
console.log( console.log(
"NIP-05 search: created profile for gitcitadel.com:", "NIP-05 search: created profile for gitcitadel.com:",
@ -216,10 +258,30 @@ async function searchNip05Domains(
const npub = await getNpubFromNip05(nip05Address); const npub = await getNpubFromNip05(nip05Address);
if (npub) { if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub); console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub, ndk);
const profile: NostrProfile = {
// AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined;
try {
const decoded = nip19.decode(npub);
if (decoded.type === "npub") {
const pubkey = decoded.data as string;
const originalEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
if (originalEvent && originalEvent.created_at) {
created_at = originalEvent.created_at;
}
}
} catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e);
}
const profile: NostrProfile & { created_at?: number } = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
created_at: created_at,
}; };
console.log( console.log(
"NIP-05 search: created profile for", "NIP-05 search: created profile for",
@ -252,7 +314,7 @@ async function searchNip05Domains(
} }
/** /**
* Quick relay search with short timeout * Search for profiles across all available relays
*/ */
async function quickRelaySearch( async function quickRelaySearch(
searchTerm: string, searchTerm: string,
@ -264,12 +326,32 @@ async function quickRelaySearch(
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use all profile relays for better coverage // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive profile discovery
const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays // This ensures we don't miss profiles due to stale cache or limited relay coverage
console.log("Using all relays for search:", quickRelayUrls);
// Get all available relays from NDK pool (most comprehensive)
const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) => r.url) as string[];
const userInboxRelays = get(activeInboxRelays);
const userOutboxRelays = get(activeOutboxRelays);
// Combine ALL available relays for maximum coverage
const allRelayUrls = [
...poolRelays, // All NDK pool relays
...userInboxRelays, // User's personal inbox relays
...userOutboxRelays, // User's personal outbox relays
...searchRelays, // Dedicated profile search relays
...communityRelays, // Community relays
...secondaryRelays, // Secondary relays as fallback
...anonymousRelays, // Anonymous relays as additional fallback
];
// Deduplicate relay URLs
const uniqueRelayUrls = [...new Set(allRelayUrls)];
console.log("Using ALL available relays for profile search:", uniqueRelayUrls);
console.log("Total relays for profile search:", uniqueRelayUrls.length);
// Create relay sets for parallel search // Create relay sets for parallel search
const relaySets = quickRelayUrls const relaySets = uniqueRelayUrls
.map((url) => { .map((url) => {
try { try {
return NDKRelaySet.fromRelayUrls([url], ndk); return NDKRelaySet.fromRelayUrls([url], ndk);
@ -280,6 +362,8 @@ async function quickRelaySearch(
}) })
.filter(Boolean); .filter(Boolean);
console.log("Created relay sets for profile search:", relaySets.length);
// Search all relays in parallel with short timeout // Search all relays in parallel with short timeout
const searchPromises = relaySets.map((relaySet, index) => { const searchPromises = relaySets.map((relaySet, index) => {
if (!relaySet) return []; if (!relaySet) return [];
@ -289,7 +373,7 @@ async function quickRelaySearch(
let eventCount = 0; let eventCount = 0;
console.log( console.log(
`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`, `Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`,
); );
const sub = ndk.subscribe( const sub = ndk.subscribe(
@ -303,8 +387,8 @@ async function quickRelaySearch(
try { try {
if (!event.content) return; if (!event.content) return;
const profileData = JSON.parse(event.content); const profileData = JSON.parse(event.content);
const displayName = const displayName = profileData.displayName ||
profileData.displayName || profileData.display_name || ""; profileData.display_name || "";
const display_name = profileData.display_name || ""; const display_name = profileData.display_name || "";
const name = profileData.name || ""; const name = profileData.name || "";
const nip05 = profileData.nip05 || ""; const nip05 = profileData.nip05 || "";
@ -336,6 +420,7 @@ async function quickRelaySearch(
nip05: profileData.nip05, nip05: profileData.nip05,
pubkey: event.pubkey, pubkey: event.pubkey,
searchTerm: normalizedSearchTerm, searchTerm: normalizedSearchTerm,
relay: uniqueRelayUrls[index],
}); });
const profile = createProfileFromEvent(event, profileData); const profile = createProfileFromEvent(event, profileData);
@ -354,7 +439,9 @@ async function quickRelaySearch(
sub.on("eose", () => { sub.on("eose", () => {
console.log( console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, `Relay ${index + 1} (${
uniqueRelayUrls[index]
}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
); );
resolve(foundInRelay); resolve(foundInRelay);
}); });
@ -362,7 +449,9 @@ async function quickRelaySearch(
// Short timeout for quick search // Short timeout for quick search
setTimeout(() => { setTimeout(() => {
console.log( console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, `Relay ${index + 1} (${
uniqueRelayUrls[index]
}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
); );
sub.stop(); sub.stop();
resolve(foundInRelay); resolve(foundInRelay);

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save