Browse Source

Merge master into visualization-improvements - preserve enhanced visualization features

master
limina1 8 months ago
parent
commit
eea0dafaca
  1. 2
      .onedev-buildspec.yml
  2. 2
      .prettierrc
  3. 3
      .vscode/settings.json
  4. 22
      Dockerfile
  5. 15
      Dockerfile.local
  6. 15
      Dockerfile.next
  7. 15
      Dockerfile.prod
  8. 45
      README.md
  9. 1078
      bun.lock
  10. 1
      deno.json
  11. 1745
      deno.lock
  12. 6
      docker-compose.yaml
  13. 16
      import_map.json
  14. 10
      maintainers.yaml
  15. 3833
      package-lock.json
  16. 54
      package.json
  17. 29
      playwright.config.ts
  18. 5
      postcss.config.js
  19. 124
      src/app.css
  20. 17
      src/app.d.ts
  21. 31
      src/app.html
  22. 633
      src/lib/components/CommentBox.svelte
  23. 456
      src/lib/components/EventDetails.svelte
  24. 540
      src/lib/components/EventInput.svelte
  25. 2
      src/lib/components/EventLimitControl.svelte
  26. 10
      src/lib/components/EventRenderLevelLimit.svelte
  27. 853
      src/lib/components/EventSearch.svelte
  28. 76
      src/lib/components/Login.svelte
  29. 84
      src/lib/components/LoginModal.svelte
  30. 12
      src/lib/components/Modal.svelte
  31. 8
      src/lib/components/Navigation.svelte
  32. 59
      src/lib/components/NetworkStatus.svelte
  33. 187
      src/lib/components/Preview.svelte
  34. 254
      src/lib/components/PublicationFeed.svelte
  35. 64
      src/lib/components/PublicationHeader.svelte
  36. 121
      src/lib/components/PublicationSection.svelte
  37. 166
      src/lib/components/RelayActions.svelte
  38. 57
      src/lib/components/RelayDisplay.svelte
  39. 164
      src/lib/components/RelayStatus.svelte
  40. 24
      src/lib/components/Toc.svelte
  41. 180
      src/lib/components/ZettelEditor.svelte
  42. 88
      src/lib/components/cards/BlogHeader.svelte
  43. 154
      src/lib/components/cards/ProfileHeader.svelte
  44. 88
      src/lib/components/publications/Publication.svelte
  45. 414
      src/lib/components/publications/PublicationFeed.svelte
  46. 90
      src/lib/components/publications/PublicationHeader.svelte
  47. 155
      src/lib/components/publications/PublicationSection.svelte
  48. 182
      src/lib/components/publications/TableOfContents.svelte
  49. 111
      src/lib/components/publications/svelte_publication_tree.svelte.ts
  50. 297
      src/lib/components/publications/table_of_contents.svelte.ts
  51. 38
      src/lib/components/util/ArticleNav.svelte
  52. 199
      src/lib/components/util/CardActions.svelte
  53. 115
      src/lib/components/util/ContainingIndexes.svelte
  54. 30
      src/lib/components/util/CopyToClipboard.svelte
  55. 123
      src/lib/components/util/Details.svelte
  56. 72
      src/lib/components/util/Interactions.svelte
  57. 90
      src/lib/components/util/LazyImage.svelte
  58. 629
      src/lib/components/util/Profile.svelte
  59. 4
      src/lib/components/util/QrCode.svelte
  60. 143
      src/lib/components/util/TocToggle.svelte
  61. 84
      src/lib/components/util/ViewPublicationLink.svelte
  62. 4
      src/lib/components/util/ZapOutline.svelte
  63. 61
      src/lib/consts.ts
  64. 319
      src/lib/data_structures/publication_tree.ts
  65. 586
      src/lib/ndk.ts
  66. 474
      src/lib/parser.ts
  67. 111
      src/lib/services/publisher.ts
  68. 14
      src/lib/snippets/PublicationSnippets.svelte
  69. 78
      src/lib/snippets/UserSnippets.svelte
  70. 2
      src/lib/state.ts
  71. 28
      src/lib/stores.ts
  72. 11
      src/lib/stores/authStore.Svelte.ts
  73. 55
      src/lib/stores/networkStore.ts
  74. 4
      src/lib/stores/relayStore.ts
  75. 427
      src/lib/stores/userStore.ts
  76. 10
      src/lib/types.ts
  77. 36
      src/lib/utils.ts
  78. 110
      src/lib/utils/ZettelParser.ts
  79. 106
      src/lib/utils/community_checker.ts
  80. 437
      src/lib/utils/event_input_utils.ts
  81. 224
      src/lib/utils/event_search.ts
  82. 31
      src/lib/utils/image_utils.ts
  83. 139
      src/lib/utils/indexEventCache.ts
  84. 77
      src/lib/utils/markup/MarkupInfo.md
  85. 371
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  86. 533
      src/lib/utils/markup/advancedMarkupParser.ts
  87. 202
      src/lib/utils/markup/asciidoctorExtensions.ts
  88. 133
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  89. 213
      src/lib/utils/markup/basicMarkupParser.ts
  90. 60
      src/lib/utils/markup/tikzRenderer.ts
  91. 33
      src/lib/utils/mime.ts
  92. 188
      src/lib/utils/network_detection.ts
  93. 444
      src/lib/utils/nostrEventService.ts
  94. 470
      src/lib/utils/nostrUtils.ts
  95. 2
      src/lib/utils/npubCache.ts
  96. 393
      src/lib/utils/profile_search.ts
  97. 142
      src/lib/utils/relayDiagnostics.ts
  98. 531
      src/lib/utils/relay_management.ts
  99. 108
      src/lib/utils/searchCache.ts
  100. 124
      src/lib/utils/search_constants.ts
  101. Some files were not shown because too many files have changed in this diff Show More

2
.onedev-buildspec.yml

@ -1,6 +1,6 @@
version: 39 version: 39
jobs: jobs:
- name: Github Push - name: Github Push
steps: steps:
- !PushRepository - !PushRepository
name: gc-alexandria name: gc-alexandria

2
.prettierrc

@ -1,3 +1,3 @@
{ {
"plugins":["prettier-plugin-svelte"] "plugins": ["prettier-plugin-svelte"]
} }

3
.vscode/settings.json vendored

@ -10,5 +10,6 @@
}, },
"files.associations": { "files.associations": {
"*.svelte": "svelte" "*.svelte": "svelte"
} },
"editor.tabSize": 2
} }

22
Dockerfile

@ -1,13 +1,17 @@
FROM node:23-alpine AS build FROM denoland/deno:alpine AS build
WORKDIR /app/src
COPY . .
RUN deno install
RUN deno task build
FROM denoland/deno:alpine AS release
WORKDIR /app WORKDIR /app
COPY --from=build /app/src/build/ ./build/
COPY --from=build /app/src/import_map.json .
COPY . ./ ENV ORIGIN=http://localhost:3000
COPY package.json ./
COPY package-lock.json ./
RUN npm install
RUN npm run build
EXPOSE 80 RUN deno cache --import-map=import_map.json ./build/index.js
FROM nginx:1.27.4
COPY --from=build /app/build /usr/share/nginx/html EXPOSE 3000
CMD [ "deno", "run", "--allow-env", "--allow-read", "--allow-net", "--import-map=import_map.json", "./build/index.js" ]

15
Dockerfile.local

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

15
Dockerfile.next

@ -1,15 +0,0 @@
FROM denoland/deno:alpine AS build
WORKDIR /app/src
COPY . .
RUN deno install
RUN deno task build
FROM denoland/deno:alpine AS release
WORKDIR /app
COPY --from=build /app/src/build/ ./build/
COPY --from=build /app/src/import_map.json .
ENV ORIGIN=https://$HOST
EXPOSE 3000
CMD [ "deno", "run", "--allow-env", "--allow-read", "--allow-net", "--import-map=import_map.json", "./build/index.js" ]

15
Dockerfile.prod

@ -1,15 +0,0 @@
FROM denoland/deno:alpine AS build
WORKDIR /app/src
COPY . .
RUN deno install
RUN deno task build
FROM denoland/deno:alpine AS release
WORKDIR /app
COPY --from=build /app/src/build/ ./build/
COPY --from=build /app/src/import_map.json .
ENV ORIGIN=https://$HOST
EXPOSE 3000
CMD [ "deno", "run", "--allow-env", "--allow-read", "--allow-net", "--import-map=import_map.json", "./build/index.js" ]

45
README.md

@ -18,21 +18,25 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?
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:
```bash ```bash
npm install npm install
``` ```
or with Deno: or with Deno:
```bash ```bash
deno install deno install
``` ```
then start a development server with Node: then start a development server with Node:
```bash ```bash
npm run dev npm run dev
``` ```
or with Deno: or with Deno:
```bash ```bash
deno task dev deno task dev
``` ```
@ -42,52 +46,27 @@ deno task dev
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:
```bash ```bash
npm run build npm run build
``` ```
or with Deno: or with Deno:
```bash ```bash
deno task build deno task build
``` ```
You can preview the (non-static) production build with: You can preview the (non-static) production build with:
```bash ```bash
npm run preview npm run preview
``` ```
or with Deno: or with Deno:
```bash
deno task preview
```
## Docker
This docker container performs the build.
To build the container:
```bash
docker build . -t gc-alexandria
```
To run the container, in detached mode (-d):
```bash ```bash
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria deno task preview
```
The container is then viewable on your [local machine](http://localhost:4173).
If you want to see the container process (assuming it's the last process to start), enter:
```bash
docker ps -l
```
which should return something like:
```bash
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d83d736322f gc-alexandria "/docker-entrypoint.…" 2 minutes ago Up 2 minutes 0.0.0.0:4174->80/tcp, [::]:4174->80/tcp gc-alexandria
``` ```
## Docker + Deno ## Docker + Deno
@ -95,25 +74,29 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS
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:
```bash ```bash
docker build -t local-alexandria -f Dockerfile.local . docker build -t local-alexandria -f Dockerfile .
``` ```
To run the local development build: To run the local development build:
```bash ```bash
docker run -d -p 3000:3000 local-alexandria 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
``` ```
For the Playwright end-to-end (e2e) tests: For the Playwright end-to-end (e2e) tests:
```bash ```bash
npx playwright test npx playwright test
``` ```

1078
bun.lock

File diff suppressed because it is too large Load Diff

1
deno.json

@ -1,7 +1,6 @@
{ {
"importMap": "./import_map.json", "importMap": "./import_map.json",
"compilerOptions": { "compilerOptions": {
"allowJs": true,
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"] "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
} }
} }

1745
deno.lock

File diff suppressed because it is too large Load Diff

6
docker-compose.yaml

@ -1,9 +1,9 @@
version: '3' version: "3"
services: services:
wikinostr: alexandria:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- 3023:4173 - 3000:3000

16
import_map.json

@ -1,19 +1,19 @@
{ {
"imports": { "imports": {
"he": "npm:he@1.2.x", "he": "npm:he@1.2.x",
"@nostr-dev-kit/ndk": "npm:@nostr-dev-kit/ndk@2.11.x", "@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.5.x", "@nostr-dev-kit/ndk-cache-dexie": "npm:@nostr-dev-kit/ndk-cache-dexie@^2.6.33",
"@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.x",
"nostr-tools": "npm:nostr-tools@2.10.x", "nostr-tools": "npm:nostr-tools@^2.15.1",
"tailwind-merge": "npm:tailwind-merge@2.5.x", "tailwind-merge": "npm:tailwind-merge@^3.3.1",
"svelte": "npm:svelte@5.0.x", "svelte": "npm:svelte@^5.36.8",
"flowbite": "npm:flowbite@2.2.x", "flowbite": "npm:flowbite@^3.1.2",
"flowbite-svelte": "npm:flowbite-svelte@0.44.x", "flowbite-svelte": "npm:flowbite-svelte@^1.10.10",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x", "flowbite-svelte-icons": "npm:flowbite-svelte-icons@^2.2.1",
"child_process": "node:child_process" "child_process": "node:child_process"
} }
} }

10
maintainers.yaml

@ -1,8 +1,8 @@
identifier: Alexandria identifier: Alexandria
maintainers: maintainers:
- npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf - npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf
- npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z - npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z
- npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn - npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn
relays: relays:
- wss://theforest.nostr1.com - wss://theforest.nostr1.com
- wss://thecitadel.nostr1.com - wss://thecitadel.nostr1.com

3833
package-lock.json generated

File diff suppressed because it is too large Load Diff

54
package.json

@ -1,6 +1,6 @@
{ {
"name": "alexandria", "name": "alexandria",
"version": "0.0.6", "version": "0.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -14,54 +14,52 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@noble/curves": "^1.9.1",
"@noble/hashes": "^1.8.0", "@noble/hashes": "^1.8.0",
"@nostr-dev-kit/ndk": "2.11.x", "@noble/curves": "^1.9.4",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x", "@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "2.6.x",
"@popperjs/core": "2.11.x", "@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x", "@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x", "@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"he": "1.2.0", "he": "1.2.x",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"node-emoji": "^2.2.0", "node-emoji": "^2.2.0",
"nostr-tools": "2.10.x", "nostr-tools": "2.15.x",
"plantuml-encoder": "^1.4.0",
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0", "@playwright/test": "^1.54.1",
"@sveltejs/adapter-auto": "~3.3.1", "@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/adapter-static": "~3.0.8", "@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "^2.21.0", "@sveltejs/kit": "^2.25.0",
"@sveltejs/vite-plugin-svelte": "~4.0.4", "@sveltejs/vite-plugin-svelte": "^6.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/svelte": "^5.2.8",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/he": "1.2.x", "@types/he": "1.2.x",
"@types/node": "22.x", "@types/mathjax": "^0.0.40",
"@types/node": "^24.0.15",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vitest/ui": "^3.1.4", "autoprefixer": "^10.4.21",
"autoprefixer": "10.x", "eslint-plugin-svelte": "^3.11.0",
"eslint-plugin-svelte": "2.x",
"flowbite": "2.x", "flowbite": "2.x",
"flowbite-svelte": "0.x", "flowbite-svelte": "0.48.x",
"flowbite-svelte-icons": "2.1.x", "flowbite-svelte-icons": "2.1.x",
"jsdom": "^26.1.0",
"playwright": "^1.50.1", "playwright": "^1.50.1",
"postcss": "8.x", "postcss": "^8.5.6",
"postcss-load-config": "6.x", "postcss-load-config": "6.x",
"prettier": "3.x", "prettier": "^3.6.2",
"prettier-plugin-svelte": "3.x", "prettier-plugin-svelte": "^3.4.0",
"svelte": "5.x", "svelte": "^5.36.8",
"svelte-check": "4.x", "svelte-check": "4.x",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.1",
"tailwindcss": "3.x", "tailwindcss": "^3.4.17",
"tslib": "2.8.x", "tslib": "2.8.x",
"typescript": "5.7.x", "typescript": "^5.8.3",
"vite": "5.x", "vite": "^7.0.5",
"vitest": "^3.1.3" "vitest": "^3.1.3"
} }
} }

29
playwright.config.ts

@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from "@playwright/test";
import process from "node:process";
/** /**
* Read environment variables from file. * Read environment variables from file.
@ -12,7 +13,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e/', testDir: "./tests/e2e/",
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
@ -22,34 +23,31 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [ reporter: [["list"], ["html", { outputFolder: "./tests/e2e/html-report" }]],
['list'],
['html', { outputFolder: './tests/e2e/html-report' }]
],
/* 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://127.0.0.1:3000', // baseURL: 'http://127.0.0.1:3000',
/* 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",
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ {
name: 'chromium', name: "chromium",
use: { ...devices['Desktop Chrome'] }, use: { ...devices["Desktop Chrome"] },
}, },
{ {
name: 'firefox', name: "firefox",
use: { ...devices['Desktop Firefox'] }, use: { ...devices["Desktop Firefox"] },
}, },
{ {
name: 'webkit', name: "webkit",
use: { ...devices['Desktop Safari'] }, use: { ...devices["Desktop Safari"] },
}, },
/* Test against mobile viewports. */ /* Test against mobile viewports. */
@ -84,10 +82,10 @@ export default defineConfig({
// testIgnore: '*test-assets', // testIgnore: '*test-assets',
// Glob patterns or regular expressions that match test files. // Glob patterns or regular expressions that match test files.
testMatch: '*.pw.spec.ts', testMatch: "*.pw.spec.ts",
// Folder for test artifacts such as screenshots, videos, traces, etc. // Folder for test artifacts such as screenshots, videos, traces, etc.
outputDir: './tests/e2e/test-results', outputDir: "./tests/e2e/test-results",
// path to the global setup files. // path to the global setup files.
// globalSetup: require.resolve('./global-setup'), // globalSetup: require.resolve('./global-setup'),
@ -102,5 +100,4 @@ export default defineConfig({
// Maximum time expect() should wait for the condition to be met. // Maximum time expect() should wait for the condition to be met.
timeout: 5000, timeout: 5000,
}, },
}); });

5
postcss.config.js

@ -2,8 +2,5 @@ import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer"; import autoprefixer from "autoprefixer";
export default { export default {
plugins: [ plugins: [tailwindcss(), autoprefixer()],
tailwindcss(),
autoprefixer(),
]
}; };

124
src/app.css

@ -1,13 +1,14 @@
@import './styles/base.css'; @import "./styles/base.css";
@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/events.css";
@import "./styles/asciidoc.css";
/* Custom styles */ /* Custom styles */
@layer base { @layer base {
.leather { .leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200; @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
} }
.btn-leather.text-xs { .btn-leather.text-xs {
@ -26,8 +27,8 @@
@apply h-4 w-4; @apply h-4 w-4;
} }
div[role='tooltip'] button.btn-leather { div[role="tooltip"] button.btn-leather {
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 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 {
@ -45,11 +46,11 @@
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-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @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-600 dark:text-gray-200 dark:hover:text-primary-200; @apply text-gray-900 hover:text-primary-700 dark:text-gray-100 dark:hover:text-primary-300;
} }
main { main {
@ -67,13 +68,13 @@
main.main-leather, main.main-leather,
article.article-leather { article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
} }
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-800 dark:text-gray-300 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)),
@ -88,7 +89,7 @@
h4.h-leather, h4.h-leather,
h5.h-leather, h5.h-leather,
h6.h-leather { h6.h-leather {
@apply text-gray-800 dark:text-gray-300; @apply text-gray-900 dark:text-gray-100;
} }
h1.h-leather { h1.h-leather {
@ -115,21 +116,21 @@
@apply text-base font-semibold; @apply text-base font-semibold;
} }
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,
div.modal-leather>div>h2, div.modal-leather > div > h2,
div.modal-leather>div>h3, div.modal-leather > div > h3,
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-800 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-300; @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-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @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 */
@ -142,7 +143,7 @@
} }
nav.navbar-leather svg { nav.navbar-leather svg {
@apply fill-gray-800 hover:fill-primary-400 dark:fill-gray-300 dark:hover:fill-primary-500; @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,
@ -151,50 +152,40 @@
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-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
/* Sidebar */
aside.sidebar-leather {
@apply fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10;
@apply bg-primary-0 dark:bg-primary-1000 px-5 w-full sm:w-auto sm:max-w-xl;
}
aside.sidebar-leather > div {
@apply bg-primary-50 dark:bg-gray-800 h-full px-5 py-0;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-gray-800;
} }
div.skeleton-leather div { div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800; @apply bg-primary-100 dark:bg-primary-800;
} }
div.skeleton-leather {
@apply h-48;
}
div.textarea-leather { div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000;
} }
div.textarea-leather>div:nth-child(1), div.textarea-leather > div:nth-child(1),
div.toolbar-leather { div.toolbar-leather {
@apply border-none; @apply border-none;
} }
div.textarea-leather>div:nth-child(2) { div.textarea-leather > div:nth-child(2) {
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000;
} }
div.textarea-leather, div.textarea-leather,
div.textarea-leather textarea { div.textarea-leather textarea {
@apply text-gray-800 dark:text-gray-300; @apply text-gray-900 dark:text-gray-100;
} }
div.tooltip-leather { div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300; @apply text-gray-900 dark:text-gray-100;
} }
div[role='tooltip'] button.btn-leather .tooltip-leather { div[role="tooltip"] button.btn-leather .tooltip-leather {
@apply bg-primary-100 dark:bg-primary-800; @apply bg-primary-100 dark:bg-primary-800;
} }
@ -229,7 +220,7 @@
/* Utilities can be applied via the @apply directive. */ /* Utilities can be applied via the @apply directive. */
@layer utilities { @layer utilities {
.h-leather { .h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4; @apply text-gray-900 dark:text-gray-100 pt-4;
} }
.h1-leather { .h1-leather {
@ -259,11 +250,11 @@
/* Lists */ /* Lists */
.ol-leather li a, .ol-leather li a,
.ul-leather li a { .ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @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-400 dark:hover:text-primary-500; @apply underline cursor-pointer hover:text-primary-600 dark:hover:text-primary-400;
} }
/* Card with transition */ /* Card with transition */
@ -273,7 +264,7 @@
} }
.ArticleBox.grid.active .ArticleBoxImage { .ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-72; @apply max-h-40;
} }
.tags span { .tags span {
@ -290,7 +281,6 @@
} }
@layer components { @layer components {
/* 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 rounded;
@ -300,7 +290,7 @@
/* Tooltip */ /* Tooltip */
.tooltip-leather { .tooltip-leather {
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 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;
} }
@ -312,6 +302,8 @@
/* Rendered publication content */ /* Rendered publication content */
.publication-leather { .publication-leather {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;
scroll-margin-top: 150px;
scroll-behavior: smooth;
h1, h1,
h2, h2,
@ -391,7 +383,7 @@
} }
.stemblock { .stemblock {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg; @apply bg-gray-200 dark:bg-gray-800 p-4 rounded-lg;
} }
.literalblock { .literalblock {
@ -409,7 +401,6 @@
thead, thead,
tbody { tbody {
th, th,
td { td {
@apply border border-gray-200 dark:border-gray-700; @apply border border-gray-200 dark:border-gray-700;
@ -439,10 +430,10 @@
padding-left: 1rem; padding-left: 1rem;
} }
.line-ellipsis { .line-ellipsis {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.footnotes li { .footnotes li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -466,6 +457,21 @@
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important; scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
} }
/* Section scroll behavior */
section[id] {
scroll-margin-top: 150px;
}
/* Ensure section headers maintain their padding */
section[id] h1,
section[id] h2,
section[id] h3,
section[id] h4,
section[id] h5,
section[id] h6 {
@apply pt-4;
}
.description-textarea { .description-textarea {
min-height: 100% !important; min-height: 100% !important;
} }
@ -508,7 +514,17 @@
input[type="tel"], input[type="tel"],
input[type="url"], input[type="url"],
textarea { textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 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-400 dark:focus:border-primary-500; @apply focus:border-primary-600 dark:focus:border-primary-400;
}
/* Table of Contents highlighting */
.toc-highlight {
@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;
}
.toc-highlight:hover {
@apply bg-primary-300 dark:bg-primary-600;
} }
} }

17
src/app.d.ts vendored

@ -1,7 +1,7 @@
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKNip07Signer } from "@nostr-dev-kit/ndk";
import Pharos from "./lib/parser.ts"; import { HLJSApi } from "highlight.js";
// for information about these interfaces // for information about these interfaces
declare global { declare global {
@ -9,13 +9,24 @@ declare global {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
interface PageData { interface PageData {
waitable?: Promise<any>; waitable?: Promise<unknown>;
publicationType?: string; publicationType?: string;
indexEvent?: NDKEvent; indexEvent?: NDKEvent;
url?: URL; url?: URL;
} }
// interface Platform {} // interface Platform {}
} }
var hljs: HLJSApi;
// deno-lint-ignore no-explicit-any
var MathJax: any;
var nostr: NDKNip07Signer & {
getRelays: () => Promise<Record<string, Record<string, boolean | undefined>>>;
// deno-lint-ignore no-explicit-any
signEvent: (event: any) => Promise<any>;
};
} }
export {}; export {};

31
src/app.html

@ -4,6 +4,37 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" /> <link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<!-- MathJax for math rendering -->
<script>
window.MathJax = {
tex: {
inlineMath: [
["$", "$"],
["\\(", "\\)"],
],
displayMath: [
["$$", "$$"],
["\\[", "\\]"],
],
processEscapes: true,
processEnvironments: true,
},
options: {
ignoreHtmlClass: "tex2jax_ignore",
processHtmlClass: "tex2jax_process",
},
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<!-- highlight.js for code highlighting -->
<link
rel="stylesheet"
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>
%sveltekit.head% %sveltekit.head%
</head> </head>

633
src/lib/components/CommentBox.svelte

@ -1,66 +1,144 @@
<script lang="ts"> <script lang="ts">
import { Button, Textarea, Alert } from 'flowbite-svelte'; import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
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 { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils'; import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { standardRelays, fallbackRelays } from '$lib/consts'; import { searchProfiles } from "$lib/utils/search_utility";
import { userRelays } from '$lib/stores/relayStore'; import type {
import { get } from 'svelte/store'; NostrProfile,
import { goto } from '$app/navigation'; ProfileSearchResult,
import type { NDKEvent } from '$lib/utils/nostrUtils'; } from "$lib/utils/search_utility";
import { onMount } from 'svelte';
import { userPubkey } from "$lib/stores/authStore.Svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
extractRootEventInfo,
extractParentEventInfo,
buildReplyTags,
createSignedEvent,
publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService";
import { tick } from "svelte";
import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
userPubkey: string;
userRelayPreference: boolean; userRelayPreference: boolean;
}>(); }>();
let content = $state(''); let content = $state("");
let preview = $state(''); let preview = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null); let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let showOtherRelays = $state(false); let showOtherRelays = $state(false);
let showFallbackRelays = $state(false); let showSecondaryRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null); let userProfile = $state<NostrProfile | null>(null);
// Fetch user profile on mount // Add state for modals and search
onMount(async () => { let showMentionModal = $state(false);
if (props.userPubkey) { let showWikilinkModal = $state(false);
const npub = nip19.npubEncode(props.userPubkey); let mentionSearch = $state("");
userProfile = await getUserMetadata(npub); let mentionResults = $state<NostrProfile[]>([]);
let mentionLoading = $state(false);
let wikilinkTarget = $state("");
let wikilinkLabel = $state("");
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let mentionSearchInput: HTMLInputElement | undefined;
// Reset modal state when it opens/closes
$effect(() => {
if (showMentionModal) {
// Reset search when modal opens
mentionSearch = "";
mentionResults = [];
mentionLoading = false;
// Focus the search input after a brief delay to ensure modal is rendered
setTimeout(() => {
mentionSearchInput?.focus();
}, 100);
} else {
// Reset search when modal closes
mentionSearch = "";
mentionResults = [];
mentionLoading = false;
}
});
$effect(() => {
const trimmedPubkey = $userPubkey?.trim();
const npub = toNpub(trimmedPubkey);
if (npub) {
// Call an async function, but don't make the effect itself async
getUserMetadata(npub).then((metadata) => {
userProfile = metadata;
});
} else if (trimmedPubkey) {
userProfile = null;
error = "Invalid public key: must be a 64-character hex string.";
} else {
userProfile = null;
error = null;
} }
}); });
$effect(() => {
if (!success) return;
content = "";
preview = "";
});
// Markup buttons // Markup buttons
const markupButtons = [ const markupButtons = [
{ label: 'Bold', action: () => insertMarkup('**', '**') }, { label: "Bold", action: () => insertMarkup("**", "**") },
{ label: 'Italic', action: () => insertMarkup('_', '_') }, { label: "Italic", action: () => insertMarkup("_", "_") },
{ label: 'Strike', action: () => insertMarkup('~~', '~~') }, { label: "Strike", action: () => insertMarkup("~~", "~~") },
{ label: 'Link', action: () => insertMarkup('[', '](url)') }, { label: "Link", action: () => insertMarkup("[", "](url)") },
{ label: 'Image', action: () => insertMarkup('![', '](url)') }, { label: "Image", action: () => insertMarkup("![", "](url)") },
{ label: 'Quote', action: () => insertMarkup('> ', '') }, { label: "Quote", action: () => insertMarkup("> ", "") },
{ label: 'List', action: () => insertMarkup('- ', '') }, { label: "List", action: () => insertMarkup("* ", "") },
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') }, { label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: 'Hashtag', action: () => insertMarkup('#', '') } { label: "Hashtag", action: () => insertMarkup("#", "") },
{
label: "@",
action: () => {
mentionSearch = "";
mentionResults = [];
showMentionModal = true;
},
},
{
label: "Wikilink",
action: () => {
showWikilinkModal = true;
},
},
]; ];
function insertMarkup(prefix: string, suffix: string) { function insertMarkup(prefix: string, suffix: string) {
const textarea = document.querySelector('textarea'); const textarea = document.querySelector("textarea");
if (!textarea) return; if (!textarea) return;
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const selectedText = content.substring(start, end); const selectedText = content.substring(start, end);
content = content.substring(0, start) + prefix + selectedText + suffix + content.substring(end); content =
content.substring(0, start) +
prefix +
selectedText +
suffix +
content.substring(end);
updatePreview(); updatePreview();
// Set cursor position after the inserted markup // Set cursor position after the inserted markup
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length; textarea.selectionStart = textarea.selectionEnd =
start + prefix.length + selectedText.length + suffix.length;
}, 0); }, 0);
} }
@ -69,155 +147,220 @@
} }
function clearForm() { function clearForm() {
content = ''; content = "";
preview = ''; preview = "";
error = null; error = null;
success = null;
showOtherRelays = false; showOtherRelays = false;
showFallbackRelays = false; showSecondaryRelays = false;
} }
function removeFormatting() { function removeFormatting() {
content = content content = content
.replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/_(.*?)_/g, '$1') .replace(/_(.*?)_/g, "$1")
.replace(/~~(.*?)~~/g, '$1') .replace(/~~(.*?)~~/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/!\[(.*?)\]\(.*?\)/g, '$1') .replace(/!\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s*/gm, '') .replace(/^>\s*/gm, "")
.replace(/^[-*]\s*/gm, '') .replace(/^[-*]\s*/gm, "")
.replace(/^\d+\.\s*/gm, '') .replace(/^\d+\.\s*/gm, "")
.replace(/#(\w+)/g, '$1'); .replace(/#(\w+)/g, "$1");
updatePreview(); updatePreview();
} }
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) { async function handleSubmit(
useOtherRelays = false,
useSecondaryRelays = false,
) {
isSubmitting = true; isSubmitting = true;
error = null; error = null;
success = null; success = null;
try { try {
if (!props.event.kind) { const pk = $userPubkey || "";
throw new Error('Invalid event: missing kind'); const npub = toNpub(pk);
}
const kind = props.event.kind === 1 ? 1 : 1111; if (!npub) {
const tags: string[][] = []; throw new Error(
"Invalid public key: must be a 64-character hex string.",
if (kind === 1) { );
// NIP-10 reply
tags.push(['e', props.event.id, '', 'reply']);
tags.push(['p', props.event.pubkey]);
if (props.event.tags) {
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root');
if (rootTag) {
tags.push(['e', rootTag[1], '', 'root']);
}
// Add all p tags from the parent event
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(['p', t[1]]);
} }
});
} if (props.event.kind === undefined || props.event.kind === null) {
} else { throw new Error("Invalid event: missing kind");
// NIP-22 comment
tags.push(['E', props.event.id, '', props.event.pubkey]);
tags.push(['K', props.event.kind.toString()]);
tags.push(['P', props.event.pubkey]);
tags.push(['e', props.event.id, '', props.event.pubkey]);
tags.push(['k', props.event.kind.toString()]);
tags.push(['p', props.event.pubkey]);
} }
const eventToSign = { const parent = props.event;
// Use the same kind as parent for replies, or 1111 for generic replies
const kind = parent.kind === 1 ? 1 : 1111;
// Extract root and parent event information
const rootInfo = extractRootEventInfo(parent);
const parentInfo = extractParentEventInfo(parent);
// Build tags for the reply
const tags = buildReplyTags(parent, rootInfo, parentInfo, kind);
// Create and sign the event
const { event: signedEvent } = await createSignedEvent(
content,
pk,
kind, kind,
created_at: Math.floor(Date.now() / 1000),
tags, tags,
content, );
pubkey: props.userPubkey
};
const id = getEventHash(eventToSign); // Publish the event using the new relay system
const sig = await signEvent(eventToSign); let relays = $activeOutboxRelays;
if (useOtherRelays && !useSecondaryRelays) {
relays = [...$activeOutboxRelays, ...$activeInboxRelays];
} else if (useSecondaryRelays) {
// For secondary relays, use a subset of outbox relays
relays = $activeOutboxRelays.slice(0, 3); // Use first 3 outbox relays
}
const signedEvent = { const successfulRelays = await publishEvent(signedEvent, relays);
...eventToSign,
id, success = {
sig relay: successfulRelays[0] || "Unknown relay",
eventId: signedEvent.id,
}; };
// Determine which relays to use // Clear form after successful submission
let relays = props.userRelayPreference ? get(userRelays) : standardRelays; content = "";
if (useOtherRelays) { preview = "";
relays = props.userRelayPreference ? standardRelays : get(userRelays); showOtherRelays = false;
showSecondaryRelays = false;
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error occurred";
} finally {
isSubmitting = false;
}
}
// Add a helper to shorten npub
function shortenNpub(npub: string | undefined) {
if (!npub) return "";
return npub.slice(0, 8) + "…" + npub.slice(-4);
}
async function insertAtCursor(text: string) {
const textarea = document.querySelector("textarea");
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
content = content.substring(0, start) + text + content.substring(end);
updatePreview();
// Wait for DOM updates to complete
await tick();
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
}
// Add mention search functionality using centralized search utility
let communityStatus: Record<string, boolean> = $state({});
let isSearching = $state(false);
async function searchMentions() {
if (!mentionSearch.trim()) {
mentionResults = [];
communityStatus = {};
return;
} }
if (useFallbackRelays) {
relays = fallbackRelays; // Prevent multiple concurrent searches
if (isSearching) {
return;
} }
// Try to publish to relays console.log("Starting search for:", mentionSearch.trim());
let published = false;
for (const relayUrl of relays) { // Set loading state
mentionLoading = true;
isSearching = true;
try { try {
const ws = new WebSocket(relayUrl); console.log("Search promise created, waiting for result...");
await new Promise<void>((resolve, reject) => { const result = await searchProfiles(mentionSearch.trim());
const timeout = setTimeout(() => { console.log("Search completed, found profiles:", result.profiles.length);
ws.close(); console.log("Profile details:", result.profiles);
reject(new Error('Timeout')); console.log("Community status:", result.Status);
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', signedEvent]));
};
ws.onmessage = (e) => { // Update state
const [type, id, ok, message] = JSON.parse(e.data); mentionResults = result.profiles;
if (type === 'OK' && id === signedEvent.id) { communityStatus = result.Status;
clearTimeout(timeout);
if (ok) { console.log(
published = true; "State updated - mentionResults length:",
success = { relay: relayUrl, eventId: signedEvent.id }; mentionResults.length,
ws.close(); );
resolve(); console.log(
} else { "State updated - communityStatus keys:",
ws.close(); Object.keys(communityStatus),
reject(new Error(message)); );
} catch (error) {
console.error("Error searching mentions:", error);
mentionResults = [];
communityStatus = {};
} finally {
mentionLoading = false;
isSearching = false;
console.log(
"Search finished - loading:",
mentionLoading,
"searching:",
isSearching,
);
} }
} }
};
ws.onerror = () => { function selectMention(profile: NostrProfile) {
clearTimeout(timeout); let mention = "";
ws.close(); if (profile.pubkey) {
reject(new Error('WebSocket error')); try {
}; const npub = toNpub(profile.pubkey);
}); if (npub) {
if (published) break; mention = `nostr:${npub}`;
} else {
// If toNpub fails, fallback to pubkey
mention = `nostr:${profile.pubkey}`;
}
} catch (e) { } catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e); console.error("Error in toNpub:", e);
// Fallback to pubkey if conversion fails
mention = `nostr:${profile.pubkey}`;
}
} else {
console.warn("No pubkey in profile, falling back to display name");
mention = `@${profile.displayName || profile.name}`;
} }
insertAtCursor(mention);
showMentionModal = false;
mentionSearch = "";
mentionResults = [];
} }
if (!published) { function insertWikilink() {
if (!useOtherRelays && !useFallbackRelays) { let markup = "";
showOtherRelays = true; if (wikilinkLabel.trim()) {
error = 'Failed to publish to primary relays. Would you like to try the other relays?'; markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
} else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true;
error = 'Failed to publish to other relays. Would you like to try the fallback relays?';
} else { } else {
error = 'Failed to publish to any relays. Please try again later.'; markup = `[[${wikilinkTarget}]]`;
} }
} else { insertAtCursor(markup);
// Navigate to the event page showWikilinkModal = false;
const nevent = nip19.neventEncode({ id: signedEvent.id }); wikilinkTarget = "";
goto(`/events?id=${nevent}`); wikilinkLabel = "";
} }
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred'; function handleViewComment() {
} finally { if (success?.eventId) {
isSubmitting = false; const nevent = nip19.neventEncode({ id: success.eventId });
goto(`/events?id=${encodeURIComponent(nevent)}`);
} }
} }
</script> </script>
@ -227,11 +370,172 @@
{#each markupButtons as button} {#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button> <Button size="xs" on:click={button.action}>{button.label}</Button>
{/each} {/each}
<Button size="xs" color="alternative" on:click={removeFormatting}>Remove Formatting</Button> <Button size="xs" color="alternative" on:click={removeFormatting}
>Remove Formatting</Button
>
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button> <Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <!-- Mention Modal -->
<Modal
class="modal-leather"
title="Mention User"
bind:open={showMentionModal}
autoclose
outsideclose
size="sm"
>
<div class="space-y-4">
<div class="flex gap-2">
<input
type="text"
placeholder="Search display name, name, NIP-05, or npub..."
bind:value={mentionSearch}
bind:this={mentionSearchInput}
onkeydown={(e) => {
if (e.key === "Enter" && mentionSearch.trim() && !isSearching) {
searchMentions();
}
}}
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"
/>
<Button
size="xs"
color="primary"
onclick={(e: Event) => {
e.preventDefault();
e.stopPropagation();
searchMentions();
}}
disabled={isSearching || !mentionSearch.trim()}
>
{#if isSearching}
Searching...
{:else}
Search
{/if}
</Button>
</div>
{#if mentionLoading}
<div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0}
<div class="text-center py-2 text-xs text-gray-500">
Found {mentionResults.length} results
</div>
<div
class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg"
>
<ul class="space-y-1 p-2">
{#each mentionResults as profile}
<button
type="button"
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)}
>
{#if profile.pubkey && communityStatus[profile.pubkey]}
<div
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"
>
<svg
class="w-4 h-4 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-6 h-6"></div>
{/if}
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
{:else}
<div
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"
></div>
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
{profile.displayName || profile.name || mentionSearch}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg
class="inline w-4 h-4 text-primary-500"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/></svg
>
{profile.nip05}
</span>
{/if}
<span class="text-xs text-gray-400 font-mono truncate"
>{shortenNpub(profile.pubkey)}</span
>
</div>
</button>
{/each}
</ul>
</div>
{:else if mentionSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div>
{:else}
<div class="text-center py-4 text-gray-500">
Enter a search term to find users
</div>
{/if}
</div>
</Modal>
<!-- Wikilink Modal -->
<Modal
class="modal-leather"
title="Insert Wikilink"
bind:open={showWikilinkModal}
autoclose
outsideclose
size="sm"
>
<Input
type="text"
placeholder="Target page (e.g. target page or target-page)"
bind:value={wikilinkTarget}
class="mb-2"
/>
<Input
type="text"
placeholder="Display text (optional)"
bind:value={wikilinkLabel}
class="mb-4"
/>
<div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button
>
<Button
size="xs"
color="alternative"
on:click={() => {
showWikilinkModal = false;
}}>Cancel</Button
>
</div>
</Modal>
<div class="space-y-4">
<div> <div>
<Textarea <Textarea
bind:value={content} bind:value={content}
@ -241,7 +545,9 @@
class="w-full" class="w-full"
/> />
</div> </div>
<div class="prose dark:prose-invert max-w-none p-4 border rounded-lg"> <div
class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg"
>
{@html preview} {@html preview}
</div> </div>
</div> </div>
@ -250,20 +556,30 @@
<Alert color="red" dismissable> <Alert color="red" dismissable>
{error} {error}
{#if showOtherRelays} {#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}>Try Other Relays</Button> <Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}
>Try Other Relays</Button
>
{/if} {/if}
{#if showFallbackRelays} {#if showSecondaryRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button> <Button
size="xs"
class="mt-2"
on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button
>
{/if} {/if}
</Alert> </Alert>
{/if} {/if}
{#if success} {#if success}
<Alert color="green" dismissable> <Alert color="green" dismissable>
Comment published successfully to {success.relay}! Comment published successfully to {success.relay}!<br />
<a href="/events?id={nip19.neventEncode({ id: success.eventId })}" class="text-primary-600 dark:text-primary-500 hover:underline"> Event ID: <span class="font-mono">{success.eventId}</span>
<button
onclick={handleViewComment}
class="text-primary-600 dark:text-primary-500 hover:underline ml-2"
>
View your comment View your comment
</a> </button>
</Alert> </Alert>
{/if} {/if}
@ -273,7 +589,7 @@
{#if userProfile.picture} {#if userProfile.picture}
<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"
onerror={(e) => { onerror={(e) => {
const img = e.target as HTMLImageElement; const img = e.target as HTMLImageElement;
@ -281,17 +597,19 @@
}} }}
/> />
{/if} {/if}
<span class="text-gray-700 dark:text-gray-300"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'} {userProfile.displayName ||
userProfile.name ||
nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."}
</span> </span>
</div> </div>
{/if} {/if}
<Button <Button
on:click={() => handleSubmit()} on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !props.userPubkey} disabled={isSubmitting || !content.trim() || !$userPubkey}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !props.userPubkey} {#if !$userPubkey}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -301,9 +619,10 @@
</Button> </Button>
</div> </div>
{#if !props.userPubkey} {#if !$userPubkey}
<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 current account. Please sign in to post comments. Your comments will be signed with your
current account.
</Alert> </Alert>
{/if} {/if}
</div> </div>

456
src/lib/components/EventDetails.svelte

@ -4,12 +4,22 @@
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 { standardRelays } from "$lib/consts"; import { activeInboxRelays, activeOutboxRelays } 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";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
const { event, profile = null, searchValue = null } = $props<{ const {
event,
profile = null,
searchValue = null,
} = $props<{
event: NDKEvent; event: NDKEvent;
profile?: { profile?: {
name?: string; name?: string;
@ -25,71 +35,337 @@
}>(); }>();
let showFullContent = $state(false); let showFullContent = $state(false);
let parsedContent = $state(''); let parsedContent = $state("");
let contentPreview = $state(''); let contentPreview = $state("");
let authorDisplayName = $state<string | undefined>(undefined);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled'; // First try to get title from title tag
const titleTag = getMatchingTags(event, "title")[0]?.[1];
if (titleTag) {
return titleTag;
}
// For kind 30023 events, extract title from markdown content if no title tag
if (event.kind === 30023 && event.content) {
const match = event.content.match(/^#\s+(.+)$/m);
if (match) {
return match[1].trim();
}
}
// For kind 30040, 30041, and 30818 events, extract title from AsciiDoc content if no title tag
if (
(event.kind === 30040 || event.kind === 30041 || event.kind === 30818) &&
event.content
) {
// First try to find a document header (= )
const docMatch = event.content.match(/^=\s+(.+)$/m);
if (docMatch) {
return docMatch[1].trim();
}
// If no document header, try to find the first section header (== )
const sectionMatch = event.content.match(/^==\s+(.+)$/m);
if (sectionMatch) {
return sectionMatch[1].trim();
}
}
return "Untitled";
} }
function getEventSummary(event: NDKEvent): string { function getEventSummary(event: NDKEvent): string {
return getMatchingTags(event, 'summary')[0]?.[1] || ''; return getMatchingTags(event, "summary")[0]?.[1] || "";
} }
function getEventHashtags(event: NDKEvent): string[] { function getEventHashtags(event: NDKEvent): string[] {
return getMatchingTags(event, 't').map((tag: string[]) => tag[1]); 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 { function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) { if (tag[0] === "a" && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':'); const parts = tag[1].split(":");
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`; if (parts.length >= 3) {
} else if (tag[0] === 'e' && tag.length > 1) { const [kind, pubkey, d] = parts;
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`; // 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 { } else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`; 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[]): {
text: string;
gotoValue?: 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 {
text: `a:${tag[1]}`,
gotoValue: naddr,
};
} catch (error) {
console.warn("Failed to encode naddr for a tag:", tag[1], error);
return { text: `a:${tag[1]}` };
}
} else {
console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` };
}
} else {
console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` };
}
} 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 {
text: `e:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for e tag:", tag[1], error);
return { text: `e:${tag[1]}` };
}
} else {
console.warn("Invalid event ID in e tag:", tag[1]);
return { text: `e:${tag[1]}` };
}
} else if (tag[0] === "p" && tag.length > 1) {
const npub = toNpub(tag[1]);
return {
text: `p:${npub || tag[1]}`,
gotoValue: npub ? npub : undefined,
};
} 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 {
text: `note:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for note tag:", tag[1], error);
return { text: `note:${tag[1]}` };
}
} else {
console.warn("Invalid event ID in note tag:", tag[1]);
return { text: `note:${tag[1]}` };
}
} else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return {
text: `d:${tag[1]}`,
gotoValue: `d:${tag[1]}`,
};
} else if (tag[0] === "t" && tag.length > 1) {
// 't' tags are hashtags - navigate to t-tag search
return {
text: `t:${tag[1]}`,
gotoValue: `t:${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(() => { $effect(() => {
if (event && event.kind !== 0 && event.content) { if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then(html => { parseBasicmarkup(event.content).then((html) => {
parsedContent = html; parsedContent = html;
contentPreview = html.slice(0, 250); contentPreview = html.slice(0, 250);
}); });
} }
}); });
$effect(() => {
if (!event?.pubkey) {
authorDisplayName = undefined;
return;
}
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
});
// --- Identifier helpers --- // --- Identifier helpers ---
function getIdentifiers(event: NDKEvent, profile: any): { label: string, value: string, link?: string }[] { function getIdentifiers(
const ids: { label: string, value: string, link?: string }[] = []; event: NDKEvent,
profile: any,
): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) { if (event.kind === 0) {
// NIP-05 // NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, 'nip05')[0]?.[1]; const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub // npub
const npub = toNpub(event.pubkey); const npub = toNpub(event.pubkey);
if (npub) ids.push({ label: 'npub', value: npub, link: `/events?id=${npub}` }); if (npub)
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` });
// nprofile // nprofile
ids.push({ label: 'nprofile', value: nprofileEncode(event.pubkey, standardRelays), link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}` }); ids.push({
label: "nprofile",
value: nprofileEncode(event.pubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`,
});
// nevent // nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); ids.push({
label: "nevent",
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
// hex pubkey // hex pubkey
ids.push({ label: 'pubkey', value: event.pubkey }); ids.push({ label: "pubkey", value: event.pubkey });
} else { } else {
// nevent // nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); ids.push({
label: "nevent",
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
// naddr (if addressable) // naddr (if addressable)
try { try {
const naddr = naddrEncode(event, standardRelays); 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
ids.push({ label: 'id', value: event.id }); ids.push({ label: "id", value: event.id });
} }
return ids; return ids;
} }
@ -97,56 +373,92 @@
function isCurrentSearch(value: string): boolean { function isCurrentSearch(value: string): boolean {
if (!searchValue) return false; if (!searchValue) return false;
// Compare ignoring case and possible nostr: prefix // Compare ignoring case and possible nostr: prefix
const norm = (s: string) => s.replace(/^nostr:/, '').toLowerCase(); const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase();
return norm(value) === norm(searchValue); return norm(value) === norm(searchValue);
} }
onMount(() => {
function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.tagName === "A") {
const href = (target as HTMLAnchorElement).getAttribute("href");
if (href && href.startsWith("/")) {
event.preventDefault();
goto(href);
}
}
}
document.addEventListener("click", handleInternalLinkClick);
return () => document.removeEventListener("click", handleInternalLinkClick);
});
</script> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
{#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">{getEventTitle(event)}</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{getEventTitle(event)}
</h2>
{/if} {/if}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span> <span class="text-gray-600 dark:text-gray-400"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
profile?.display_name || event.pubkey,
)}</span
>
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span> <span class="text-gray-600 dark:text-gray-400"
>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">
<span class="text-gray-600 dark:text-gray-400">Kind:</span> <span class="text-gray-700 dark:text-gray-300">Kind:</span>
<span class="font-mono">{event.kind}</span> <span class="font-mono">{event.kind}</span>
<span class="text-gray-600 dark:text-gray-400">({getEventTypeDisplay(event)})</span> <span class="text-gray-700 dark:text-gray-300"
>({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">
<span class="text-gray-600 dark:text-gray-400">Summary:</span> <span class="text-gray-700 dark:text-gray-300">Summary:</span>
<p class="text-gray-800 dark:text-gray-200">{getEventSummary(event)}</p> <p class="text-gray-900 dark:text-gray-100">{getEventSummary(event)}</p>
</div> </div>
{/if} {/if}
{#if getEventHashtags(event).length} {#if getEventHashtags(event).length}
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Tags:</span> <span class="text-gray-700 dark:text-gray-300">Tags:</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag} {#each getEventHashtags(event) as tag}
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span> <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} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<!-- Containing Publications -->
<ContainingIndexes {event} />
<!-- Content --> <!-- Content -->
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
{#if event.kind !== 0} {#if event.kind !== 0}
<span class="text-gray-600 dark:text-gray-400">Content:</span> <span class="text-gray-700 dark:text-gray-300">Content:</span>
<div class="prose dark:prose-invert max-w-none"> <div class="prose dark:prose-invert max-w-none">
{@html showFullContent ? parsedContent : contentPreview} {@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250} {#if !showFullContent && parsedContent.length > 250}
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => showFullContent = true}>Show more</button> <button
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
>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -154,31 +466,81 @@
<!-- If event is profile --> <!-- If event is profile -->
{#if event.kind === 0} {#if event.kind === 0}
<ProfileHeader {event} {profile} identifiers={getIdentifiers(event, profile)} /> <ProfileHeader
{event}
{profile}
identifiers={getIdentifiers(event, profile)}
/>
{/if} {/if}
<!-- Tags Array --> <!-- Tags Array -->
{#if event.tags && event.tags.length} {#if event.tags && event.tags.length}
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Event Tags:</span> <span class="text-gray-700 dark:text-gray-300">Event Tags:</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each event.tags as tag} {#each event.tags as tag}
{@html renderTag(tag)} {@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
<button
onclick={() => {
// Handle different types of gotoValue
if (
tagInfo.gotoValue!.startsWith("naddr") ||
tagInfo.gotoValue!.startsWith("nevent") ||
tagInfo.gotoValue!.startsWith("npub") ||
tagInfo.gotoValue!.startsWith("nprofile") ||
tagInfo.gotoValue!.startsWith("note")
) {
// For naddr, nevent, npub, nprofile, note - navigate directly
goto(`/events?id=${tagInfo.gotoValue!}`);
} else if (tagInfo.gotoValue!.startsWith("/")) {
// For relative URLs - navigate directly
goto(tagInfo.gotoValue!);
} else if (tagInfo.gotoValue!.startsWith("d:")) {
// For d-tag searches - navigate to d-tag search
const dTag = tagInfo.gotoValue!.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (tagInfo.gotoValue!.startsWith("t:")) {
// For t-tag searches - navigate to t-tag search
const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`);
} else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) {
// For hex event IDs - use navigateToEvent
navigateToEvent(tagInfo.gotoValue!);
} else {
// For other cases, try direct navigation
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"
>
{tagInfo.text}
</button>
{/if}
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<!-- Raw Event JSON --> <!-- Raw Event JSON -->
<details class="bg-primary-50 dark:bg-primary-900 rounded p-4"> <details
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"> class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4"
>
<summary
class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"
>
Show Raw Event JSON Show Raw Event JSON
</summary> </summary>
<div class="absolute top-4 right-4">
<CopyToClipboard
displayText=""
copyText={JSON.stringify(event.rawEvent(), null, 2)}
/>
</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"
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>
</details> </details>
</div> </div>

540
src/lib/components/EventInput.svelte

@ -0,0 +1,540 @@
<script lang="ts">
import {
getTitleTagForEvent,
getDTagForEvent,
requiresDTag,
hasDTag,
validateNotAsciidoc,
validateAsciiDoc,
build30040EventSet,
titleToDTag,
validate30040EventSet,
get30040EventDescription,
analyze30040Event,
get30040FixGuidance,
} from "$lib/utils/event_input_utils";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte";
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 { nip19 } from "nostr-tools";
import { goto } from "$app/navigation";
let kind = $state<number>(30023);
let tags = $state<[string, string][]>([]);
let content = $state("");
let createdAt = $state<number>(Math.floor(Date.now() / 1000));
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state<string | null>(null);
let publishedRelays = $state<string[]>([]);
let title = $state("");
let dTag = $state("");
let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false);
let dTagError = $state("");
let lastPublishedEventId = $state<string | null>(null);
/**
* Extracts the first Markdown/AsciiDoc header as the title.
*/
function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m);
return match ? match[2].trim() : "";
}
function handleContentInput(e: Event) {
content = (e.target as HTMLTextAreaElement).value;
if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content);
console.log("Content input - extracted title:", extracted);
title = extracted;
}
}
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 isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
function validate(): { valid: boolean; reason?: 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 (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content);
if (!v.valid) return v;
}
return { valid: true };
}
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = "";
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) {
dTagError = "A d-tag is required.";
return;
}
handlePublish();
}
async function handlePublish(): Promise<void> {
error = null;
success = null;
publishedRelays = [];
loading = true;
createdAt = Math.floor(Date.now() / 1000);
try {
const ndk = get(ndkInstance);
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)) {
error = "Invalid public key: must be a 64-character hex string.";
loading = false;
return;
}
// Validate before proceeding
const validation = validate();
if (!validation.valid) {
error = validation.reason || "Validation failed.";
loading = false;
return;
}
const baseEvent = { pubkey: pubkeyString, created_at: createdAt };
let events: NDKEvent[] = [];
console.log("Publishing event with kind:", kind);
console.log("Content length:", content.length);
console.log("Content preview:", content.substring(0, 100));
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;
}
} else {
let eventTags = [...tags];
// Ensure d-tag exists and has a value for addressable events
if (requiresDTag(kind)) {
const dTagIndex = eventTags.findIndex(([k]) => k === "d");
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, "");
if (dTagValue) {
if (dTagIndex >= 0) {
// Update existing d-tag
eventTags[dTagIndex] = ["d", dTagValue];
} else {
// Add new d-tag
eventTags = [...eventTags, ["d", dTagValue]];
}
}
}
// Add title tag if we have a title
const titleValue = title.trim() || getTitleTagForEvent(kind, content);
if (titleValue) {
eventTags = [...eventTags, ["title", titleValue]];
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
// 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 {
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) => [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 = new WebSocket(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));
};
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);
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error("WebSocket error"));
};
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (published) {
atLeastOne = true;
// 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;
}
}
loading = false;
if (atLeastOne) {
publishedRelays = relaysPublished;
success = `Published to ${relaysPublished.length} relay(s).`;
} else {
error = "Failed to publish to any relay.";
}
} catch (err) {
console.error("Error in handlePublish:", err);
error = `Publishing failed: ${err instanceof Error ? err.message : "Unknown error"}`;
loading = false;
}
}
/**
* Debug function to analyze a 30040 event and provide guidance.
*/
function debug30040Event(eventData: {
content: string;
tags: [string, string][];
kind: number;
}) {
const analysis = analyze30040Event(eventData);
console.log("30040 Event Analysis:", analysis);
if (!analysis.valid) {
console.log("Guidance:", get30040FixGuidance());
}
return analysis;
}
function viewPublishedEvent() {
if (lastPublishedEventId) {
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
}
}
</script>
<div
class="w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg"
>
<h2 class="text-xl font-bold mb-4">Publish Nostr Event</h2>
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label class="block font-medium mb-1" for="event-kind">Kind</label>
<input
id="event-kind"
type="text"
class="input input-bordered w-full"
bind:value={kind}
required
/>
{#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 kind === 30040}
<div
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded"
>
<strong>30040 - Publication Index:</strong>
{get30040EventDescription()}
</div>
{/if}
</div>
<div>
<label class="block font-medium mb-1" for="tags-container">Tags</label>
<div id="tags-container" class="space-y-2">
{#each tags as [key, value], i}
<div class="flex gap-2">
<input
type="text"
class="input input-bordered flex-1"
placeholder="tag"
bind:value={tags[i][0]}
oninput={(e) =>
updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])}
/>
<input
type="text"
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
type="button"
class="btn btn-error btn-sm"
onclick={() => removeTag(i)}
disabled={tags.length === 1}</button
>
</div>
{/each}
<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>
<div>
<label class="block font-medium mb-1" for="event-content">Content</label>
<textarea
id="event-content"
bind:value={content}
oninput={handleContentInput}
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>
<label class="block font-medium mb-1" for="event-d-tag">d-tag</label>
<input
type="text"
id="event-d-tag"
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>
{/if}
</div>
<div class="flex justify-end">
<button
type="submit"
class="btn btn-primary border border-primary-600 px-4 py-2"
disabled={loading}>Publish</button
>
</div>
{#if loading}
<span class="ml-2 text-gray-500">Publishing...</span>
{/if}
{#if error}
<div class="mt-2 text-red-600">{error}</div>
{/if}
{#if success}
<div class="mt-2 text-green-600">{success}</div>
<div class="text-xs text-gray-500">
Relays: {publishedRelays.join(", ")}
</div>
{#if lastPublishedEventId}
<div class="mt-2 text-green-700">
Event ID: <span class="font-mono">{lastPublishedEventId}</span>
<Button
onclick={viewPublishedEvent}
class="text-blue-600 dark:text-blue-500 hover:underline ml-2"
>
View your event
</Button>
</div>
{/if}
{/if}
</form>
</div>

2
src/lib/components/EventLimitControl.svelte

@ -45,7 +45,7 @@
/> />
<button <button
on:click={handleUpdate} on:click={handleUpdate}
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
> >
Update Update
</button> </button>

10
src/lib/components/EventRenderLevelLimit.svelte

@ -29,10 +29,14 @@
</script> </script>
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<label for="levels-to-render" class="leather bg-transparent text-sm font-medium" <label
for="levels-to-render"
class="leather bg-transparent text-sm font-medium"
>Levels to render: >Levels to render:
</label> </label>
<label for="event-limit" class="leather bg-transparent text-sm font-medium">Limit: </label> <label for="event-limit" class="leather bg-transparent text-sm font-medium">
Limit:
</label>
<input <input
type="number" type="number"
id="levels-to-render" id="levels-to-render"
@ -45,7 +49,7 @@
/> />
<button <button
onclick={handleUpdate} onclick={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
> >
Update Update
</button> </button>

853
src/lib/components/EventSearch.svelte

@ -1,204 +1,785 @@
<script lang="ts"> <script lang="ts">
import { Input, Button } from "flowbite-svelte"; import { onMount } from 'svelte';
import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from '$lib/utils/nostrUtils';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils'; import { Input, Button } from "flowbite-svelte";
import RelayDisplay from './RelayDisplay.svelte'; import { Spinner } from "flowbite-svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
searchEvent,
searchBySubscription,
searchNip05,
} from "$lib/utils/search_utility";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import type { SearchResult } from '$lib/utils/search_types';
import { userStore } from "$lib/stores/userStore";
import { get } from "svelte/store";
const { loading, error, searchValue, onEventFound, event } = $props<{ // Props definition
let {
loading,
error,
searchValue,
dTagValue,
onEventFound,
onSearchResults,
event,
onClear,
onLoadingChange,
}: {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
searchValue: string | null; searchValue: string | null;
dTagValue: string | null;
onEventFound: (event: NDKEvent) => void; onEventFound: (event: NDKEvent) => void;
onSearchResults: (
firstOrder: NDKEvent[],
secondOrder: NDKEvent[],
tTagEvents: NDKEvent[],
eventIds: Set<string>,
addresses: Set<string>,
searchType?: string,
searchTerm?: string,
) => void;
event: NDKEvent | null; event: NDKEvent | null;
}>(); onClear?: () => void;
onLoadingChange?: (loading: boolean) => void;
} = $props();
// Component state
let searchQuery = $state(""); let searchQuery = $state("");
let localError = $state<string | null>(null); let localError = $state<string | null>(null);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let foundEvent = $state<NDKEvent | null>(null); let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false); let searching = $state(false);
let searchCompleted = $state(false);
let searchResultCount = $state<number | null>(null);
let searchResultType = $state<string | null>(null);
let isResetting = $state(false);
// Internal state for cleanup
let activeSub: any = null;
let currentAbortController: AbortController | null = null;
// Derived values
let hasActiveSearch = $derived(searching && !foundEvent);
let showError = $derived(localError || error);
let showSuccess = $derived(searchCompleted && searchResultCount !== null);
// Track last processed values to prevent loops
let lastProcessedSearchValue = $state<string | null>(null);
let lastProcessedDTagValue = $state<string | null>(null);
let isProcessingSearch = $state(false);
let currentProcessingSearchValue = $state<string | null>(null);
let lastSearchValue = $state<string | null>(null);
let isWaitingForSearchResult = $state(false);
let isUserEditing = $state(false);
// Move search handler functions above all $effect runes
async function handleNip05Search(query: string) {
try {
const foundEvent = await searchNip05(query);
if (foundEvent) {
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, "nip05");
} else {
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, 0, "nip05");
}
} catch (error) {
localError =
error instanceof Error ? error.message : "NIP-05 lookup failed";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
lastSearchValue = null;
}
}
async function handleEventSearch(query: string) {
try {
const foundEvent = await searchEvent(query);
if (!foundEvent) {
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, "event");
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
}
}
async function handleSearchEvent(
clearInput: boolean = true,
queryOverride?: string,
) {
if (searching) {
console.log("EventSearch: Already searching, skipping");
return;
}
resetSearchState();
localError = null;
updateSearchState(true);
isResetting = false;
isUserEditing = false; // Reset user editing flag when search starts
const query = (
queryOverride !== undefined ? queryOverride || "" : searchQuery || ""
).trim();
if (!query) {
updateSearchState(false, false, null, null);
return;
}
if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, "d");
updateSearchState(false, false, null, null);
return;
}
}
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription("t", searchTerm);
return;
}
}
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription("n", searchTerm);
return;
}
}
if (query.includes("@")) {
await handleNip05Search(query);
return;
}
if (clearInput) {
navigateToSearch(query, "id");
// Don't clear searchQuery here - let the effect handle it
}
await handleEventSearch(query);
}
// Keep searchQuery in sync with searchValue and dTagValue props
$effect(() => { $effect(() => {
// Only sync if we're not currently searching, resetting, or if the user is editing
if (searching || isResetting || isUserEditing) {
return;
}
if (dTagValue) {
// If dTagValue is set, show it as "d:tag" in the search bar
searchQuery = `d:${dTagValue}`;
} else if (searchValue) {
// searchValue should already be in the correct format (t:, n:, d:, etc.)
searchQuery = searchValue;
} else if (!searchQuery) {
// Only clear if searchQuery is empty to avoid clearing user input
searchQuery = "";
}
});
// Debounced effect to handle searchValue changes
$effect(() => {
if (
!searchValue ||
searching ||
isResetting ||
isProcessingSearch ||
isWaitingForSearchResult
) {
return;
}
// Check if we've already processed this searchValue
if (searchValue === lastProcessedSearchValue) {
return;
}
// If we already have the event for this searchValue, do nothing
if (foundEvent) {
const currentEventId = foundEvent.id;
let currentNaddr = null;
let currentNevent = null;
let currentNpub = null;
try {
currentNevent = neventEncode(foundEvent, $activeInboxRelays);
} catch {}
try {
currentNaddr = getMatchingTags(foundEvent, "d")[0]?.[1]
? naddrEncode(foundEvent, $activeInboxRelays)
: null;
} catch {}
try {
currentNpub = foundEvent.kind === 0 ? toNpub(foundEvent.pubkey) : null;
} catch {}
// Debug log for comparison
console.log(
"[EventSearch effect] searchValue:",
searchValue,
"foundEvent.id:",
currentEventId,
"foundEvent.pubkey:",
foundEvent.pubkey,
"toNpub(pubkey):",
currentNpub,
"foundEvent.kind:",
foundEvent.kind,
"currentNaddr:",
currentNaddr,
"currentNevent:",
currentNevent,
);
// Also check if searchValue is an nprofile and matches the current event's pubkey
let currentNprofile = null;
if (
searchValue &&
searchValue.startsWith("nprofile1") &&
foundEvent.kind === 0
) {
try {
currentNprofile = nprofileEncode(foundEvent.pubkey, $activeInboxRelays);
} catch {}
}
if (
searchValue === currentEventId ||
(currentNaddr && searchValue === currentNaddr) ||
(currentNevent && searchValue === currentNevent) ||
(currentNpub && searchValue === currentNpub) ||
(currentNprofile && searchValue === currentNprofile)
) {
// Already displaying the event for this searchValue
lastProcessedSearchValue = searchValue;
return;
}
}
// Otherwise, trigger a search for the new value
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
isProcessingSearch = true;
isWaitingForSearchResult = true;
lastProcessedSearchValue = searchValue;
if (searchValue) { if (searchValue) {
searchEvent(false, searchValue); handleSearchEvent(false, searchValue);
}
}, 300);
});
// Add debouncing to prevent rapid successive searches
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Cleanup function to clear timeout when component is destroyed
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
// Simple effect to handle dTagValue changes
$effect(() => {
if (
dTagValue &&
!searching &&
!isResetting &&
dTagValue !== lastProcessedDTagValue
) {
console.log("EventSearch: Processing dTagValue:", dTagValue);
lastProcessedDTagValue = dTagValue;
// Add a small delay to prevent rapid successive calls
setTimeout(() => {
if (!searching && !isResetting) {
handleSearchBySubscription("d", dTagValue);
}
}, 100);
} }
}); });
// Simple effect to handle event prop changes
$effect(() => { $effect(() => {
if (event && !searching && !isResetting) {
foundEvent = event; foundEvent = event;
}
}); });
async function searchEvent(clearInput: boolean = true, queryOverride?: string) { // Search utility functions
function updateSearchState(
isSearching: boolean,
completed: boolean = false,
count: number | null = null,
type: string | null = null,
) {
searching = isSearching;
searchCompleted = completed;
searchResultCount = count;
searchResultType = type;
if (onLoadingChange) {
onLoadingChange(isSearching);
}
}
function resetSearchState() {
isResetting = true;
foundEvent = null;
localError = null; localError = null;
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim(); lastProcessedSearchValue = null;
if (!query) return; lastProcessedDTagValue = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
updateSearchState(false, false, null, null);
// Only update the URL if this is a manual search // Cancel ongoing search
if (clearInput) { if (currentAbortController) {
const encoded = encodeURIComponent(query); currentAbortController.abort();
goto(`?id=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true }); currentAbortController = null;
} }
if (clearInput) { // Clean up subscription
searchQuery = ''; if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
} }
// Clean the query // Clear search results
let cleanedQuery = query.replace(/^nostr:/, ''); onSearchResults([], [], [], new Set(), new Set());
let filterOrId: any = cleanedQuery;
console.log('[Events] Cleaned query:', cleanedQuery);
// NIP-05 address pattern: user@domain // Clear any pending timeout
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(cleanedQuery)) { if (searchTimeout) {
try { clearTimeout(searchTimeout);
const [name, domain] = cleanedQuery.split('@'); searchTimeout = null;
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`);
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
filterOrId = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (profileEvent) {
handleFoundEvent(profileEvent);
return;
} else {
localError = 'No profile found for this NIP-05 address.';
return;
} }
} else {
localError = 'NIP-05 address not found.'; // Reset the flag after a short delay to allow effects to settle
return; setTimeout(() => {
isResetting = false;
}, 100);
} }
function handleFoundEvent(event: NDKEvent) {
foundEvent = event;
localError = null; // Clear local error when event is found
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) { } catch (e) {
localError = 'Error resolving NIP-05 address.'; console.warn("Error stopping subscription:", e);
return; }
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
// Clear search state
searching = false;
searchCompleted = true;
searchResultCount = 1;
searchResultType = "event";
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
lastSearchValue = searchValue;
} }
// Reset processing flag
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
onEventFound(event);
} }
// If it's a 64-char hex, try as event id first, then as pubkey (profile) function navigateToSearch(query: string, paramName: string) {
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) { const encoded = encodeURIComponent(query);
// Try as event id goto(`?${paramName}=${encoded}`, {
filterOrId = cleanedQuery; replaceState: false,
const eventResult = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); keepFocus: true,
// Always try as pubkey (profile event) as well noScroll: true,
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; });
const profileEvent = await fetchEventWithFallback($ndkInstance, profileFilter, 10000); }
// Prefer profile if found and pubkey matches query
if (profileEvent && profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()) { // Search handlers
handleFoundEvent(profileEvent); async function handleSearchBySubscription(
} else if (eventResult) { searchType: "d" | "t" | "n",
handleFoundEvent(eventResult); searchTerm: string,
) {
console.log("EventSearch: Starting subscription search:", {
searchType,
searchTerm,
});
isResetting = false; // Allow effects to run for new searches
localError = null;
updateSearchState(true);
// Wait for relays to be available (with timeout)
let retryCount = 0;
const maxRetries = 20; // Wait up to 10 seconds (20 * 500ms) for user login to complete
while ($activeInboxRelays.length === 0 && $activeOutboxRelays.length === 0 && retryCount < maxRetries) {
console.debug(`EventSearch: Waiting for relays... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 500)); // Wait 500ms
retryCount++;
} }
// Additional wait for user-specific relays if user is logged in
const currentUser = get(userStore);
if (currentUser.signedIn && currentUser.pubkey) {
console.debug(`EventSearch: User is logged in (${currentUser.pubkey}), waiting for user-specific relays...`);
retryCount = 0;
while ($activeOutboxRelays.length <= 9 && retryCount < maxRetries) {
// If we still have the default relay count (9), wait for user-specific relays
console.debug(`EventSearch: Waiting for user-specific relays... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 500));
retryCount++;
}
}
// Check if we have any relays available
if ($activeInboxRelays.length === 0 && $activeOutboxRelays.length === 0) {
console.warn("EventSearch: No relays available after waiting, failing search");
localError = "No relays available. Please check your connection and try again.";
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
searching = false;
return; return;
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) { }
console.log("EventSearch: Relays available, proceeding with search:", {
inboxCount: $activeInboxRelays.length,
outboxCount: $activeOutboxRelays.length
});
try { try {
const decoded = nip19.decode(cleanedQuery); // Cancel existing search
if (!decoded) throw new Error('Invalid identifier'); if (currentAbortController) {
console.log('[Events] Decoded NIP-19:', decoded); currentAbortController.abort();
switch (decoded.type) { }
case 'nevent': currentAbortController = new AbortController();
filterOrId = decoded.data.id; // Add a timeout to prevent hanging searches
break; const searchPromise = searchBySubscription(
case 'note': searchType,
filterOrId = decoded.data; searchTerm,
break; {
case 'naddr': onSecondOrderUpdate: (updatedResult) => {
filterOrId = { console.log("EventSearch: Second order update:", updatedResult);
kinds: [decoded.data.kind], onSearchResults(
authors: [decoded.data.pubkey], updatedResult.events,
'#d': [decoded.data.identifier], updatedResult.secondOrder,
}; updatedResult.tTagEvents,
break; updatedResult.eventIds,
case 'nprofile': updatedResult.addresses,
filterOrId = { updatedResult.searchType,
kinds: [0], updatedResult.searchTerm,
authors: [decoded.data.pubkey], );
}; },
break; onSubscriptionCreated: (sub) => {
case 'npub': console.log("EventSearch: Subscription created:", sub);
filterOrId = { if (activeSub) {
kinds: [0], activeSub.stop();
authors: [decoded.data],
};
break;
default:
filterOrId = cleanedQuery;
} }
console.log('[Events] Using filterOrId:', filterOrId); activeSub = sub;
},
},
currentAbortController.signal,
);
// Add a 30-second timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Search timeout: No results received within 30 seconds"));
}, 30000);
});
const result = await Promise.race([searchPromise, timeoutPromise]) as any;
console.log("EventSearch: Search completed:", result);
onSearchResults(
result.events,
result.secondOrder,
result.tTagEvents,
result.eventIds,
result.addresses,
result.searchType,
result.searchTerm,
);
const totalCount =
result.events.length +
result.secondOrder.length +
result.tTagEvents.length;
localError = null; // Clear local error when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) { } catch (e) {
console.error('[Events] Invalid Nostr identifier:', cleanedQuery, e); console.warn("Error stopping subscription:", e);
localError = 'Invalid Nostr identifier.'; }
return; activeSub = null;
} }
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
} }
updateSearchState(false, true, totalCount, searchType);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
}
} catch (error) {
if (error instanceof Error && error.message === "Search cancelled") {
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
return;
}
console.error("EventSearch: Search failed:", error);
localError = error instanceof Error ? error.message : "Search failed";
// Provide more specific error messages for different failure types
if (error instanceof Error) {
if (
error.message.includes("timeout") ||
error.message.includes("connection")
) {
localError =
"Search timed out. The relays may be temporarily unavailable. Please try again.";
} else if (error.message.includes("NDK not initialized")) {
localError =
"Nostr client not initialized. Please refresh the page and try again.";
} else {
localError = `Search failed: ${error.message}`;
}
}
localError = null; // Clear local error when search fails
// Stop any ongoing subscription
if (activeSub) {
try { try {
console.log('Searching for event:', filterOrId); activeSub.stop();
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); } catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
if (!event) { // Update last processed search value to prevent re-processing even on error
console.warn('[Events] Event not found for filterOrId:', filterOrId); if (searchValue) {
localError = 'Event not found'; lastProcessedSearchValue = searchValue;
} else {
console.log('[Events] Event found:', event);
handleFoundEvent(event);
} }
} catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', query);
localError = 'Error fetching event. Please check the ID and try again.';
} }
} }
function handleFoundEvent(event: NDKEvent) { function handleClear() {
foundEvent = event; isResetting = true;
onEventFound(event); searchQuery = "";
isUserEditing = false; // Reset user editing flag
resetSearchState();
// Clear URL parameters to reset the page
goto("", {
replaceState: true,
keepFocus: true,
noScroll: true,
});
// Ensure all search state is cleared
searching = false;
searchCompleted = false;
searchResultCount = null;
searchResultType = null;
foundEvent = null;
localError = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
isWaitingForSearchResult = false;
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
if (onClear) {
onClear();
}
// Reset the flag after a short delay to allow effects to settle
setTimeout(() => {
isResetting = false;
}, 100);
}
function getResultMessage(): string {
if (searchResultCount === 0) {
return "Search completed. No results found.";
}
const typeLabel =
searchResultType === "n"
? "profile"
: searchResultType === "nip05"
? "NIP-05 address"
: "event";
const countLabel = searchResultType === "n" ? "profiles" : "events";
return searchResultCount === 1
? `Search completed. Found 1 ${typeLabel}.`
: `Search completed. Found ${searchResultCount} ${countLabel}.`;
}
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);
} }
</script> </script>
<div class="flex flex-col space-y-6"> <div class="flex flex-col space-y-6">
<div class="flex gap-2"> <!-- Search Input Section -->
<div class="flex gap-2 items-center">
<Input <Input
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Enter event ID, nevent, or naddr..." placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..."
class="flex-grow" class="flex-grow"
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent(true)} onkeydown={(e: KeyboardEvent) =>
e.key === "Enter" && handleSearchEvent(true)}
oninput={() => (isUserEditing = true)}
onblur={() => (isUserEditing = false)}
/> />
<Button on:click={() => searchEvent(true)} disabled={loading}> <Button onclick={() => handleSearchEvent(true)} disabled={loading}>
{loading ? 'Searching...' : 'Search'} {#if searching}
<Spinner class="mr-2 text-gray-600 dark:text-gray-300" size="5" />
{/if}
{searching ? "Searching..." : "Search"}
</Button>
<Button
onclick={handleClear}
color="alternative"
type="button"
disabled={loading}
>
Clear
</Button> </Button>
</div> </div>
{#if localError || error} <!-- Error Display -->
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> {#if showError}
<div
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{localError || error} {localError || error}
{#if searchQuery.trim()}
<div class="mt-2">
You can also try viewing this event on
<a
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
rel="noopener"
>Njump</a>.
</div>
{/if}
</div> </div>
{/if} {/if}
<div class="mt-4"> <!-- Success Display -->
<div class="flex flex-wrap gap-2"> {#if showSuccess}
{#each Object.entries(relayStatuses) as [relay, status]} <div
<RelayDisplay {relay} showStatus={true} status={status} /> class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg"
{/each} role="alert"
>
{getResultMessage()}
</div> </div>
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')}
<div class="text-gray-500 mt-2">Searching relays...</div>
{/if}
{#if !foundEvent && !searching && Object.values(relayStatuses).every(s => s !== 'pending')}
<div class="text-red-500 mt-2">Event not found on any relay.</div>
{/if} {/if}
</div>
</div> </div>

76
src/lib/components/Login.svelte

@ -1,76 +0,0 @@
<script lang='ts'>
import { type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { activePubkey, loginWithExtension, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover } from 'flowbite-svelte';
import Profile from "$components/util/Profile.svelte";
let profile = $state<NDKUserProfile | null>(null);
let npub = $state<string | undefined >(undefined);
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
$effect(() => {
if ($ndkSignedIn) {
$ndkInstance
.getUser({ pubkey: $activePubkey ?? undefined })
?.fetchProfile()
.then(userProfile => {
profile = userProfile;
});
npub = $ndkInstance.activeUser?.npub;
}
});
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = '';
const user = await loginWithExtension();
if (!user) {
throw new Error('The NIP-07 extension did not return a user.');
}
profile = await user.fetchProfile();
persistLogin(user);
} catch (e) {
console.error(e);
signInFailed = true;
errorMessage = e instanceof Error ? e.message : 'Failed to sign in. Please try again.';
}
}
</script>
<div class="m-4">
{#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} />
{:else}
<Avatar rounded class='h-6 w-6 cursor-pointer bg-transparent' id='avatar' />
<Popover
class='popover-leather w-fit'
placement='bottom'
triggeredBy='#avatar'
>
<div class='w-full flex flex-col space-y-2'>
<Button
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
{#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage}
</div>
{/if}
<!-- <Button
color='alternative'
on:click={signInWithBunker}
>
Bunker Sign-In
</Button> -->
</div>
</Popover>
{/if}
</div>

84
src/lib/components/LoginModal.svelte

@ -1,77 +1,81 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from '$lib/ndk'; import { loginWithExtension } from "$lib/stores/userStore";
import { userStore } from "$lib/stores/userStore";
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{ const {
show = false,
onClose = () => {},
onLoginSuccess = () => {},
} = $props<{
show?: boolean; show?: boolean;
onClose?: () => void; onClose?: () => void;
onLoginSuccess?: () => void; onLoginSuccess?: () => void;
}>(); }>();
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>(''); let errorMessage = $state<string>("");
let user = $state($userStore);
let modalOpen = $state(show);
userStore.subscribe((val) => (user = val));
$effect(() => { $effect(() => {
if ($ndkSignedIn && show) { modalOpen = show;
});
$effect(() => {
if (user.signedIn && show) {
onLoginSuccess(); onLoginSuccess();
onClose(); onClose();
} }
}); });
$effect(() => {
if (!modalOpen) {
onClose();
}
});
async function handleSignInClick() { async function handleSignInClick() {
try { try {
signInFailed = false; signInFailed = false;
errorMessage = ''; errorMessage = "";
const user = await loginWithExtension(); await loginWithExtension();
if (!user) {
throw new Error('The NIP-07 extension did not return a user.');
}
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
signInFailed = true; signInFailed = true;
errorMessage = (e as Error)?.message ?? 'Failed to sign in. Please try again.'; errorMessage =
(e as Error)?.message ?? "Failed to sign in. Please try again.";
} }
} }
</script> </script>
{#if show} <Modal
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50"> class="modal-leather"
<div class="relative w-auto my-6 mx-auto max-w-3xl"> title="Login Required"
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none"> bind:open={modalOpen}
<!-- Header --> autoclose
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t"> outsideclose
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">Login Required</h3> size="sm"
<button >
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none" <p class="text-base leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
onclick={onClose} You need to be logged in to submit an issue. Your form data will be
> preserved.
<span class="bg-transparent text-gray-500 dark:text-gray-400 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span>
</button>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400 mb-6">
You need to be logged in to submit an issue. Your form data will be preserved.
</p> </p>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<div class="flex justify-center"> <div class="flex justify-center">
<Button <Button color="primary" onclick={handleSignInClick}>
color="primary"
onclick={handleSignInClick}
>
Sign in with Extension Sign in with Extension
</Button> </Button>
</div> </div>
{#if signInFailed} {#if signInFailed}
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded"> <div
class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded"
>
{errorMessage} {errorMessage}
</div> </div>
{/if} {/if}
</div> </div>
</div> </Modal>
</div>
</div>
</div>
{/if}

12
src/lib/components/Modal.svelte

@ -1,12 +0,0 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
export let showModal;
export let event: NDKEvent;
// let str: string = JSON.stringify(event);
</script>
{#if showModal}
<div class="backdrop">
<div class="Modal">{event.id}</div>
</div>
{/if}

8
src/lib/components/Navigation.svelte

@ -7,9 +7,12 @@
NavHamburger, NavHamburger,
NavBrand, NavBrand,
} from "flowbite-svelte"; } from "flowbite-svelte";
import Login from "./Login.svelte"; import Profile from "./util/Profile.svelte";
import { userStore } from "$lib/stores/userStore";
let { class: className = "" } = $props(); let { class: className = "" } = $props();
let userState = $derived($userStore);
</script> </script>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}> <Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
@ -19,11 +22,12 @@
</NavBrand> </NavBrand>
</div> </div>
<div class="flex md:order-2"> <div class="flex md:order-2">
<Login /> <Profile isNav={true} pubkey={userState.npub || undefined} />
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">
<NavLi href="/">Publications</NavLi> <NavLi href="/">Publications</NavLi>
<NavLi href="/new/compose">Compose</NavLi>
<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>

59
src/lib/components/NetworkStatus.svelte

@ -0,0 +1,59 @@
<script lang="ts">
import { networkCondition, isNetworkChecking, startNetworkStatusMonitoring } from '$lib/stores/networkStore';
import { NetworkCondition } from '$lib/utils/network_detection';
import { onMount } from 'svelte';
function getStatusColor(): string {
switch ($networkCondition) {
case NetworkCondition.ONLINE:
return 'text-green-600 dark:text-green-400';
case NetworkCondition.SLOW:
return 'text-yellow-600 dark:text-yellow-400';
case NetworkCondition.OFFLINE:
return 'text-red-600 dark:text-red-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
}
function getStatusIcon(): string {
switch ($networkCondition) {
case NetworkCondition.ONLINE:
return '🟢';
case NetworkCondition.SLOW:
return '🟡';
case NetworkCondition.OFFLINE:
return '🔴';
default:
return '⚪';
}
}
function getStatusText(): string {
switch ($networkCondition) {
case NetworkCondition.ONLINE:
return 'Online';
case NetworkCondition.SLOW:
return 'Slow Connection';
case NetworkCondition.OFFLINE:
return 'Offline';
default:
return 'Unknown';
}
}
onMount(() => {
// Start centralized network monitoring
startNetworkStatusMonitoring();
});
</script>
<div class="flex items-center space-x-2 text-xs {getStatusColor()} font-medium">
{#if $isNetworkChecking}
<span class="animate-spin"></span>
<span>Checking...</span>
{:else}
<span class="text-lg">{getStatusIcon()}</span>
<span>{getStatusText()}</span>
{/if}
</div>

187
src/lib/components/Preview.svelte

@ -1,11 +1,27 @@
<script lang='ts'> <script lang="ts">
import { pharosInstance, SiblingSearchDirection } from '$lib/parser'; import { pharosInstance, SiblingSearchDirection } from "$lib/parser";
import { Button, ButtonGroup, CloseButton, Input, P, Textarea, Tooltip } from 'flowbite-svelte'; import {
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons'; Button,
import Self from './Preview.svelte'; ButtonGroup,
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte'; CloseButton,
Input,
P,
Textarea,
Tooltip,
} from "flowbite-svelte";
import {
CaretDownSolid,
CaretUpSolid,
EditOutline,
} from "flowbite-svelte-icons";
import Self from "./Preview.svelte";
import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
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";
// TODO: Fix move between parents. // TODO: Fix move between parents.
@ -21,7 +37,7 @@
index, index,
sectionClass, sectionClass,
publicationType, publicationType,
onBlogUpdate onBlogUpdate,
} = $props<{ } = $props<{
allowEditing?: boolean; allowEditing?: boolean;
depth?: number; depth?: number;
@ -39,7 +55,9 @@
let currentContent: string = $state($pharosInstance.getContent(rootId)); let currentContent: string = $state($pharosInstance.getContent(rootId));
let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId)); let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId));
let orderedChildren: string[] = $state($pharosInstance.getOrderedChildIds(rootId)); let orderedChildren: string[] = $state(
$pharosInstance.getOrderedChildIds(rootId),
);
let blogEntries = $state(Array.from($pharosInstance.getBlogEntries())); let blogEntries = $state(Array.from($pharosInstance.getBlogEntries()));
let metadata = $state($pharosInstance.getIndexMetadata()); let metadata = $state($pharosInstance.getIndexMetadata());
@ -86,8 +104,16 @@
$effect(() => { $effect(() => {
if (parentId && allowEditing) { if (parentId && allowEditing) {
// Check for previous/next siblings on load // Check for previous/next siblings on load
const previousSibling = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Previous); const previousSibling = $pharosInstance.getNearestSibling(
const nextSibling = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Next); rootId,
depth - 1,
SiblingSearchDirection.Previous,
);
const nextSibling = $pharosInstance.getNearestSibling(
rootId,
depth - 1,
SiblingSearchDirection.Next,
);
// Hide arrows if no siblings exist // Hide arrows if no siblings exist
hasPreviousSibling = !!previousSibling[0]; hasPreviousSibling = !!previousSibling[0];
@ -102,23 +128,26 @@
function byline(rootId: string, index: number) { function byline(rootId: string, index: number) {
console.log(rootId, index, blogEntries); console.log(rootId, index, blogEntries);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const author = event ? getMatchingTags(event, 'author')[0][1] : ''; const author = event ? getMatchingTags(event, "author")[0][1] : "";
return author ?? ""; return author ?? "";
} }
function hasCoverImage(rootId: string, index: number) { function hasCoverImage(rootId: string, index: number) {
console.log(rootId); console.log(rootId);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const image = event && getMatchingTags(event, 'image')[0] ? getMatchingTags(event, 'image')[0][1] : ''; const image =
return image ?? ''; event && getMatchingTags(event, "image")[0]
? getMatchingTags(event, "image")[0][1]
: "";
return image ?? "";
} }
function publishedAt(rootId: string, index: number) { function publishedAt(rootId: string, index: number) {
console.log(rootId, index); console.log(rootId, index);
console.log(blogEntries[index]); console.log(blogEntries[index]);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const date = event.created_at ? new Date(event.created_at * 1000) : ''; const date = event.created_at ? new Date(event.created_at * 1000) : "";
if (date !== '') { if (date !== "") {
const formattedDate = new Intl.DateTimeFormat("en-US", { const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -126,14 +155,14 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function readBlog(rootId:string) { function readBlog(rootId: string) {
onBlogUpdate?.(rootId); onBlogUpdate?.(rootId);
} }
function propagateBlogUpdate(rootId:string) { function propagateBlogUpdate(rootId: string) {
onBlogUpdate?.(rootId); onBlogUpdate?.(rootId);
} }
@ -167,7 +196,6 @@
if (editing && shouldSave) { if (editing && shouldSave) {
if (orderedChildren.length > 0) { if (orderedChildren.length > 0) {
} }
$pharosInstance.updateEventContent(id, currentContent); $pharosInstance.updateEventContent(id, currentContent);
@ -178,7 +206,11 @@
function moveUp(rootId: string, parentId: string) { function moveUp(rootId: string, parentId: string) {
// Get the previous sibling and its index // Get the previous sibling and its index
const [prevSiblingId, prevIndex] = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Previous); const [prevSiblingId, prevIndex] = $pharosInstance.getNearestSibling(
rootId,
depth - 1,
SiblingSearchDirection.Previous,
);
if (!prevSiblingId || prevIndex == null) { if (!prevSiblingId || prevIndex == null) {
return; return;
} }
@ -186,11 +218,15 @@
// Move the current event before the previous sibling. // Move the current event before the previous sibling.
$pharosInstance.moveEvent(rootId, prevSiblingId, false); $pharosInstance.moveEvent(rootId, prevSiblingId, false);
needsUpdate = true; needsUpdate = true;
}; }
function moveDown(rootId: string, parentId: string) { function moveDown(rootId: string, parentId: string) {
// Get the next sibling and its index // Get the next sibling and its index
const [nextSiblingId, nextIndex] = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Next); const [nextSiblingId, nextIndex] = $pharosInstance.getNearestSibling(
rootId,
depth - 1,
SiblingSearchDirection.Next,
);
if (!nextSiblingId || nextIndex == null) { if (!nextSiblingId || nextIndex == null) {
return; return;
} }
@ -203,7 +239,9 @@
{#snippet sectionHeading(title: string, depth: number)} {#snippet sectionHeading(title: string, depth: number)}
{@const headingLevel = Math.min(depth + 1, 6)} {@const headingLevel = Math.min(depth + 1, 6)}
{@const className = $pharosInstance.isFloatingTitle(rootId) ? 'discrete' : 'h-leather'} {@const className = $pharosInstance.isFloatingTitle(rootId)
? "discrete"
: "h-leather"}
<svelte:element this={`h${headingLevel}`} class={className}> <svelte:element this={`h${headingLevel}`} class={className}>
{title} {title}
@ -219,25 +257,25 @@
{/snippet} {/snippet}
{#snippet blogMetadata(rootId: string, index: number)} {#snippet blogMetadata(rootId: string, index: number)}
<p class='h-leather'> <p class="h-leather">
by {byline(rootId, index)} by {byline(rootId, index)}
</p> </p>
<p class='h-leather italic text-sm'> <p class="h-leather italic text-sm">
{publishedAt(rootId, index)} {publishedAt(rootId, index)}
</p> </p>
{/snippet} {/snippet}
{#snippet contentParagraph(content: string, publicationType: string)} {#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === 'novel'} {#if publicationType === "novel"}
<P class='whitespace-normal' firstupper={isSectionStart}> <P class="whitespace-normal" firstupper={isSectionStart}>
{@html content} {@html content}
</P> </P>
{:else if publicationType === 'blog'} {:else if publicationType === "blog"}
<P class='whitespace-normal' firstupper={false}> <P class="whitespace-normal" firstupper={false}>
{@html content} {@html content}
</P> </P>
{:else} {:else}
<P class='whitespace-normal' firstupper={false}> <P class="whitespace-normal" firstupper={false}>
{@html content} {@html content}
</P> </P>
{/if} {/if}
@ -249,28 +287,31 @@
class={`note-leather flex space-x-2 justify-between text-wrap break-words ${sectionClass}`} class={`note-leather flex space-x-2 justify-between text-wrap break-words ${sectionClass}`}
onmouseenter={handleMouseEnter} onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave} onmouseleave={handleMouseLeave}
aria-label='Publication section' aria-label="Publication section"
> >
<!-- Zettel base case --> <!-- Zettel base case -->
{#if orderedChildren.length === 0 || depth >= 4} {#if orderedChildren.length === 0 || depth >= 4}
{#key updateCount} {#key updateCount}
{#if isEditing} {#if isEditing}
<form class='w-full'> <form class="w-full">
<Textarea class='textarea-leather w-full whitespace-normal' bind:value={currentContent}> <Textarea
<div slot='footer' class='flex space-x-2 justify-end'> class="textarea-leather w-full whitespace-normal"
bind:value={currentContent}
>
<div slot="footer" class="flex space-x-2 justify-end">
<Button <Button
type='reset' type="reset"
class='btn-leather min-w-fit' class="btn-leather min-w-fit"
size='sm' size="sm"
outline outline
onclick={() => toggleEditing(rootId, false)} onclick={() => toggleEditing(rootId, false)}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type='submit' type="submit"
class='btn-leather min-w-fit' class="btn-leather min-w-fit"
size='sm' size="sm"
onclick={() => toggleEditing(rootId, true)} onclick={() => toggleEditing(rootId, true)}
> >
Save Save
@ -283,32 +324,43 @@
{/if} {/if}
{/key} {/key}
{:else} {:else}
<div class='flex flex-col space-y-2 w-full'> <div class="flex flex-col space-y-2 w-full">
{#if isEditing} {#if isEditing}
<ButtonGroup class='w-full'> <ButtonGroup class="w-full">
<Input type='text' class='input-leather' size='lg' bind:value={title}> <Input type="text" class="input-leather" size="lg" bind:value={title}>
<CloseButton slot='right' onclick={() => toggleEditing(rootId, false)} /> <CloseButton
slot="right"
onclick={() => toggleEditing(rootId, false)}
/>
</Input> </Input>
<Button class='btn-leather' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}> <Button
class="btn-leather"
color="primary"
size="lg"
onclick={() => toggleEditing(rootId, true)}
>
Save Save
</Button> </Button>
</ButtonGroup> </ButtonGroup>
{:else} {:else if !(publicationType === "blog" && depth === 1)}
{#if !(publicationType === 'blog' && depth === 1)}
{@render sectionHeading(title!, depth)} {@render sectionHeading(title!, depth)}
{/if} {/if}
{/if}
<!-- Recurse on child indices and zettels --> <!-- Recurse on child indices and zettels -->
{#if publicationType === 'blog' && depth === 1} {#if publicationType === "blog" && depth === 1}
<BlogHeader event={getBlogEvent(index)} rootId={rootId} onBlogUpdate={readBlog} active={true} /> <BlogHeader
{:else } event={getBlogEvent(index)}
{rootId}
onBlogUpdate={readBlog}
active={true}
/>
{:else}
{#key subtreeUpdateCount} {#key subtreeUpdateCount}
{#each orderedChildren as id, index} {#each orderedChildren as id, index}
<Self <Self
rootId={id} rootId={id}
parentId={rootId} parentId={rootId}
index={index} {index}
publicationType={publicationType} {publicationType}
depth={depth + 1} depth={depth + 1}
{allowEditing} {allowEditing}
{sectionClass} {sectionClass}
@ -324,21 +376,38 @@
</div> </div>
{/if} {/if}
{#if allowEditing && depth > 0} {#if allowEditing && depth > 0}
<div class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? 'visible' : 'invisible'}`}> <div
class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? "visible" : "invisible"}`}
>
{#if hasPreviousSibling && parentId} {#if hasPreviousSibling && parentId}
<Button class='btn-leather' size='sm' outline onclick={() => moveUp(rootId, parentId)}> <Button
class="btn-leather"
size="sm"
outline
onclick={() => moveUp(rootId, parentId)}
>
<CaretUpSolid /> <CaretUpSolid />
</Button> </Button>
{/if} {/if}
{#if hasNextSibling && parentId} {#if hasNextSibling && parentId}
<Button class='btn-leather' size='sm' outline onclick={() => moveDown(rootId, parentId)}> <Button
class="btn-leather"
size="sm"
outline
onclick={() => moveDown(rootId, parentId)}
>
<CaretDownSolid /> <CaretDownSolid />
</Button> </Button>
{/if} {/if}
<Button class='btn-leather' size='sm' outline onclick={() => toggleEditing(rootId)}> <Button
class="btn-leather"
size="sm"
outline
onclick={() => toggleEditing(rootId)}
>
<EditOutline /> <EditOutline />
</Button> </Button>
<Tooltip class='tooltip-leather' type='auto' placement='top'> <Tooltip class="tooltip-leather" type="auto" placement="top">
Edit Edit
</Tooltip> </Tooltip>
</div> </div>

254
src/lib/components/PublicationFeed.svelte

@ -1,254 +0,0 @@
<script lang='ts'>
import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents, debounce } from '$lib/utils';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.svelte';
import { onMount } from 'svelte';
import { getMatchingTags, NDKRelaySetFromNDK, type NDKEvent, type NDKRelaySet } from '$lib/utils/nostrUtils';
let { relays, fallbackRelays, searchQuery = '' } = $props<{ relays: string[], fallbackRelays: string[], searchQuery?: string }>();
let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let loading: boolean = $state(true);
let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime()
);
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
console.debug('[PublicationFeed] Search query changed:', query);
if (query.trim()) {
console.debug('[PublicationFeed] Clearing events and searching with query:', query);
eventsInView = [];
await getEvents(undefined, query, true);
} else {
console.debug('[PublicationFeed] Clearing events and resetting search');
eventsInView = [];
await getEvents(undefined, '', true);
}
}, 300);
$effect(() => {
console.debug('[PublicationFeed] Search query effect triggered:', searchQuery);
debouncedSearch(searchQuery);
});
async function getEvents(before: number | undefined = undefined, search: string = '', reset: boolean = false) {
loading = true;
const ndk = $ndkInstance;
const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r));
relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending']));
let allEvents: NDKEvent[] = [];
let fetchedCount = 0; // Track number of new events
console.debug('[getEvents] Called with before:', before, 'search:', search);
// Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => {
if (!search) return events;
const query = search.toLowerCase();
console.debug('[PublicationFeed] Filtering events with query:', query, 'Total events before filter:', events.length);
// Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query);
console.debug('[PublicationFeed] Is NIP-05 query:', isNip05Query);
const filtered = events.filter(event => {
const title = getMatchingTags(event, 'title')[0]?.[1]?.toLowerCase() ?? '';
const authorName = getMatchingTags(event, 'author')[0]?.[1]?.toLowerCase() ?? '';
const authorPubkey = event.pubkey.toLowerCase();
const nip05 = getMatchingTags(event, 'nip05')[0]?.[1]?.toLowerCase() ?? '';
// For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) {
const matches = nip05 === query;
if (matches) {
console.debug('[PublicationFeed] Event matches NIP-05 search:', {
id: event.id,
nip05,
authorPubkey
});
}
return matches;
}
// For regular queries, match against all fields
const matches = (
title.includes(query) ||
authorName.includes(query) ||
authorPubkey.includes(query) ||
nip05.includes(query)
);
if (matches) {
console.debug('[PublicationFeed] Event matches search:', {
id: event.id,
title,
authorName,
authorPubkey,
nip05
});
}
return matches;
});
console.debug('[PublicationFeed] Events after filtering:', filtered.length);
return filtered;
};
// First, try primary relays
let foundEventsInPrimary = false;
await Promise.all(
primaryRelays.map(async (relay: string) => {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 30,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
foundEventsInPrimary = true;
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`);
} catch (err) {
console.error(`Error fetching from primary relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
// Only try fallback relays if no events were found in primary relays
if (!foundEventsInPrimary && fallback.length > 0) {
console.debug('[getEvents] No events found in primary relays, trying fallback relays');
relayStatuses = { ...relayStatuses, ...Object.fromEntries(fallback.map((r: string) => [r, 'pending'])) };
await Promise.all(
fallback.map(async (relay: string) => {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 18,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`);
} catch (err) {
console.error(`Error fetching from fallback relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
}
// Deduplicate and sort
const eventMap = reset
? new Map(allEvents.map(event => [event.tagAddress(), event]))
: new Map([...eventsInView, ...allEvents].map(event => [event.tagAddress(), event]));
const uniqueEvents = Array.from(eventMap.values());
uniqueEvents.sort((a, b) => b.created_at! - a.created_at!);
eventsInView = uniqueEvents;
const pageSize = fallback.length > 0 ? 18 : 30;
if (fetchedCount < pageSize) {
endOfFeed = true;
} else {
endOfFeed = false;
}
console.debug(`[getEvents] Total unique events after deduplication: ${uniqueEvents.length}`);
console.debug(`[getEvents] endOfFeed set to: ${endOfFeed} (fetchedCount: ${fetchedCount}, pageSize: ${pageSize})`);
loading = false;
console.debug('Relay statuses:', relayStatuses);
}
const getSkeletonIds = (): string[] => {
const skeletonHeight = 124; // The height of the skeleton component in pixels.
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {
skeletonIds.push(`skeleton-${i}`);
}
return skeletonIds;
}
async function loadMorePublications() {
loadingMore = true;
await getEvents(cutoffTimestamp, searchQuery, false);
loadingMore = false;
}
onMount(async () => {
await getEvents();
});
</script>
<div class='leather'>
<div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}
<ArticleHeader {event} />
{/each}
{:else}
<div class='col-span-full'>
<p class='text-center'>No publications found.</p>
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'>
<Button outline class="w-full max-w-md" onclick={async () => {
await loadMorePublications();
}}>
Show more publications
</Button>
</div>
{:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'>
<Button outline disabled class="w-full max-w-md">
<Spinner class='mr-3 text-gray-300' size='4' />
Loading...
</Button>
</div>
{:else}
<div class='flex justify-center mt-4 mb-8'>
<P class='text-sm text-gray-600'>You've reached the end of the feed.</P>
</div>
{/if}
</div>

64
src/lib/components/PublicationHeader.svelte

@ -1,64 +0,0 @@
<script lang="ts">
import { ndkInstance } from '$lib/ndk';
import { naddrEncode } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { standardRelays } from '../consts';
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>();
const relays = $derived.by(() => {
return $ndkInstance.activeUser?.relayUrls ?? standardRelays;
});
const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
}
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
console.log("PublicationHeader event:", event);
</script>
{#if title != null && href != null}
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'>
{#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden">
<Img src={image} class="rounded w-full h-full object-cover"/>
</div>
{/if}
<div class='col flex flex-row flex-grow space-x-4'>
<div class="flex flex-col flex-grow">
<a href="/{href}" class='flex flex-col space-y-2'>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
<h3 class='text-base font-normal'>
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
{:else}
{author}
{/if}
</h3>
{#if version != '1'}
<h3 class='text-base font-thin'>version: {version}</h3>
{/if}
</a>
</div>
<div class="flex flex-col justify-start items-center">
<CardActions event={event} />
</div>
</div>
</Card>
{/if}

121
src/lib/components/PublicationSection.svelte

@ -1,121 +0,0 @@
<script lang='ts'>
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils';
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string,
rootAddress: string,
leaves: Array<NDKEvent | null>,
ref: (ref: HTMLElement) => void,
} = $props();
const publicationTree: PublicationTree = getContext('publicationTree');
const asciidoctor: Asciidoctor = getContext('asciidoctor');
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(address));
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(rootAddress));
let publicationType: Promise<string | undefined> = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () =>
await publicationTree.getHierarchy(address));
let leafTitle: Promise<string | undefined> = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]);
let leafContent: Promise<string | Document> = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? ''));
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number;
let event: NDKEvent | null = null;
let decrement = 1;
do {
index = leaves.findIndex(leaf => leaf?.tagAddress() === address);
if (index === 0) {
return null;
}
event = leaves[index - decrement++];
} while (event == null && index - decrement >= 0);
return event;
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
});
let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]);
const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) {
for (let i = 0; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
}
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
let sectionRef: HTMLElement;
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
});
</script>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)}
{/await}
</section>

166
src/lib/components/RelayActions.svelte

@ -1,11 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils'; import {
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte'; createRelaySetFromUrls,
import { standardRelays, fallbackRelays } from "$lib/consts"; createNDKEvent,
} from "$lib/utils/nostrUtils";
import RelayDisplay, {
getConnectedRelays,
getEventRelays,
} from "./RelayDisplay.svelte";
import { communityRelays, secondaryRelays } from "$lib/consts";
const { event } = $props<{ const { event } = $props<{
event: NDKEvent; event: NDKEvent;
@ -13,11 +19,10 @@
let searchingRelays = $state(false); let searchingRelays = $state(false);
let foundRelays = $state<string[]>([]); let foundRelays = $state<string[]>([]);
let broadcasting = $state(false);
let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false); let showRelayModal = $state(false);
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); let relaySearchResults = $state<
Record<string, "pending" | "found" | "notfound">
>({});
let allRelays = $state<string[]>([]); let allRelays = $state<string[]>([]);
// Magnifying glass icon SVG // Magnifying glass icon SVG
@ -25,42 +30,6 @@
<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"/> <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>`; </svg>`;
// Broadcast icon SVG
const broadcastIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/>
</svg>`;
async function broadcastEvent() {
if (!event || !$ndkInstance?.activeUser) return;
broadcasting = true;
broadcastSuccess = false;
broadcastError = null;
try {
const connectedRelays = getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays available');
}
// Create a new event with the same content
const newEvent = createNDKEvent($ndkInstance, {
...event.rawEvent(),
pubkey: $ndkInstance.activeUser.pubkey,
created_at: Math.floor(Date.now() / 1000),
sig: ''
});
// Publish to all relays
await newEvent.publish();
broadcastSuccess = true;
} catch (err) {
console.error('Error broadcasting event:', err);
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event';
} finally {
broadcasting = false;
}
}
function openRelayModal() { function openRelayModal() {
showRelayModal = true; showRelayModal = true;
relaySearchResults = {}; relaySearchResults = {};
@ -71,55 +40,43 @@
if (!event) return; if (!event) return;
relaySearchResults = {}; relaySearchResults = {};
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url); const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(
allRelays = [ (r) => r.url,
...standardRelays, );
...userRelays, allRelays = [...$activeInboxRelays, ...$activeOutboxRelays, ...userRelays].filter(
...fallbackRelays (url, idx, arr) => arr.indexOf(url) === idx,
].filter((url, idx, arr) => arr.indexOf(url) === idx); );
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending'])); relaySearchResults = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
await Promise.all( await Promise.all(
allRelays.map(async (relay: string) => { allRelays.map(async (relay: string) => {
try { try {
const relaySet = createRelaySetFromUrls([relay], ndk); const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk.fetchEvent( const found = await ndk
{ ids: [event?.id || ''] }, .fetchEvent({ ids: [event?.id || ""] }, undefined, relaySet)
undefined, .withTimeout(2000);
relaySet relaySearchResults = {
).withTimeout(3000); ...relaySearchResults,
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' }; [relay]: found ? "found" : "notfound",
};
} catch { } catch {
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' }; relaySearchResults = { ...relaySearchResults, [relay]: "notfound" };
} }
}) }),
); );
} }
function closeRelayModal() { function closeRelayModal() {
showRelayModal = false; showRelayModal = false;
} }
</script> </script>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<Button <Button on:click={openRelayModal} class="flex items-center">
on:click={openRelayModal}
class="flex items-center"
>
{@html searchIcon} {@html searchIcon}
Where can I find this event? Where can I find this event?
</Button> </Button>
{#if $ndkInstance?.activeUser}
<Button
on:click={broadcastEvent}
disabled={broadcasting}
class="flex items-center"
>
{@html broadcastIcon}
{broadcasting ? 'Broadcasting...' : 'Broadcast'}
</Button>
{/if}
</div> </div>
{#if foundRelays.length > 0} {#if foundRelays.length > 0}
@ -133,23 +90,6 @@
</div> </div>
{/if} {/if}
{#if broadcastSuccess}
<div class="mt-2 p-2 bg-green-100 text-green-700 rounded">
Event broadcast successfully to:
<div class="flex flex-wrap gap-2 mt-1">
{#each getConnectedRelays() as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
{#if broadcastError}
<div class="mt-2 p-2 bg-red-100 text-red-700 rounded">
{broadcastError}
</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">
@ -159,32 +99,32 @@
</div> </div>
</div> </div>
{#if showRelayModal} <Modal
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"> class="modal-leather"
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"> title="Relay Search Results"
<button class="absolute top-2 right-2 text-gray-500 hover:text-gray-800" onclick={closeRelayModal}>&times;</button> bind:open={showRelayModal}
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2> autoclose
outsideclose
size="lg"
>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto"> <div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries({ {#each Object.entries( { "Active Inbox Relays": $activeInboxRelays, "Active Outbox Relays": $activeOutboxRelays }, ) as [groupName, groupRelays]}
'Standard Relays': standardRelays,
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url),
'Fallback Relays': fallbackRelays
}) as [groupName, groupRelays]}
{#if groupRelays.length > 0} {#if groupRelays.length > 0}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h3 class="font-medium text-gray-700 dark:text-gray-300 sticky top-0 bg-white dark:bg-gray-900 py-2"> <h3
class="font-medium text-gray-900 dark:text-gray-100 sticky top-0 bg-white dark:bg-gray-900 py-2"
>
{groupName} {groupName}
</h3> </h3>
{#each groupRelays as relay} {#each groupRelays as relay}
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} /> <RelayDisplay
{relay}
showStatus={true}
status={relaySearchResults[relay] || null}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
{/each} {/each}
</div> </div>
<div class="mt-4 flex justify-end"> </Modal>
<Button onclick={closeRelayModal}>Close</Button>
</div>
</div>
</div>
{/if}

57
src/lib/components/RelayDisplay.svelte

@ -1,56 +1,73 @@
<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 { activeInboxRelays, ndkInstance } from "$lib/ndk";
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays) // 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[] {
if (event && (event as any).relay) { if (event && (event as any).relay) {
const relay = (event as any).relay; const relay = (event as any).relay;
return [typeof relay === 'string' ? relay : relay.url]; return [typeof relay === "string" ? relay : relay.url];
} }
if (event && (event as any).relays && (event as any).relays.length) { if (event && (event as any).relays && (event as any).relays.length) {
return (event as any).relays.map((r: any) => typeof r === 'string' ? r : r.url); return (event as any).relays.map((r: any) =>
typeof r === "string" ? r : r.url,
);
} }
return standardRelays; // Use active inbox relays as fallback
return get(activeInboxRelays);
} }
export function getConnectedRelays(): string[] { export function getConnectedRelays(): string[] {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
return Array.from(ndk?.pool?.relays.values() || []) return Array.from(ndk?.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays .filter((r) => r.status === 1) // Only use connected relays
.map(r => r.url); .map((r) => r.url);
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { get } from 'svelte/store';
import { ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts";
export let relay: string; export let relay: string;
export let showStatus = false; export let showStatus = false;
export let status: 'pending' | 'found' | 'notfound' | null = null; export let status: "pending" | "found" | "notfound" | null = null;
// Use a static fallback icon for all relays // Use a static fallback icon for all relays
function relayFavicon(relay: string): string { function relayFavicon(relay: string): string {
return '/favicon.png'; return "/favicon.png";
} }
</script> </script>
<div class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"> <div
class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"
>
<img <img
src={relayFavicon(relay)} src={relayFavicon(relay)}
alt="relay icon" alt="relay icon"
class="w-5 h-5 object-contain" class="w-5 h-5 object-contain"
onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} onerror={(e) => {
(e.target as HTMLImageElement).src = "/favicon.png";
}}
/> />
<span class="font-mono text-xs flex-1">{relay}</span> <span class="font-mono text-xs flex-1">{relay}</span>
{#if showStatus && status} {#if showStatus && status}
{#if status === 'pending'} {#if status === "pending"}
<svg class="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="w-4 h-4 animate-spin text-gray-600 dark:text-gray-400"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path> fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
></path>
</svg> </svg>
{:else if status === 'found'} {:else if status === "found"}
<span class="text-green-600">&#10003;</span> <span class="text-green-600">&#10003;</span>
{:else} {:else}
<span class="text-red-500">&#10007;</span> <span class="text-red-500">&#10007;</span>

164
src/lib/components/RelayStatus.svelte

@ -0,0 +1,164 @@
<script lang="ts">
import { Button, Alert } from "flowbite-svelte";
import {
ndkInstance,
ndkSignedIn,
testRelayConnection,
checkWebSocketSupport,
checkEnvironmentForWebSocketDowngrade,
} from "$lib/ndk";
import { onMount } from "svelte";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
interface RelayStatus {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
testing: boolean;
}
let relayStatuses = $state<RelayStatus[]>([]);
let testing = $state(false);
// Use the new relay management system
let allRelays: string[] = $state([]);
$effect(() => {
allRelays = [...$activeInboxRelays, ...$activeOutboxRelays];
});
async function runRelayTests() {
testing = true;
const ndk = $ndkInstance;
if (!ndk) {
testing = false;
return;
}
let relaysToTest: string[] = [];
// Use active relays from the new relay management system
const userRelays = new Set([...$activeInboxRelays, ...$activeOutboxRelays]);
relaysToTest = Array.from(userRelays);
console.log("[RelayStatus] Relays to test:", relaysToTest);
relayStatuses = relaysToTest.map((url) => ({
url,
connected: false,
requiresAuth: false,
testing: true,
}));
const results = await Promise.allSettled(
relaysToTest.map(async (url) => {
console.log("[RelayStatus] Testing relay:", url);
try {
return await testRelayConnection(url, ndk);
} catch (error) {
return {
connected: false,
requiresAuth: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}),
);
relayStatuses = relayStatuses.map((status, index) => {
const result = results[index];
if (result.status === "fulfilled") {
return {
...status,
...result.value,
testing: false,
};
} else {
return {
...status,
connected: false,
requiresAuth: false,
error: "Test failed",
testing: false,
};
}
});
testing = false;
}
$effect(() => {
// Re-run relay tests when feed type, login state, or relay lists change
void runRelayTests();
});
onMount(() => {
checkWebSocketSupport();
checkEnvironmentForWebSocketDowngrade();
// Run initial relay tests
void runRelayTests();
});
function getStatusColor(status: RelayStatus): string {
if (status.testing) return "text-yellow-600";
if (status.connected) return "text-green-600";
if (status.requiresAuth && !$ndkSignedIn) return "text-orange-600";
return "text-red-600";
}
function getStatusText(status: RelayStatus): string {
if (status.testing) return "Testing...";
if (status.connected) return "Connected";
if (status.requiresAuth && !$ndkSignedIn) return "Requires Authentication";
if (status.error) return `Error: ${status.error}`;
return "Failed to Connect";
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Relay Connection Status</h3>
<Button size="sm" onclick={runRelayTests} disabled={testing}>
{testing ? "Testing..." : "Refresh"}
</Button>
</div>
{#if !$ndkSignedIn}
<Alert color="yellow">
<span class="font-medium">Anonymous Mode</span>
<p class="mt-1 text-sm">
You are not signed in. Some relays require authentication and may not be
accessible. Sign in to access all relays.
</p>
</Alert>
{/if}
<div class="space-y-2">
{#each relayStatuses as status}
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex-1">
<div class="font-medium">{status.url}</div>
<div class="text-sm {getStatusColor(status)}">
{getStatusText(status)}
</div>
</div>
<div
class="w-3 h-3 rounded-full {getStatusColor(status).replace(
'text-',
'bg-',
)}"
></div>
</div>
{/each}
</div>
{#if relayStatuses.some((s) => s.requiresAuth && !$ndkSignedIn)}
<Alert color="orange">
<span class="font-medium">Authentication Required</span>
<p class="mt-1 text-sm">
Some relays require authentication. Sign in to access these relays.
</p>
</Alert>
{/if}
</div>

24
src/lib/components/Toc.svelte

@ -1,24 +0,0 @@
<script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import {nip19} from 'nostr-tools';
export let notes: NDKEvent[] = [];
// check if notes is empty
if (notes.length === 0) {
console.debug('notes is empty');
}
</script>
<div class="toc">
<h2>Table of contents</h2>
<ul>
{#each notes as note}
<li><a href="#{nip19.noteEncode(note.id)}">{note.getMatchingTags('title')[0][1]}</a></li>
{/each}
</ul>
</div>
<style>
.toc h2 {
text-align: center;
}
</style>

180
src/lib/components/ZettelEditor.svelte

@ -0,0 +1,180 @@
<script lang="ts">
import { Textarea, Button } from "flowbite-svelte";
import { EyeOutline } from "flowbite-svelte-icons";
import {
parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
import asciidoctor from "asciidoctor";
// Component props
let {
content = "",
placeholder = `== Note Title
:author: {author} // author is optional
:tags: tag1, tag2, tag3 // tags are optional
note content here...
== Note Title 2
:tags: tag1, tag2, tag3
Note content here...
`,
showPreview = false,
onContentChange = (content: string) => {},
onPreviewToggle = (show: boolean) => {},
} = $props<{
content?: string;
placeholder?: string;
showPreview?: boolean;
onContentChange?: (content: string) => void;
onPreviewToggle?: (show: boolean) => void;
}>();
// Initialize AsciiDoctor processor
const asciidoctorProcessor = asciidoctor();
// Parse sections for preview
let parsedSections = $derived(parseAsciiDocSections(content, 2));
// Toggle preview panel
function togglePreview() {
const newShowPreview = !showPreview;
onPreviewToggle(newShowPreview);
}
// Handle content changes
function handleContentChange(event: Event) {
const target = event.target as HTMLTextAreaElement;
onContentChange(target.value);
}
</script>
<div class="flex flex-col space-y-4">
<div class="flex items-center justify-between">
<Button
color="light"
size="sm"
on:click={togglePreview}
class="flex items-center space-x-1"
>
{#if showPreview}
<EyeOutline class="w-4 h-4" />
<span>Hide Preview</span>
{:else}
<EyeOutline class="w-4 h-4" />
<span>Show Preview</span>
{/if}
</Button>
</div>
<div class="flex space-x-4 {showPreview ? 'h-96' : ''}">
<!-- Editor Panel -->
<div class="{showPreview ? 'w-1/2' : 'w-full'} flex flex-col space-y-4">
<div class="flex-1">
<Textarea
bind:value={content}
on:input={handleContentChange}
{placeholder}
class="h-full min-h-64 resize-none"
rows={12}
/>
</div>
</div>
<!-- Preview Panel -->
{#if showPreview}
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4">
<div class="sticky top-4">
<h3
class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"
>
AsciiDoc Preview
</h3>
<div
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-h-80 overflow-y-auto"
>
{#if !content.trim()}
<div class="text-gray-500 dark:text-gray-400 text-sm">
Start typing to see the preview...
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{#each parsedSections as section, index}
<div class="mb-6">
<div
class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"
>
{@html asciidoctorProcessor.convert(
`== ${section.title}\n\n${section.content}`,
{
standalone: false,
doctype: "article",
attributes: {
showtitle: true,
sectids: true,
},
},
)}
</div>
{#if index < parsedSections.length - 1}
<!-- Gray area with tag bubbles above event boundary -->
<div class="my-4 relative">
<!-- Gray background area -->
<div
class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"
>
<div class="flex flex-wrap gap-2 items-center">
{#if section.tags && section.tags.length > 0}
{#each section.tags as tag}
<div
class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"
>
<span class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span>
</div>
{/each}
{:else}
<span
class="text-gray-500 dark:text-gray-400 text-xs italic"
>No tags</span
>
{/if}
</div>
</div>
<!-- Event boundary line -->
<div
class="border-t-2 border-dashed border-blue-400 relative"
>
<div
class="absolute -top-2 left-1/2 transform -translate-x-1/2 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs font-medium"
>
Event Boundary
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
<div
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 p-2 rounded border"
>
<strong>Event Count:</strong>
{parsedSections.length} event{parsedSections.length !== 1
? "s"
: ""}
<br />
<strong>Note:</strong> Currently only the first event will be published.
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>

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

@ -1,24 +1,41 @@
<script lang="ts"> <script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { scale } from 'svelte/transition'; import { scale } from "svelte/transition";
import { Card, Img } from "flowbite-svelte"; import { Card } from "flowbite-svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; <<<<<<< HEAD
import { getMatchingTags } from "$lib/utils/nostrUtils";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>(); const {
rootId,
event,
onBlogUpdate,
active = true,
} = $props<{
rootId: string;
event: NDKEvent;
onBlogUpdate?: any;
active: boolean;
}>();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); );
let hashtags: string = $derived(event.getMatchingTags('t') ?? null); let image: string = $derived(event.getMatchingTags("image")[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
let hashtags: string = $derived(event.getMatchingTags("t") ?? null);
function publishedAt() { function publishedAt() {
const date = event.created_at ? new Date(event.created_at * 1000) : ''; const date = event.created_at ? new Date(event.created_at * 1000) : "";
if (date !== '') { if (date !== "") {
const formattedDate = new Intl.DateTimeFormat("en-US", { const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -26,7 +43,7 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function showBlog() { function showBlog() {
@ -35,25 +52,42 @@
</script> </script>
{#if title != null} {#if title != null}
<Card class="ArticleBox card-leather w-full grid max-w-xl {active ? 'active' : ''}"> <Card
<div class='space-y-4'> class="ArticleBox card-leather w-full grid max-w-xl {active
? 'active'
: ''}"
>
<div class="space-y-4 relative">
<div class="flex flex-row justify-between my-2"> <div class="flex flex-row justify-between my-2">
<div class="flex flex-col"> <div class="flex flex-col">
{@render userBadge(authorPubkey, author)} {@render userBadge(authorPubkey, author)}
<span class='text-gray-500'>{publishedAt()}</span> <span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div> </div>
<CardActions event={event} />
</div> </div>
{#if image && active}
<div class="ArticleBoxImage flex col justify-center" <div
class="ArticleBoxImage flex justify-center items-center p-2 h-40 -mt-2"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }} in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
> >
<Img src={image} class="rounded w-full max-h-72 object-cover"/> {#if image}
<LazyImage
src={image}
alt={title || "Publication image"}
eventId={event.id}
className="rounded w-full h-full object-cover"
/>
{:else}
<div
class="rounded w-full h-full"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div> </div>
{/if} {/if}
<div class='flex flex-col flex-grow space-y-4'> </div>
<button onclick={() => showBlog()} class='text-left'>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> <div class="flex flex-col space-y-4">
<button onclick={() => showBlog()} class="text-left">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
</button> </button>
{#if hashtags} {#if hashtags}
<div class="tags"> <div class="tags">
@ -63,9 +97,15 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if active} {#if active}
<Interactions rootId={rootId} event={event} /> <Interactions {rootId} {event} />
{/if} {/if}
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</div> </div>
</Card> </Card>
{/if} {/if}

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

@ -1,47 +1,123 @@
<script lang="ts"> <script lang="ts">
import { Card, Img, 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 { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts";
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 { generateDarkPastelColor } from "$lib/utils/image_utils";
import {
lnurlpWellKnownUrl,
checkCommunity,
} from "$lib/utils/search_utility";
// @ts-ignore // @ts-ignore
import { bech32 } from 'https://esm.sh/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";
const { event, profile, identifiers = [] } = $props<{ event: NDKEvent, profile: NostrProfile, identifiers?: { label: string, value: string, link?: string }[] }>(); const {
event,
profile,
identifiers = [],
} = $props<{
event: NDKEvent;
profile: NostrProfile;
identifiers?: { label: string; value: string; link?: string }[];
}>();
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);
onMount(async () => { onMount(async () => {
if (profile?.lud16) { if (profile?.lud16) {
try { try {
// Convert LN address to LNURL // Convert LN address to LNURL
const [name, domain] = profile?.lud16.split('@'); const [name, domain] = profile?.lud16.split("@");
const url = `https://${domain}/.well-known/lnurlp/${name}`; const url = lnurlpWellKnownUrl(domain, name);
const words = bech32.toWords(new TextEncoder().encode(url)); const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode('lnurl', words); lnurl = bech32.encode("lnurl", words);
} catch { } catch {
console.log('Error converting LN address to LNURL'); console.log("Error converting LN address to LNURL");
} }
} }
}); });
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
});
function navigateToIdentifier(link: string) {
goto(link);
}
</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">
<div class='space-y-4'> <div class="space-y-4">
{#if profile.banner}
<div class="ArticleBoxImage flex col justify-center"> <div class="ArticleBoxImage flex col justify-center">
<Img src={profile.banner} class="rounded w-full max-h-72 object-cover" alt="Profile banner" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none';}} /> {#if profile.banner}
<LazyImage
src={profile.banner}
alt="Profile banner"
eventId={event.id}
className="rounded w-full max-h-72 object-cover"
/>
{:else}
<div
class="rounded w-full max-h-72"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div> </div>
{/if} {/if}
<div class='flex flex-row space-x-4 items-center'> </div>
<div class="flex flex-row space-x-4 items-center">
{#if profile.picture} {#if profile.picture}
<img src={profile.picture} alt="Profile avatar" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> <img
src={profile.picture}
alt="Profile avatar"
class="w-16 h-16 rounded-full border"
onerror={(e) => {
(e.target as HTMLImageElement).src = "/favicon.png";
}}
/>
{/if}
<div class="flex items-center gap-2">
{@render userBadge(
toNpub(event.pubkey) as string,
profile.displayName ||
profile.display_name ||
profile.name ||
event.pubkey,
)}
{#if communityStatus === true}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if} {/if}
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.name || event.pubkey)} </div>
</div> </div>
<div> <div>
<div class="mt-2 flex flex-col gap-4"> <div class="mt-2 flex flex-col gap-4">
@ -68,14 +144,25 @@
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt> <dt class="font-semibold min-w-[120px]">Website:</dt>
<dd> <dd>
<a href={profile.website} target="_blank" class="underline text-primary-700 dark:text-primary-200">{profile.website}</a> <a
href={profile.website}
class="underline text-primary-700 dark:text-primary-200"
>{profile.website}</a
>
</dd> </dd>
</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">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> <dt class="font-semibold min-w-[120px]">Lightning Address:</dt>
<dd><Button class="btn-leather" color="primary" outline onclick={() => lnModalOpen = true}>{profile.lud16}</Button> </dd> <dd>
<Button
class="btn-leather"
color="primary"
outline
onclick={() => (lnModalOpen = true)}>{profile.lud16}</Button
>
</dd>
</div> </div>
{/if} {/if}
{#if profile.nip05} {#if profile.nip05}
@ -87,27 +174,48 @@
{#each identifiers as id} {#each identifiers as id}
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> <dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">{#if id.link}<a href={id.link} class="underline text-primary-700 dark:text-primary-200 break-all">{id.value}</a>{:else}{id.value}{/if}</dd> <dd class="break-all">
{#if id.link}
<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"
onclick={() => navigateToIdentifier(id.link)}
>
{id.value}
</button>
{:else}
{id.value}
{/if}
</dd>
</div> </div>
{/each} {/each}
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'> <Modal
class="modal-leather"
title="Lightning Address"
bind:open={lnModalOpen}
outsideclose
size="sm"
>
{#if profile.lud16} {#if profile.lud16}
<div> <div>
<div class='flex flex-col items-center'> <div class="flex flex-col items-center">
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)} {@render userBadge(
toNpub(event.pubkey) as string,
profile?.displayName || profile.name || event.pubkey,
)}
<P>{profile.lud16}</P> <P>{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 style="overflow-wrap: anywhere">
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard> <CopyToClipboard icon={false} displayText={lnurl}
></CopyToClipboard>
</P> </P>
<QrCode value={lnurl} /> <QrCode value={lnurl} />
{:else} {:else}
@ -116,5 +224,5 @@
</div> </div>
</div> </div>
{/if} {/if}
</Modal> </Modal>
{/if} {/if}

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

@ -7,6 +7,7 @@
SidebarGroup, SidebarGroup,
SidebarWrapper, SidebarWrapper,
Heading, Heading,
CloseButton,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import { import {
@ -15,13 +16,13 @@
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte"; import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte"; import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte"; import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import { pharosInstance } from '$lib/parser'; import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
let { rootAddress, publicationType, indexEvent } = $props<{ let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string; rootAddress: string;
@ -29,16 +30,18 @@
indexEvent: NDKEvent; indexEvent: NDKEvent;
}>(); }>();
const publicationTree = getContext("publicationTree") as PublicationTree; const publicationTree = getContext(
"publicationTree",
) as SveltePublicationTree;
const toc = getContext("toc") as TocType;
// #region Loading // #region Loading
// TODO: Test load handling.
let leaves = $state<Array<NDKEvent | null>>([]); let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state<boolean>(false); let isLoading = $state<boolean>(false);
let isDone = $state<boolean>(false); let isDone = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null); let lastElementRef = $state<HTMLElement | null>(null);
let activeAddress = $state<string | null>(null);
let observer: IntersectionObserver; let observer: IntersectionObserver;
@ -82,7 +85,8 @@
// #endregion // #endregion
// region Columns visibility // #region Columns visibility
let currentBlog: null | string = $state(null); let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null); let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041); const isLeaf = $derived(indexEvent.kind === 30041);
@ -91,6 +95,10 @@
return currentBlog !== null && $publicationColumnVisibility.inner; return currentBlog !== null && $publicationColumnVisibility.inner;
} }
function closeToc() {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
function closeDiscussion() { function closeDiscussion() {
publicationColumnVisibility.update((v) => ({ ...v, discussion: false })); publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
} }
@ -119,6 +127,33 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140; return currentBlog && currentBlogEvent && window.innerWidth < 1140;
} }
// #endregion
/**
* Performs actions on the DOM element for a publication tree leaf when it is mounted.
*
* @param el The DOM element that was mounted.
* @param address The address of the event that was mounted.
*/
function onPublicationSectionMounted(el: HTMLElement, address: string) {
// Update last element ref for the intersection observer.
setLastElementRef(el, leaves.length);
// Michael J - 08 July 2025 - NOTE: Updating the ToC from here somewhat breaks separation of
// concerns, since the TableOfContents component is primarily responsible for working with the
// ToC data structure. However, the Publication component has direct access to the needed DOM
// element already, and I want to avoid complicated callbacks between the two components.
// Update the ToC from the contents of the leaf section.
const entry = toc.getEntry(address);
if (!entry) {
console.warn(`[Publication] No parent found for ${address}`);
return;
}
toc.buildTocFromDocument(el, entry);
}
// #region Lifecycle hooks
onDestroy(() => { onDestroy(() => {
// reset visibility // reset visibility
publicationColumnVisibility.reset(); publicationColumnVisibility.reset();
@ -147,20 +182,42 @@
}, },
{ threshold: 0.5 }, { threshold: 0.5 },
); );
loadMore(8); loadMore(12);
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}); });
// Whenever the publication changes, update rootId // #endregion
let rootId = $derived($pharosInstance.getRootIndexId());
</script> </script>
<!-- Table of contents --> <!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf} {#if publicationType !== "blog" || !isLeaf}
<TocToggle {rootId} /> {#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ""}`}
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg"
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg"
>
<CloseButton
onclick={closeToc}
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800"
/>
<TableOfContents
{rootAddress}
depth={2}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone) {
loadMore(4);
}
}}
/>
</Sidebar>
{/if}
{/if} {/if}
<!-- Default publications --> <!-- Default publications -->
@ -179,11 +236,12 @@
Error loading content. One or more events could not be loaded. Error loading content. One or more events could not be loaded.
</Alert> </Alert>
{:else} {:else}
{@const address = leaf.tagAddress()}
<PublicationSection <PublicationSection
{rootAddress} {rootAddress}
{leaves} {leaves}
address={leaf.tagAddress()} {address}
ref={(el) => setLastElementRef(el, i)} ref={(el) => onPublicationSectionMounted(el, address)}
/> />
{/if} {/if}
{/each} {/each}
@ -204,9 +262,7 @@
<!-- Blog list --> <!-- Blog list -->
{#if $publicationColumnVisibility.blog} {#if $publicationColumnVisibility.blog}
<div <div
class="flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
{isInnerActive() ? 'discreet' : ''}
"
> >
<div <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"

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

@ -0,0 +1,414 @@
<script lang="ts">
import { indexKind } from "$lib/consts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte";
import { onMount, onDestroy } from "svelte";
import {
getMatchingTags,
NDKRelaySetFromNDK,
type NDKEvent,
type NDKRelaySet,
} from "$lib/utils/nostrUtils";
import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache";
import { isValidNip05Address } from "$lib/utils/search_utility";
const props = $props<{
searchQuery?: string;
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>();
// Component state
let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>({});
let loading: boolean = $state(true);
let hasInitialized = $state(false);
let fallbackTimeout: ReturnType<typeof setTimeout> | null = null;
// Relay management
let allRelays: string[] = $state([]);
let ndk = $derived($ndkInstance);
// Event management
let allIndexEvents: NDKEvent[] = $state([]);
// Initialize relays and fetch events
async function initializeAndFetch() {
if (!ndk) {
console.debug('[PublicationFeed] No NDK instance available');
return;
}
// Get relays from active stores
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
const newRelays = [...inboxRelays, ...outboxRelays];
console.debug('[PublicationFeed] Available relays:', {
inboxCount: inboxRelays.length,
outboxCount: outboxRelays.length,
totalCount: newRelays.length,
relays: newRelays
});
if (newRelays.length === 0) {
console.debug('[PublicationFeed] No relays available, waiting...');
return;
}
// Update allRelays if different
const currentRelaysString = allRelays.sort().join(',');
const newRelaysString = newRelays.sort().join(',');
if (currentRelaysString !== newRelaysString) {
allRelays = newRelays;
console.debug('[PublicationFeed] Relays updated, fetching events');
await fetchAllIndexEventsFromRelays();
}
}
// Watch for relay store changes
$effect(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
const newRelays = [...inboxRelays, ...outboxRelays];
if (newRelays.length > 0 && !hasInitialized) {
console.debug('[PublicationFeed] Relays available, initializing');
hasInitialized = true;
if (fallbackTimeout) {
clearTimeout(fallbackTimeout);
fallbackTimeout = null;
}
setTimeout(() => initializeAndFetch(), 0);
} else if (newRelays.length === 0 && !hasInitialized) {
console.debug('[PublicationFeed] No relays available, setting up fallback');
if (!fallbackTimeout) {
fallbackTimeout = setTimeout(() => {
console.debug('[PublicationFeed] Fallback timeout reached, retrying');
hasInitialized = true;
initializeAndFetch();
}, 3000);
}
}
});
async function fetchAllIndexEventsFromRelays() {
console.debug('[PublicationFeed] fetchAllIndexEventsFromRelays called with relays:', {
allRelaysCount: allRelays.length,
allRelays: allRelays
});
if (!ndk) {
console.error('[PublicationFeed] No NDK instance available');
loading = false;
return;
}
if (allRelays.length === 0) {
console.debug('[PublicationFeed] No relays available for fetching');
loading = false;
return;
}
// Check cache first
const cachedEvents = indexEventCache.get(allRelays);
if (cachedEvents) {
console.log(
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
);
allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
return;
}
loading = true;
relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
let allEvents: NDKEvent[] = [];
const eventMap = new Map<string, NDKEvent>();
// Helper to fetch from a single relay with timeout
async function fetchFromRelay(relay: string): Promise<void> {
try {
console.debug(`[PublicationFeed] Fetching from relay: ${relay}`);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk
.fetchEvents(
{
kinds: [indexKind],
limit: 1000, // Increased limit to get more events
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet,
)
.withTimeout(5000); // Reduced timeout to 5 seconds for faster response
console.debug(`[PublicationFeed] Raw events from ${relay}:`, eventSet.size);
eventSet = filterValidIndexEvents(eventSet);
console.debug(`[PublicationFeed] Valid events from ${relay}:`, eventSet.size);
relayStatuses = { ...relayStatuses, [relay]: "found" };
// Add new events to the map and update the view immediately
const newEvents: NDKEvent[] = [];
for (const event of eventSet) {
const tagAddress = event.tagAddress();
if (!eventMap.has(tagAddress)) {
eventMap.set(tagAddress, event);
newEvents.push(event);
}
}
if (newEvents.length > 0) {
// Update allIndexEvents with new events
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`);
}
} catch (err) {
console.error(`[PublicationFeed] Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: "notfound" };
}
}
// Fetch from all relays in parallel, return events as they arrive
console.debug(`[PublicationFeed] Starting fetch from ${allRelays.length} relays`);
// Start all relay fetches in parallel
const fetchPromises = allRelays.map(fetchFromRelay);
// Wait for all to complete (but events are shown as they arrive)
await Promise.allSettled(fetchPromises);
console.debug(`[PublicationFeed] All relays completed, final event count:`, allIndexEvents.length);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
}
// Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => {
if (!props.searchQuery) return events;
const query = props.searchQuery.toLowerCase();
console.debug(
"[PublicationFeed] Filtering events with query:",
query,
"Total events before filter:",
events.length,
);
// Check cache first for publication search
const cachedResult = searchCache.get("publication", query);
if (cachedResult) {
console.log(
`[PublicationFeed] Using cached results for publication search: ${query}`,
);
return cachedResult.events;
}
// Check if the query is a NIP-05 address
const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
const filtered = events.filter((event) => {
const title =
getMatchingTags(event, "title")[0]?.[1]?.toLowerCase() ?? "";
const authorName =
getMatchingTags(event, "author")[0]?.[1]?.toLowerCase() ?? "";
const authorPubkey = event.pubkey.toLowerCase();
const nip05 =
getMatchingTags(event, "nip05")[0]?.[1]?.toLowerCase() ?? "";
// For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) {
const matches = nip05 === query;
if (matches) {
console.debug("[PublicationFeed] Event matches NIP-05 search:", {
id: event.id,
nip05,
authorPubkey,
});
}
return matches;
}
// For regular queries, match against all fields
const matches =
title.includes(query) ||
authorName.includes(query) ||
authorPubkey.includes(query) ||
nip05.includes(query);
if (matches) {
console.debug("[PublicationFeed] Event matches search:", {
id: event.id,
title,
authorName,
authorPubkey,
nip05,
});
}
return matches;
});
// 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);
console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered;
};
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query);
if (query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents);
eventsInView = filtered.slice(0, 30);
endOfFeed = filtered.length <= 30;
} else {
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
}
}, 300);
$effect(() => {
console.debug(
"[PublicationFeed] Search query effect triggered:",
props.searchQuery,
);
debouncedSearch(props.searchQuery);
});
// Emit event count updates
$effect(() => {
if (props.onEventCountUpdate) {
props.onEventCountUpdate({
displayed: eventsInView.length,
total: allIndexEvents.length
});
}
});
async function loadMorePublications() {
loadingMore = true;
const current = eventsInView.length;
let source = props.searchQuery.trim()
? filterEventsBySearch(allIndexEvents)
: allIndexEvents;
eventsInView = source.slice(0, current + 30);
endOfFeed = eventsInView.length >= source.length;
loadingMore = false;
}
function getSkeletonIds(): string[] {
const skeletonHeight = 192; // The height of the card component in pixels (h-48 = 12rem = 192px).
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {
skeletonIds.push(`skeleton-${i}`);
}
return skeletonIds;
}
function getCacheStats(): string {
const indexStats = indexEventCache.getStats();
const searchStats = searchCache.size();
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
}
// Cleanup function for fallback timeout
function cleanup() {
if (fallbackTimeout) {
clearTimeout(fallbackTimeout);
fallbackTimeout = null;
}
}
// Cleanup on component destruction
onDestroy(() => {
cleanup();
});
onMount(async () => {
console.debug('[PublicationFeed] onMount called');
// The effect will handle fetching when relays become available
});
</script>
<div class="flex flex-col space-y-4">
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full"
>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}
<ArticleHeader {event} />
{/each}
{:else}
<div class="col-span-full">
<p class="text-center">No publications found.</p>
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<div class="flex justify-center mt-4 mb-8">
<Button
outline
class="w-full max-w-md"
onclick={async () => {
await loadMorePublications();
}}
>
Show more publications
</Button>
</div>
{:else if loadingMore}
<div class="flex justify-center mt-4 mb-8">
<Button outline disabled class="w-full max-w-md">
<Spinner class="mr-3 text-gray-600 dark:text-gray-300" size="4" />
Loading...
</Button>
</div>
{:else}
<div class="flex justify-center mt-4 mb-8">
<P class="text-sm text-gray-700 dark:text-gray-300"
>You've reached the end of the feed.</P
>
</div>
{/if}
</div>

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

@ -0,0 +1,90 @@
<script lang="ts">
import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { activeInboxRelays } from "$lib/ndk";
import { Card } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
const { event } = $props<{ event: NDKEvent }>();
function getRelayUrls(): string[] {
return $activeInboxRelays;
}
const relays = $derived.by(() => {
return getRelayUrls();
});
const href = $derived.by(() => {
const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
});
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
event.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
);
let version: string = $derived(
event.getMatchingTags("version")[0]?.[1] ?? "1",
);
let image: string = $derived(event.getMatchingTags("image")[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
</script>
{#if title != null && href != null}
<Card class="ArticleBox card-leather max-w-md h-48 flex flex-row space-x-2 relative">
<div
class="flex-shrink-0 w-32 h-40 overflow-hidden rounded flex items-center justify-center p-2 -mt-2"
>
{#if image}
<LazyImage
src={image}
alt={title || "Publication image"}
eventId={event.id}
className="w-full h-full object-cover"
/>
{:else}
<div
class="w-full h-full rounded"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div>
{/if}
</div>
<div class="flex flex-col flex-grow space-x-2">
<div class="flex flex-col flex-grow">
<a href="/{href}" class="flex flex-col space-y-2 h-full">
<div class="flex-grow pt-2">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
{:else}
{author}
{/if}
</h3>
</div>
{#if version != "1"}
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto">version: {version}</h3>
{/if}
</a>
</div>
</div>
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</Card>
{/if}

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

@ -0,0 +1,155 @@
<script lang="ts">
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string;
rootAddress: string;
leaves: Array<NDKEvent | null>;
ref: (ref: HTMLElement) => void;
} = $props();
const publicationTree: SveltePublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress),
);
let publicationType: Promise<string | undefined> = $derived.by(
async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1],
);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(
async () => await publicationTree.getHierarchy(address),
);
let leafTitle: Promise<string | undefined> = $derived.by(
async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1],
);
let leafContent: Promise<string | Document> = $derived.by(async () => {
const content = (await leafEvent)?.content ?? "";
const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString());
return processed;
});
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number;
let event: NDKEvent | null = null;
let decrement = 1;
do {
index = leaves.findIndex((leaf) => leaf?.tagAddress() === address);
if (index === 0) {
return null;
}
event = leaves[index - decrement++];
} while (event == null && index - decrement >= 0);
return event;
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
},
);
let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([
leafHierarchy,
previousLeafHierarchy,
]);
const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) {
for (let i = 0; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
}
const minLength = Math.min(
leafHierarchyValue.length,
previousLeafHierarchyValue.length,
);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
let sectionRef: HTMLElement;
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
});
</script>
<section
id={address}
bind:this={sectionRef}
class="publication-leather content-visibility-auto"
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
{/await}
</section>

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

@ -0,0 +1,182 @@
<script lang="ts">
import {
TableOfContents,
type TocEntry,
} from "$lib/components/publications/table_of_contents.svelte";
import { getContext } from "svelte";
import {
SidebarDropdownWrapper,
SidebarGroup,
SidebarItem,
} from "flowbite-svelte";
import Self from "./TableOfContents.svelte";
import { onMount, onDestroy } from "svelte";
let { depth, onSectionFocused, onLoadMore } = $props<{
rootAddress: string;
depth: number;
onSectionFocused?: (address: string) => void;
onLoadMore?: () => void;
}>();
let toc = getContext("toc") as TableOfContents;
let entries = $derived.by<TocEntry[]>(() => {
const newEntries = [];
for (const [_, entry] of toc.addressMap) {
if (entry.depth !== depth) {
continue;
}
newEntries.push(entry);
}
return newEntries;
});
// Track the currently visible section
let currentVisibleSection = $state<string | null>(null);
let observer: IntersectionObserver;
function setEntryExpanded(address: string, expanded: boolean = false) {
const entry = toc.getEntry(address);
if (!entry) {
return;
}
toc.expandedMap.set(address, expanded);
entry.resolveChildren();
}
function handleSectionClick(address: string) {
// Smooth scroll to the section
const element = document.getElementById(address);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
onSectionFocused?.(address);
// Check if this is the last entry and trigger loading more events
const currentEntries = entries;
const lastEntry = currentEntries[currentEntries.length - 1];
if (lastEntry && lastEntry.address === address) {
console.debug('[TableOfContents] Last entry clicked, triggering load more');
onLoadMore?.();
}
}
// Check if an entry is currently visible
function isEntryVisible(address: string): boolean {
return currentVisibleSection === address;
}
// Set up intersection observer to track visible sections
onMount(() => {
observer = new IntersectionObserver(
(entries) => {
// Find the section that is most visible in the viewport
let maxIntersectionRatio = 0;
let mostVisibleSection: string | null = null;
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > maxIntersectionRatio) {
maxIntersectionRatio = entry.intersectionRatio;
mostVisibleSection = entry.target.id;
}
});
if (mostVisibleSection && mostVisibleSection !== currentVisibleSection) {
currentVisibleSection = mostVisibleSection;
}
},
{
threshold: [0, 0.25, 0.5, 0.75, 1],
rootMargin: "-20% 0px -20% 0px", // Consider section visible when it's in the middle 60% of the viewport
}
);
// Function to observe all section elements
function observeSections() {
const sections = document.querySelectorAll('section[id]');
sections.forEach((section) => {
observer.observe(section);
});
}
// Initial observation
observeSections();
// Set up a mutation observer to watch for new sections being added
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Check if the added node is a section with an id
if (element.tagName === 'SECTION' && element.id) {
observer.observe(element);
}
// Check if the added node contains sections
const sections = element.querySelectorAll?.('section[id]');
if (sections) {
sections.forEach((section) => {
observer.observe(section);
});
}
}
});
});
});
// Start observing the document body for changes
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
mutationObserver.disconnect();
};
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
});
</script>
<!-- TODO: Figure out how to style indentations. -->
<!-- TODO: Make group title fonts the same as entry title fonts. -->
<SidebarGroup>
{#each entries as entry, index}
{@const address = entry.address}
{@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)}
{@const isVisible = isEntryVisible(address)}
{@const isLastEntry = index === entries.length - 1}
{#if isLeaf}
<SidebarItem
label={entry.title}
href={`#${address}`}
spanClass="px-2 text-ellipsis"
class={`${isVisible ? "toc-highlight" : ""} ${isLastEntry ? "pb-4" : ""}`}
onclick={() => handleSectionClick(address)}
/>
{:else}
{@const childDepth = depth + 1}
<SidebarDropdownWrapper
label={entry.title}
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)}
>
<Self rootAddress={address} depth={childDepth} {onSectionFocused} {onLoadMore} />
</SidebarDropdownWrapper>
{/if}
{/each}
</SidebarGroup>

111
src/lib/components/publications/svelte_publication_tree.svelte.ts

@ -0,0 +1,111 @@
import { SvelteSet } from "svelte/reactivity";
import { PublicationTree } from "../../data_structures/publication_tree.ts";
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
export class SveltePublicationTree {
resolvedAddresses: SvelteSet<string> = new SvelteSet();
#publicationTree: PublicationTree;
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) {
this.#publicationTree = new PublicationTree(rootEvent, ndk);
this.#publicationTree.onNodeResolved(this.#handleNodeResolved);
this.#publicationTree.onBookmarkMoved(this.#handleBookmarkMoved);
}
// #region Proxied Public Methods
getChildAddresses(address: string): Promise<Array<string | null>> {
return this.#publicationTree.getChildAddresses(address);
}
getEvent(address: string): Promise<NDKEvent | null> {
return this.#publicationTree.getEvent(address);
}
getHierarchy(address: string): Promise<NDKEvent[]> {
return this.#publicationTree.getHierarchy(address);
}
async getParent(address: string): Promise<NDKEvent | null> {
const hierarchy = await this.getHierarchy(address);
// The last element in the hierarchy is the event with the given address, so the parent is the
// second to last element.
return hierarchy.at(-2) ?? null;
}
setBookmark(address: string) {
this.#publicationTree.setBookmark(address);
}
/**
* Registers an observer function that is invoked whenever a new node is resolved.
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
this.#nodeResolvedObservers.push(observer);
}
/**
* Registers an observer function that is invoked whenever the bookmark is moved.
* @param observer The observer function.
*/
onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer);
}
// #endregion
// #region Proxied Async Iterator Methods
[Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> {
return this;
}
next(): Promise<IteratorResult<NDKEvent | null>> {
return this.#publicationTree.next();
}
previous(): Promise<IteratorResult<NDKEvent | null>> {
return this.#publicationTree.previous();
}
// #endregion
// #region Private Methods
/**
* Observer function that is invoked whenever a new node is resolved on the publication tree.
*
* @param address The address of the resolved node.
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
#handleNodeResolved = (address: string) => {
this.resolvedAddresses.add(address);
for (const observer of this.#nodeResolvedObservers) {
observer(address);
}
};
/**
* Observer function that is invoked whenever the bookmark is moved on the publication tree.
*
* @param address The address of the new bookmark.
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
#handleBookmarkMoved = (address: string) => {
for (const observer of this.#bookmarkMovedObservers) {
observer(address);
}
};
// #endregion
}

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

@ -0,0 +1,297 @@
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import { SveltePublicationTree } from "./svelte_publication_tree.svelte.ts";
import type { NDKEvent } from "../../utils/nostrUtils.ts";
import { indexKind } from "../../consts.ts";
export interface TocEntry {
address: string;
title: string;
href?: string;
children: TocEntry[];
parent?: TocEntry;
depth: number;
childrenResolved: boolean;
resolveChildren: () => Promise<void>;
}
/**
* Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are
* conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has
* been "discovered". The ToC is updated as new nodes are resolved within the publication tree.
*
* @see SveltePublicationTree
*/
export class TableOfContents {
public addressMap: SvelteMap<string, TocEntry> = new SvelteMap();
public expandedMap: SvelteMap<string, boolean> = new SvelteMap();
public leaves: SvelteSet<string> = new SvelteSet();
#root: TocEntry | null = null;
#publicationTree: SveltePublicationTree;
#pagePathname: string;
/**
* Constructs a `TableOfContents` from a `SveltePublicationTree`.
*
* @param rootAddress The address of the root event.
* @param publicationTree The SveltePublicationTree instance.
* @param pagePathname The current page pathname for href generation.
*/
constructor(
rootAddress: string,
publicationTree: SveltePublicationTree,
pagePathname: string,
) {
this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname;
this.#init(rootAddress);
}
// #region Public Methods
/**
* Returns the root entry of the ToC.
*
* @returns The root entry of the ToC, or `null` if the ToC has not been initialized.
*/
getRootEntry(): TocEntry | null {
return this.#root;
}
getEntry(address: string): TocEntry | undefined {
return this.addressMap.get(address);
}
/**
* Builds a table of contents from the DOM subtree rooted at `parentElement`.
*
* @param parentElement The root of the DOM subtree containing the content to be added to the
* ToC.
* @param parentAddress The address of the event corresponding to the DOM subtree root indicated
* by `parentElement`.
*
* This function is intended for use on segments of HTML markup that are not directly derived
* from a structure publication of the kind supported by `PublicationTree`. It may be used to
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or
* from a kind `30023` event with Markdown content.
*/
buildTocFromDocument(parentElement: HTMLElement, parentEntry: TocEntry) {
parentElement
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`)
.forEach((header) => {
// TODO: Correctly update ToC state from DOM.
const title = header.textContent?.trim();
const id = header.id;
// Only create an entry if the header has an ID and a title.
if (id && title) {
const href = `${this.#pagePathname}#${id}`;
// TODO: Check this logic.
const tocEntry: TocEntry = {
address: parentEntry.address,
title,
href,
depth: parentEntry.depth + 1,
children: [],
childrenResolved: true,
resolveChildren: () => Promise.resolve(),
};
parentEntry.children.push(tocEntry);
this.expandedMap.set(tocEntry.address, false);
this.buildTocFromDocument(header, tocEntry);
}
});
}
// #endregion
// #region Iterator Methods
/**
* Iterates over all ToC entries in depth-first order.
*/
*[Symbol.iterator](): IterableIterator<TocEntry> {
function* traverse(entry: TocEntry | null): IterableIterator<TocEntry> {
if (!entry) {
return;
}
yield entry;
if (entry.children) {
for (const child of entry.children) {
yield* traverse(child);
}
}
}
yield* traverse(this.#root);
}
// #endregion
// #region Private Methods
/**
* Initializes the ToC from the associated publication tree.
*
* @param rootAddress The address of the publication's root event.
*
* Michael J - 07 July 2025 - NOTE: Since the publication tree is conceptually infinite and
* lazy-loading, the ToC is not guaranteed to contain all the nodes at any layer until the
* publication has been fully resolved.
*
* Michael J - 07 July 2025 - TODO: If the relay provides event metadata, use the metadata to
* initialize the ToC with all of its first-level children.
*/
async #init(rootAddress: string) {
const rootEvent = await this.#publicationTree.getEvent(rootAddress);
if (!rootEvent) {
throw new Error(`[ToC] Root event ${rootAddress} not found.`);
}
this.#root = await this.#buildTocEntry(rootAddress);
this.addressMap.set(rootAddress, this.#root);
// Handle any other nodes that have already been resolved in parallel.
await Promise.all(
Array.from(this.#publicationTree.resolvedAddresses).map((address) =>
this.#buildTocEntryFromResolvedNode(address),
),
);
// Set up an observer to handle progressive resolution of the publication tree.
this.#publicationTree.onNodeResolved((address: string) => {
this.#buildTocEntryFromResolvedNode(address);
});
}
#getTitle(event: NDKEvent | null): string {
if (!event) {
// TODO: What do we want to return in this case?
return "[untitled]";
}
const titleTag = event.getMatchingTags?.("title")?.[0]?.[1];
return titleTag || event.tagAddress() || "[untitled]";
}
async #buildTocEntry(address: string): Promise<TocEntry> {
// Michael J - 07 July 2025 - NOTE: This arrow function is nested so as to use its containing
// scope in its operation. Do not move it to the top level without ensuring it still has access
// to the necessary variables.
const resolver = async () => {
if (entry.childrenResolved) {
return;
}
const event = await this.#publicationTree.getEvent(entry.address);
if (event?.kind !== indexKind) {
// TODO: Build ToC entries from HTML markup in this case.
return;
}
const childAddresses = await this.#publicationTree.getChildAddresses(
entry.address,
);
for (const childAddress of childAddresses) {
if (!childAddress) {
continue;
}
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary
// here so that we can determine whether to render an entry as a leaf before it is fully
// resolved.
if (childAddress.split(":")[0] !== indexKind.toString()) {
this.leaves.add(childAddress);
}
// Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the
// publication tree. This is acceptable here, because the tree is always resolved
// top-down. Therefore, by the time we handle a node's resolution, its parent and
// siblings have already been resolved.
const childEntry = await this.#buildTocEntry(childAddress);
childEntry.parent = entry;
childEntry.depth = entry.depth + 1;
entry.children.push(childEntry);
this.addressMap.set(childAddress, childEntry);
}
await this.#matchChildrenToTagOrder(entry);
entry.childrenResolved = true;
};
const event = await this.#publicationTree.getEvent(address);
if (!event) {
throw new Error(`[ToC] Event ${address} not found.`);
}
const depth = (await this.#publicationTree.getHierarchy(address)).length;
const entry: TocEntry = {
address,
title: this.#getTitle(event),
href: `${this.#pagePathname}#${address}`,
children: [],
depth,
childrenResolved: false,
resolveChildren: resolver,
};
this.expandedMap.set(address, false);
// Michael J - 16 June 2025 - We determine whether to add a leaf both here and in the inner
// resolver function. The resolver function is called when entries are resolved by expanding
// a ToC entry, and we'll reach the block below when entries are resolved by the publication
// tree.
if (event.kind !== indexKind) {
this.leaves.add(address);
}
return entry;
}
/**
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding
* 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) {
if (this.addressMap.has(address)) {
return;
}
this.#buildTocEntry(address).then((entry) => {
this.addressMap.set(address, entry);
});
}
// #endregion
}

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

@ -21,7 +21,7 @@
let title: string = $derived(indexEvent.getMatchingTags("title")[0]?.[1]); let title: string = $derived(indexEvent.getMatchingTags("title")[0]?.[1]);
let author: string = $derived( let author: string = $derived(
indexEvent.getMatchingTags(event, "author")[0]?.[1] ?? "unknown", indexEvent.getMatchingTags("author")[0]?.[1] ?? "unknown",
); );
let pubkey: string = $derived( let pubkey: string = $derived(
indexEvent.getMatchingTags("p")[0]?.[1] ?? null, indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
@ -136,40 +136,38 @@
outline={true} outline={true}
onclick={backToMain} onclick={backToMain}
> >
<CaretLeftOutline class="!fill-none inline mr-1" /><span <CaretLeftOutline class="!fill-none inline mr-1" />
class="hidden sm:inline">Back</span <span class="hidden sm:inline">Back</span>
>
</Button> </Button>
{/if} {/if}
{#if !isLeaf} {#if !isLeaf}
{#if publicationType === "blog"} {#if publicationType === "blog"}
<Button <Button
class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog class={`btn-leather hidden sm:flex !w-auto ${$publicationColumnVisibility.blog ? "active" : ""}`}
? 'active'
: ''}"
outline={true} outline={true}
onclick={() => toggleColumn("blog")} onclick={() => toggleColumn("blog")}
> >
<BookOutline class="!fill-none inline mr-1" /><span <BookOutline class="!fill-none inline mr-1" />
class="hidden sm:inline">Table of Contents</span <span class="hidden sm:inline">Table of Contents</span>
>
</Button> </Button>
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc} {:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc}
<Button <Button
class="btn-leather !w-auto" class={`btn-leather !w-auto ${$publicationColumnVisibility.toc ? "active" : ""}`}
outline={true} outline={true}
onclick={() => toggleColumn("toc")} onclick={() => toggleColumn("toc")}
> >
<BookOutline class="!fill-none inline mr-1" /><span <BookOutline class="!fill-none inline mr-1" />
class="hidden sm:inline">Table of Contents</span <span class="hidden sm:inline">Table of Contents</span>
>
</Button> </Button>
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="flex flex-grow text justify-center items-center"> <<<<<<< HEAD
<div class="flex flex-col flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis"> <p class="max-w-[60vw] line-ellipsis">
<b class="text-nowrap">{title}</b> <b class="text-nowrap">{title}</b>
</p>
<p>
<span class="whitespace-nowrap" <span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author)}</span >by {@render userBadge(pubkey, author)}</span
> >
@ -182,9 +180,8 @@
outline={true} outline={true}
onclick={backToBlog} onclick={backToBlog}
> >
<CloseOutline class="!fill-none inline mr-1" /><span <CloseOutline class="!fill-none inline mr-1" />
class="hidden sm:inline">Close</span <span class="hidden sm:inline">Close</span>
>
</Button> </Button>
{/if} {/if}
{#if publicationType !== "blog" && !$publicationColumnVisibility.discussion} {#if publicationType !== "blog" && !$publicationColumnVisibility.discussion}
@ -193,9 +190,8 @@
outline={true} outline={true}
onclick={() => toggleColumn("discussion")} onclick={() => toggleColumn("discussion")}
> >
<GlobeOutline class="!fill-none inline mr-1" /><span <GlobeOutline class="!fill-none inline mr-1" />
class="hidden sm:inline">Discussion</span <span class="hidden sm:inline">Discussion</span>
>
</Button> </Button>
{/if} {/if}
<Button <Button

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

@ -1,34 +1,59 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal, Popover } from "flowbite-svelte";
import { import {
ClipboardCleanOutline,
DotsVerticalOutline, DotsVerticalOutline,
EyeOutline, EyeOutline,
ShareNodesOutline ClipboardCleanOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
// Component props // Component props
let { event } = $props<{ event: NDKEvent }>(); let { event } = $props<{ event: NDKEvent }>();
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe((val) => (user = val));
// Derive metadata from event // Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? ''); let title = $derived(
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? ''); event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "",
let image = $derived(event.tags.find((t: string[]) => t[0] === 'image')?.[1] ?? null); );
let author = $derived(event.tags.find((t: string[]) => t[0] === 'author')?.[1] ?? ''); let summary = $derived(
let originalAuthor = $derived(event.tags.find((t: string[]) => t[0] === 'original_author')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "summary")?.[1] ?? "",
let version = $derived(event.tags.find((t: string[]) => t[0] === 'version')?.[1] ?? ''); );
let source = $derived(event.tags.find((t: string[]) => t[0] === 'source')?.[1] ?? null); let image = $derived(
let type = $derived(event.tags.find((t: string[]) => t[0] === 'type')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "image")?.[1] ?? null,
let language = $derived(event.tags.find((t: string[]) => t[0] === 'language')?.[1] ?? null); );
let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null); let author = $derived(
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "author")?.[1] ?? "",
);
let originalAuthor = $derived(
event.tags.find((t: string[]) => t[0] === "original_author")?.[1] ?? null,
);
let version = $derived(
event.tags.find((t: string[]) => t[0] === "version")?.[1] ?? "",
);
let source = $derived(
event.tags.find((t: string[]) => t[0] === "source")?.[1] ?? null,
);
let type = $derived(
event.tags.find((t: string[]) => t[0] === "type")?.[1] ?? null,
);
let language = $derived(
event.tags.find((t: string[]) => t[0] === "language")?.[1] ?? null,
);
let publisher = $derived(
event.tags.find((t: string[]) => t[0] === "publisher")?.[1] ?? null,
);
let identifier = $derived(
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
);
// UI state // UI state
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
@ -36,32 +61,28 @@
/** /**
* Selects the appropriate relay set based on user state and feed type * Selects the appropriate relay set based on user state and feed type
* - Uses user's inbox relays when signed in and viewing personal feed * - Uses active inbox relays from the new relay management system
* - Falls back to standard relays for anonymous users or standard feed * - Falls back to active inbox relays for anonymous users (which include community relays)
*/ */
let activeRelays = $derived( let activeRelays = $derived(
(() => { (() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays; const relays = user.signedIn ? $activeInboxRelays : $activeInboxRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays;
console.debug("[CardActions] Selected relays:", { console.debug("[CardActions] Selected relays:", {
eventId: event.id, eventId: event.id,
isSignedIn: $ndkSignedIn, isSignedIn: user.signedIn,
feedType: $feedType,
isUserFeed,
relayCount: relays.length, relayCount: relays.length,
relayUrls: relays relayUrls: relays,
}); });
return relays; return relays;
})() })(),
); );
/** /**
* Opens the actions popover menu * Opens the actions popover menu
*/ */
function openPopover() { function openPopover() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true; isOpen = true;
} }
@ -69,9 +90,8 @@
* Closes the actions popover menu and removes focus * Closes the actions popover menu and removes focus
*/ */
function closePopover() { function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false; isOpen = false;
const menu = document.getElementById('dots-' + event.id); const menu = document.getElementById("dots-" + event.id);
if (menu) menu.blur(); if (menu) menu.blur();
} }
@ -80,10 +100,9 @@
* @param type - The type of identifier to get ('nevent' or 'naddr') * @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string * @returns The encoded identifier string
*/ */
function getIdentifier(type: 'nevent' | 'naddr'): string { function getIdentifier(type: "nevent" | "naddr"): string {
const encodeFn = type === 'nevent' ? neventEncode : naddrEncode; const encodeFn = type === "nevent" ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays); const identifier = encodeFn(event, activeRelays);
console.debug("[CardActions] ${type} identifier for event ${event.id}:", identifier);
return identifier; return identifier;
} }
@ -91,60 +110,65 @@
* Opens the event details modal * Opens the event details modal
*/ */
function viewDetails() { function viewDetails() {
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author
});
detailsModalOpen = true; detailsModalOpen = true;
} }
// Log component initialization /**
console.debug("[CardActions] Initialized", { * Navigates to the event details page
eventId: event.id, */
kind: event.kind, function viewEventDetails() {
pubkey: event.pubkey, const nevent = getIdentifier("nevent");
title: event.title, goto(`/events?id=${encodeURIComponent(nevent)}`);
author: event.author }
});
</script> </script>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}> <div
class="group bg-highlight dark:bg-primary-1000 rounded"
role="group"
onmouseenter={openPopover}
>
<!-- Main button --> <!-- Main button -->
<Button type="button" <Button
type="button"
id="dots-{event.id}" id="dots-{event.id}"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" color="none" class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
data-popover-target="popover-actions"> color="none"
<DotsVerticalOutline class="h-6 w-6"/> data-popover-target="popover-actions"
>
<DotsVerticalOutline class="h-6 w-6" />
<span class="sr-only">Open actions menu</span> <span class="sr-only">Open actions menu</span>
</Button> </Button>
{#if isOpen} {#if isOpen}
<Popover id="popover-actions" <Popover
id="popover-actions"
placement="bottom" placement="bottom"
trigger="click" trigger="click"
class='popover-leather w-fit z-10' class="popover-leather w-fit z-10"
onmouseleave={closePopover} onmouseleave={closePopover}
> >
<div class='flex flex-row justify-between space-x-4'> <div class="flex flex-row justify-between space-x-4">
<div class='flex flex-col text-nowrap'> <div class="flex flex-col text-nowrap">
<ul class="space-y-2"> <ul class="space-y-2">
<li> <li>
<button class='btn-leather w-full text-left' onclick={viewDetails}> <button
class="btn-leather w-full text-left"
onclick={viewDetails}
>
<EyeOutline class="inline mr-2" /> View details <EyeOutline class="inline mr-2" /> View details
</button> </button>
</li> </li>
<li> <li>
<CopyToClipboard <CopyToClipboard
displayText="Copy naddr address" displayText="Copy naddr address"
copyText={getIdentifier('naddr')} copyText={getIdentifier("naddr")}
icon={ShareNodesOutline} icon={ClipboardCleanOutline}
/> />
</li> </li>
<li> <li>
<CopyToClipboard <CopyToClipboard
displayText="Copy nevent address" displayText="Copy nevent address"
copyText={getIdentifier('nevent')} copyText={getIdentifier("nevent")}
icon={ClipboardCleanOutline} icon={ClipboardCleanOutline}
/> />
</li> </li>
@ -154,41 +178,68 @@
</Popover> </Popover>
{/if} {/if}
<!-- Event details --> <!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'> <Modal
class="modal-leather"
title="Publication details"
bind:open={detailsModalOpen}
autoclose
outsideclose
size="sm"
>
<div class="flex flex-row space-x-4"> <div class="flex flex-row space-x-4">
{#if image} {#if image}
<div class="flex col"> <div
<img src={image} alt="Publication cover" class="w-32 h-32 object-cover rounded" /> class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
>
<img
src={image}
alt="Publication cover"
class="rounded w-full h-full object-cover"
/>
</div> </div>
{/if} {/if}
<div class="flex flex-col col space-y-5 justify-center align-middle"> <div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title || 'Untitled'}</h1> <h1 class="text-3xl font-bold mt-5">{title || "Untitled"}</h1>
<h2 class="text-base font-bold">by <h2 class="text-base font-bold">
by
{#if originalAuthor} {#if originalAuthor}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author)}
{:else} {:else}
{author || 'Unknown'} {author || "Unknown"}
{/if} {/if}
</h2> </h2>
{#if version} {#if version}
<h4 class='text-base font-thin mt-2'>Version: {version}</h4> <h4
class="text-base font-medium text-primary-700 dark:text-primary-300 mt-2"
>
Version: {version}
</h4>
{/if} {/if}
</div> </div>
</div> </div>
{#if summary} {#if summary}
<div class="flex flex-row"> <div class="flex flex-row">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p> <p class="text-base text-primary-900 dark:text-highlight">{summary}</p>
</div> </div>
{/if} {/if}
<div class="flex flex-row"> <div class="flex flex-row">
<h4 class='text-base font-normal mt-2'>Index author: {@render userBadge(event.pubkey, author)}</h4> <h4 class="text-base font-normal mt-2">
Index author: {@render userBadge(event.pubkey, author)}
</h4>
</div> </div>
<div class="flex flex-col pb-4 space-y-1"> <div class="flex flex-col pb-4 space-y-1">
{#if source} {#if source}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank" rel="noopener noreferrer">{source}</a></h5> <h5 class="text-sm">
Source: <a
class="underline"
href={source}
target="_blank"
rel="noopener noreferrer">{source}</a
>
</h5>
{/if} {/if}
{#if type} {#if type}
<h5 class="text-sm">Publication type: {type}</h5> <h5 class="text-sm">Publication type: {type}</h5>
@ -202,12 +253,12 @@
{#if identifier} {#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5> <h5 class="text-sm">Identifier: {identifier}</h5>
{/if} {/if}
<a <button
href="/events?id={getIdentifier('nevent')}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold" class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
onclick={viewEventDetails}
> >
View Event Details View Event Details
</a> </button>
</div> </div>
</Modal> </Modal>
</div> </div>

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

@ -0,0 +1,115 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { findContainingIndexEvents } from "$lib/utils/event_search";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
let { event } = $props<{
event: NDKEvent;
}>();
let containingIndexes = $state<NDKEvent[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let lastEventId = $state<string | null>(null);
async function loadContainingIndexes() {
console.log(
"[ContainingIndexes] Loading containing indexes for event:",
event.id,
);
loading = true;
error = null;
try {
containingIndexes = await findContainingIndexEvents(event);
console.log(
"[ContainingIndexes] Found containing indexes:",
containingIndexes.length,
);
} catch (err) {
error =
err instanceof Error
? err.message
: "Failed to load containing indexes";
console.error(
"[ContainingIndexes] Error loading containing indexes:",
err,
);
} finally {
loading = false;
}
}
function navigateToIndex(indexEvent: NDKEvent) {
const dTag = getMatchingTags(indexEvent, "d")[0]?.[1];
if (dTag) {
goto(`/publication?d=${encodeURIComponent(dTag)}`);
} else {
// Fallback to naddr
try {
const naddr = naddrEncode(indexEvent, $activeInboxRelays);
goto(`/publication?id=${encodeURIComponent(naddr)}`);
} catch (err) {
console.error("[ContainingIndexes] Error creating naddr:", err);
}
}
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
$effect(() => {
// Only reload if the event ID has actually changed
if (event.id !== lastEventId) {
lastEventId = event.id;
loadContainingIndexes();
}
});
</script>
{#if containingIndexes.length > 0 || loading || error}
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Containing Publications
</h4>
{#if loading}
<div class="text-sm text-gray-500 dark:text-gray-400">
Loading containing publications...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400">
{error}
</div>
{:else if containingIndexes.length > 0}
<div class="max-h-32 overflow-y-auto">
{#each containingIndexes.slice(0, 3) as indexEvent}
{@const title =
getMatchingTags(indexEvent, "title")[0]?.[1] || "Untitled"}
<Button
size="xs"
color="alternative"
class="mb-1 mr-1 text-xs"
onclick={() => navigateToIndex(indexEvent)}
>
{title}
</Button>
{/each}
{#if containingIndexes.length > 3}
<span class="text-xs text-gray-500 dark:text-gray-400">
+{containingIndexes.length - 3} more
</span>
{/if}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
No containing publications found
</div>
{/if}
</div>
{/if}

30
src/lib/components/util/CopyToClipboard.svelte

@ -1,9 +1,16 @@
<script lang='ts'> <script lang="ts">
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons"; import {
ClipboardCheckOutline,
ClipboardCleanOutline,
} from "flowbite-svelte-icons";
import { withTimeout } from "$lib/utils/nostrUtils"; import { withTimeout } from "$lib/utils/nostrUtils";
import type { Component } from "svelte"; import type { Component } from "svelte";
let { displayText, copyText = displayText, icon = ClipboardCleanOutline } = $props<{ let {
displayText,
copyText = displayText,
icon = ClipboardCleanOutline,
} = $props<{
displayText: string; displayText: string;
copyText?: string; copyText?: string;
icon?: Component | false; icon?: Component | false;
@ -16,21 +23,26 @@
await withTimeout(navigator.clipboard.writeText(copyText), 2000); await withTimeout(navigator.clipboard.writeText(copyText), 2000);
copied = true; copied = true;
await withTimeout( await withTimeout(
new Promise(resolve => setTimeout(resolve, 4000)), new Promise((resolve) => setTimeout(resolve, 4000)),
4000 4000,
).then(() => { )
.then(() => {
copied = false; copied = false;
}).catch(() => { })
.catch(() => {
// If timeout occurs, still reset the state // If timeout occurs, still reset the state
copied = false; copied = false;
}); });
} catch (err) { } catch (err) {
console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err); console.error(
"[CopyToClipboard] Failed to copy:",
err instanceof Error ? err.message : err,
);
} }
} }
</script> </script>
<button class='btn-leather w-full text-left' onclick={copyToClipboard}> <button class="btn-leather w-full text-left" onclick={copyToClipboard}>
{#if copied} {#if copied}
<ClipboardCheckOutline class="inline mr-2" /> Copied! <ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else} {:else}

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

@ -3,57 +3,111 @@
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte"; import { P } from "flowbite-svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
// - don't show all the details when _not_ in modal view // - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props(); let { event, isModal = false } = $props();
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]); let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1'); getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null); );
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null); let version: string = $derived(
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null); getMatchingTags(event, "version")[0]?.[1] ?? "1",
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null); );
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null); let image: string = $derived(getMatchingTags(event, "image")[0]?.[1] ?? null);
let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null); let summary: string = $derived(
let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null); getMatchingTags(event, "summary")[0]?.[1] ?? null,
let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null); );
let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1])); let type: string = $derived(getMatchingTags(event, "type")[0]?.[1] ?? null);
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null); let language: string = $derived(getMatchingTags(event, "l")[0]?.[1] ?? null);
let source: string = $derived(
getMatchingTags(event, "source")[0]?.[1] ?? null,
);
let publisher: string = $derived(
getMatchingTags(event, "published_by")[0]?.[1] ?? null,
);
let identifier: string = $derived(
getMatchingTags(event, "i")[0]?.[1] ?? null,
);
let hashtags: string[] = $derived(
getMatchingTags(event, "t").map((tag) => tag[1]),
);
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind); let kind = $derived(event.kind);
let authorTag: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "",
);
let pTag: string = $derived(getMatchingTags(event, "p")[0]?.[1] ?? "");
let originalAuthor: string = $derived(
getMatchingTags(event, "p")[0]?.[1] ?? null,
);
function isValidNostrPubkey(str: string): boolean {
return (
/^[a-f0-9]{64}$/i.test(str) ||
(str.startsWith("npub1") && str.length >= 59 && str.length <= 63)
);
}
</script> </script>
<div class="flex flex-col relative mb-2"> <div class="flex flex-col relative mb-2">
{#if !isModal} {#if !isModal}
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P> <!-- Index author badge -->
<CardActions event={event}></CardActions> <P class="text-base font-normal"
>{@render userBadge(event.pubkey, author)}</P
>
<CardActions {event}></CardActions>
</div> </div>
{/if} {/if}
<div class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"> <div
{#if image} class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"
>
<div class="my-2"> <div class="my-2">
<img class="w-full md:max-w-48 object-contain rounded" alt={title} src={image} /> {#if image}
<LazyImage
src={image}
alt={title}
eventId={event.id}
className="w-full md:max-w-48 object-contain rounded"
/>
{:else}
<div
class="w-full md:max-w-48 h-32 object-contain rounded"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div> </div>
{/if} {/if}
</div>
<div class="space-y-4 my-4"> <div class="space-y-4 my-4">
<h1 class="text-3xl font-bold">{title}</h1> <h1 class="text-3xl font-bold">{title}</h1>
<h2 class="text-base font-bold"> <h2 class="text-base font-bold">
by by
{#if originalAuthor !== null} {#if authorTag && pTag && isValidNostrPubkey(pTag)}
{authorTag} {@render userBadge(pTag, "")}
{:else if authorTag}
{authorTag}
{:else if pTag && isValidNostrPubkey(pTag)}
{@render userBadge(pTag, "")}
{:else if originalAuthor !== null}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author)}
{:else} {:else}
{author} unknown
{/if} {/if}
</h2> </h2>
{#if version !== '1' } {#if version !== "1"}
<h4 class="text-base font-thin">Version: {version}</h4> <h4
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
Version: {version}
</h4>
{/if} {/if}
</div> </div>
</div> </div>
@ -61,34 +115,41 @@
{#if summary} {#if summary}
<div class="flex flex-row my-2"> <div class="flex flex-row my-2">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p> <p class="text-base text-primary-900 dark:text-highlight">{summary}</p>
</div> </div>
{/if} {/if}
{#if hashtags.length} {#if hashtags.length}
<div class="tags my-2"> <div class="tags my-2">
{#each hashtags as tag} {#each hashtags as tag}
<span class="text-sm">#{tag}</span> <button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="text-sm hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer"
>#{tag}</button
>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if isModal} {#if isModal}
<div class="flex flex-row my-4"> <div class="flex flex-row my-4">
<h4 class='text-base font-normal mt-2'> <h4 class="text-base font-normal mt-2">
{#if kind === 30040} {#if kind === 30040}
<span>Index author:</span> <span>Index author:</span>
{:else} {:else}
<span>Author:</span> <span>Author:</span>
{/if} {/if}
{@render userBadge(event.pubkey, author)} {@render userBadge(event.pubkey, "")}
</h4> </h4>
</div> </div>
<div class="flex flex-col pb-4 space-y-1"> <div class="flex flex-col pb-4 space-y-1">
{#if source !== null} {#if source !== null}
<h5 class="text-sm">Source: <a class="underline break-all" href={source} target="_blank">{source}</a></h5> <h5 class="text-sm">
Source: <a class="underline break-all" href={source} target="_blank"
>{source}</a
>
</h5>
{/if} {/if}
{#if type !== null} {#if type !== null}
<h5 class="text-sm">Publication type: {type}</h5> <h5 class="text-sm">Publication type: {type}</h5>
@ -106,5 +167,5 @@
{/if} {/if}
{#if !isModal} {#if !isModal}
<Interactions event={event} rootId={rootId} direction="row"/> <Interactions {event} {rootId} direction="row" />
{/if} {/if}

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

@ -1,15 +1,21 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal, P } from "flowbite-svelte";
import { import {
Button, Modal, P HeartOutline,
} from "flowbite-svelte"; FilePenOutline,
import { HeartOutline, FilePenOutline, AnnotationOutline } from 'flowbite-svelte-icons'; AnnotationOutline,
} from "flowbite-svelte-icons";
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 { ndkInstance } from "$lib/ndk";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
const { rootId, event, direction = 'row' } = $props<{ rootId: string, event?: NDKEvent, direction?: string }>(); const {
rootId,
event,
direction = "row",
} = $props<{ rootId: string; event?: NDKEvent; direction?: string }>();
// Reactive arrays to hold incoming events // Reactive arrays to hold incoming events
let likes: NDKEvent[] = []; let likes: NDKEvent[] = [];
@ -34,13 +40,12 @@
function subscribeCount(kind: number, targetArray: NDKEvent[]) { function subscribeCount(kind: number, targetArray: NDKEvent[]) {
const sub = $ndkInstance.subscribe({ const sub = $ndkInstance.subscribe({
kinds: [kind], kinds: [kind],
'#a': [rootId] // Will this work? "#a": [rootId], // Will this work?
}); });
sub.on("event", (evt: NDKEvent) => {
sub.on('event', (evt: NDKEvent) => {
// Only add if we haven't seen this event ID yet // Only add if we haven't seen this event ID yet
if (!targetArray.find(e => e.id === evt.id)) { if (!targetArray.find((e) => e.id === evt.id)) {
targetArray.push(evt); targetArray.push(evt);
} }
}); });
@ -59,11 +64,11 @@
}); });
function showDiscussion() { function showDiscussion() {
publicationColumnVisibility.update(v => { publicationColumnVisibility.update((v) => {
const updated = { ...v, discussion: true}; const updated = { ...v, discussion: true };
// hide blog, unless the only column // hide blog, unless the only column
if (v.inner) { if (v.inner) {
updated.blog = (v.blog && window.innerWidth >= 1400 ); updated.blog = v.blog && window.innerWidth >= 1400;
} }
return updated; return updated;
}); });
@ -80,14 +85,45 @@
} }
</script> </script>
<div class='InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-500'> <div
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doLike}><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button> class="InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300"
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doZap}><ZapOutline className="mx-2" /><span>{zapCount}</span></Button> >
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doHighlight}><FilePenOutline class="mx-2" size="lg"/><span>{highlightCount}</span></Button> <Button
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={showDiscussion}><AnnotationOutline class="mx-2" size="lg"/><span>{commentCount}</span></Button> color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doLike}
><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button
>
<Button
color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doZap}
><ZapOutline className="mx-2" /><span>{zapCount}</span></Button
>
<Button
color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doHighlight}
><FilePenOutline class="mx-2" size="lg" /><span>{highlightCount}</span
></Button
>
<Button
color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={showDiscussion}
><AnnotationOutline class="mx-2" size="lg" /><span>{commentCount}</span
></Button
>
</div> </div>
<Modal class='modal-leather' title='Interaction' bind:open={interactionOpen} autoclose outsideclose size='sm'> <Modal
class="modal-leather"
title="Interaction"
bind:open={interactionOpen}
autoclose
outsideclose
size="sm"
>
<P>Can't like, zap or highlight yet.</P> <P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P> <P>You should totally check out the discussion though.</P>
</Modal> </Modal>

90
src/lib/components/util/LazyImage.svelte

@ -0,0 +1,90 @@
<script lang="ts">
import { generateDarkPastelColor } from '$lib/utils/image_utils';
import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
let {
src,
alt,
eventId,
className = 'w-full h-full object-cover',
placeholderClassName = '',
}: {
src: string;
alt: string;
eventId: string;
className?: string;
placeholderClassName?: string;
} = $props();
let imageLoaded = $state(false);
let imageError = $state(false);
let imgElement = $state<HTMLImageElement | null>(null);
const placeholderColor = $derived.by(() => generateDarkPastelColor(eventId));
function loadImage() {
if (!imgElement) return;
imgElement.onload = () => {
// Small delay to ensure smooth transition
setTimeout(() => {
imageLoaded = true;
}, 100);
};
imgElement.onerror = () => {
imageError = true;
};
// Set src after setting up event handlers
imgElement.src = src;
}
function bindImg(element: HTMLImageElement) {
imgElement = element;
// Load image immediately when element is bound
loadImage();
}
</script>
<div class="relative w-full h-full">
<!-- Placeholder -->
<div
class="absolute inset-0 {placeholderClassName}"
style="background-color: {placeholderColor};"
class:hidden={imageLoaded}
>
</div>
<!-- Image -->
<img
bind:this={imgElement}
{src}
{alt}
class="{className} {imageLoaded ? 'opacity-100' : 'opacity-0'}"
style="transition: opacity 0.2s ease-out;"
loading="lazy"
decoding="async"
class:hidden={imageError}
onload={() => {
setTimeout(() => {
imageLoaded = true;
}, 100);
}}
onerror={() => {
imageError = true;
}}
/>
<!-- Error state -->
{#if imageError}
<div
class="absolute inset-0 flex items-center justify-center bg-gray-200 dark:bg-gray-700 {placeholderClassName}"
>
<div class="text-gray-500 dark:text-gray-400 text-xs">
Failed to load
</div>
</div>
{/if}
</div>

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

@ -1,83 +1,520 @@
<script lang='ts'> <script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logout, ndkInstance } from '$lib/ndk'; import NetworkStatus from "$components/NetworkStatus.svelte";
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons"; import {
import { Avatar, Popover } from "flowbite-svelte"; logoutUser,
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; userStore,
loginWithExtension,
loginWithAmber,
loginWithNpub
} from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import {
ArrowRightToBracketOutline,
UserOutline,
FileSearchOutline,
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import { activeInboxRelays } from "$lib/ndk";
const externalProfileDestination = './events?id=' let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>();
let { pubkey, isNav = false } = $props(); // UI state for login functionality
let isLoadingExtension: boolean = $state(false);
let isLoadingAmber: boolean = $state(false);
let result: string | null = $state(null);
let nostrConnectUri: string | undefined = $state(undefined);
let showQrCode: boolean = $state(false);
let qrCodeDataUrl: string | undefined = $state(undefined);
let loginButtonRef: HTMLElement | undefined = $state();
let resultTimeout: ReturnType<typeof setTimeout> | null = null;
let profileAvatarId = "profile-avatar-btn";
let showAmberFallback = $state(false);
let fallbackCheckInterval: ReturnType<typeof setInterval> | null = null;
let isRefreshingProfile = $state(false);
let profile = $state<NDKUserProfile | null>(null); onMount(() => {
let pfp = $derived(profile?.image); if (localStorage.getItem("alexandria/amber/fallback") === "1") {
let username = $derived(profile?.name); console.log("Profile: Found fallback flag on mount, showing modal");
let tag = $derived(profile?.name); showAmberFallback = true;
let npub = $state<string | undefined >(undefined); }
});
// Use profile data from userStore
let userState = $derived($userStore);
let profile = $derived(userState.profile);
let pfp = $derived(profile?.picture);
let username = $derived(profile?.name);
let tag = $derived(profile?.name);
let npub = $derived(userState.npub);
// Debug logging
$effect(() => {
console.log("Profile component - userState:", userState);
console.log("Profile component - profile:", profile);
console.log("Profile component - pfp:", pfp);
console.log("Profile component - username:", username);
});
// Handle user state changes with effects
$effect(() => {
const currentUser = userState;
// Check for fallback flag when user state changes to signed in
if (
currentUser.signedIn &&
localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"Profile: User signed in and fallback flag found, showing modal",
);
showAmberFallback = true;
}
// Set up periodic check when user is signed in
if (currentUser.signedIn && !fallbackCheckInterval) {
fallbackCheckInterval = setInterval(() => {
if (
localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"Profile: Found fallback flag during periodic check, showing modal",
);
showAmberFallback = true;
}
}, 500); // Check every 500ms
} else if (!currentUser.signedIn && fallbackCheckInterval) {
clearInterval(fallbackCheckInterval);
fallbackCheckInterval = null;
}
});
// Auto-refresh profile when user signs in
$effect(() => {
const currentUser = userState;
// If user is signed in and we have an npub but no profile data, refresh it
if (currentUser.signedIn && currentUser.npub && !profile?.name && !isRefreshingProfile) {
console.log("Profile: User signed in but no profile data, refreshing...");
refreshProfile();
}
});
// Debug activeInboxRelays
$effect(() => {
const inboxRelays = get(activeInboxRelays);
console.log("Profile component - activeInboxRelays:", inboxRelays);
});
// Track if we've already refreshed the profile for this session
let hasRefreshedProfile = $state(false);
// Reset the refresh flag when user logs out
$effect(() => {
const currentUser = userState;
if (!currentUser.signedIn) {
hasRefreshedProfile = false;
}
});
$effect(() => { // Manual trigger to refresh profile when user signs in (only once)
const user = $ndkInstance $effect(() => {
.getUser({ pubkey: pubkey ?? undefined }); const currentUser = userState;
npub = user.npub; if (currentUser.signedIn && currentUser.npub && !isRefreshingProfile && !hasRefreshedProfile) {
console.log("Profile: User signed in, triggering profile refresh...");
hasRefreshedProfile = true;
// Add a small delay to ensure relays are ready
setTimeout(() => {
refreshProfile();
}, 1000);
}
});
// Refresh profile when login method changes (e.g., Amber to read-only)
$effect(() => {
const currentUser = userState;
if (currentUser.signedIn && currentUser.npub && currentUser.loginMethod && !isRefreshingProfile) {
console.log("Profile: Login method detected:", currentUser.loginMethod);
// If switching to read-only mode (npub), refresh profile
if (currentUser.loginMethod === "npub" && !hasRefreshedProfile) {
console.log("Profile: Switching to read-only mode, refreshing profile...");
hasRefreshedProfile = true;
setTimeout(() => {
refreshProfile();
}, 500);
}
}
});
// Track login method changes and refresh profile when switching from Amber to npub
let previousLoginMethod = $state<string | null>(null);
$effect(() => {
const currentUser = userState;
if (currentUser.signedIn && currentUser.loginMethod !== previousLoginMethod && !isRefreshingProfile) {
console.log("Profile: Login method changed from", previousLoginMethod, "to", currentUser.loginMethod);
user.fetchProfile() // If switching from Amber to npub (read-only), refresh profile
.then(userProfile => { if (previousLoginMethod === "amber" && currentUser.loginMethod === "npub" && !hasRefreshedProfile) {
profile = userProfile; console.log("Profile: Switching from Amber to read-only mode, refreshing profile...");
hasRefreshedProfile = true;
setTimeout(() => {
refreshProfile();
}, 1000);
}
previousLoginMethod = currentUser.loginMethod;
}
}); });
});
async function handleSignOutClick() { // Function to refresh profile data
logout($ndkInstance.activeUser!); async function refreshProfile() {
profile = null; if (!userState.signedIn || !userState.npub) return;
}
isRefreshingProfile = true;
try {
console.log("Refreshing profile for npub:", userState.npub);
// Try using NDK's built-in profile fetching first
const ndk = get(ndkInstance);
if (ndk && userState.ndkUser) {
console.log("Using NDK's built-in profile fetching");
const userProfile = await userState.ndkUser.fetchProfile();
console.log("NDK profile fetch result:", userProfile);
if (userProfile) {
const profileData = {
name: userProfile.name,
displayName: userProfile.displayName,
nip05: userProfile.nip05,
picture: userProfile.image,
about: userProfile.bio,
banner: userProfile.banner,
website: userProfile.website,
lud16: userProfile.lud16,
};
console.log("Converted profile data:", profileData);
// Update the userStore with fresh profile data
userStore.update(currentState => ({
...currentState,
profile: profileData
}));
return;
}
}
// Fallback to getUserMetadata
console.log("Falling back to getUserMetadata");
const freshProfile = await getUserMetadata(userState.npub, true); // Force fresh fetch
console.log("Fresh profile data from getUserMetadata:", freshProfile);
function shortenNpub(long: string|undefined) { // Update the userStore with fresh profile data
if (!long) return ''; userStore.update(currentState => ({
return long.slice(0, 8) + '…' + long.slice(-4); ...currentState,
} profile: freshProfile
}));
} catch (error) {
console.error("Failed to refresh profile:", error);
} finally {
isRefreshingProfile = false;
}
}
// Generate QR code
const generateQrCode = async (text: string): Promise<string> => {
try {
const QRCode = await import("qrcode");
return await QRCode.toDataURL(text, {
width: 256,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
} catch (err) {
console.error("Failed to generate QR code:", err);
return "";
}
};
// Copy to clipboard function
const copyToClipboard = async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
result = "✅ URI copied to clipboard!";
} catch (err) {
result = "❌ Failed to copy to clipboard";
}
};
// Helper to show result message near avatar and auto-dismiss
function showResultMessage(msg: string) {
result = msg;
if (resultTimeout) {
clearTimeout(resultTimeout);
}
resultTimeout = setTimeout(() => {
result = null;
}, 4000);
}
// Login handlers
const handleBrowserExtensionLogin = async () => {
isLoadingExtension = true;
isLoadingAmber = false;
try {
await loginWithExtension();
} catch (err: unknown) {
showResultMessage(
`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
isLoadingExtension = false;
}
};
const handleAmberLogin = async () => {
isLoadingAmber = true;
isLoadingExtension = false;
try {
const ndk = new NDK();
const relay = "wss://relay.nsec.app";
const localNsec =
localStorage.getItem("amber/nsec") ??
NDKPrivateKeySigner.generate().nsec;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: "Alexandria",
perms: "sign_event:1;sign_event:4",
});
if (amberSigner.nostrConnectUri) {
nostrConnectUri = amberSigner.nostrConnectUri ?? undefined;
showQrCode = true;
qrCodeDataUrl =
(await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined;
const user = await amberSigner.blockUntilReady();
await loginWithAmber(amberSigner, user);
showQrCode = false;
} else {
throw new Error("Failed to generate Nostr Connect URI");
}
} catch (err: unknown) {
showResultMessage(
`❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
isLoadingAmber = false;
}
};
const handleReadOnlyLogin = async () => {
const inputNpub = prompt("Enter your npub (public key):");
if (inputNpub) {
try {
await loginWithNpub(inputNpub);
} catch (err: unknown) {
showResultMessage(
`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
};
async function handleSignOutClick() {
localStorage.removeItem("amber/nsec");
localStorage.removeItem("alexandria/amber/fallback");
logoutUser();
}
function handleViewProfile() {
if (npub) {
goto(`/events?id=${encodeURIComponent(npub)}`);
}
}
function handleAmberReconnect() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
handleAmberLogin();
}
function handleAmberFallbackDismiss() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
// Refresh profile when switching to read-only mode
setTimeout(() => {
console.log("Profile: Amber fallback dismissed, refreshing profile for read-only mode...");
refreshProfile();
}, 500);
}
function shortenNpub(long: string | null | undefined) {
if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4);
}
</script> </script>
<div class="relative"> <div class="relative">
{#if profile} {#if !userState.signedIn}
<!-- Login button -->
<div class="group"> <div class="group">
<button
bind:this={loginButtonRef}
id="login-avatar"
class="h-6 w-6 rounded-full bg-gray-300 flex items-center justify-center cursor-pointer hover:bg-gray-400 transition-colors"
>
<UserOutline class="h-4 w-4 text-gray-600" />
</button>
<Popover
placement="bottom"
triggeredBy="#login-avatar"
class="popover-leather w-[200px]"
trigger="click"
>
<div class="flex flex-col space-y-2">
<h3 class="text-lg font-bold mb-2">Login with...</h3>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleBrowserExtensionLogin}
disabled={isLoadingExtension || isLoadingAmber}
>
{#if isLoadingExtension}
🔄 Connecting...
{:else}
🌐 Browser extension
{/if}
</button>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleAmberLogin}
disabled={isLoadingAmber || isLoadingExtension}
>
{#if isLoadingAmber}
🔄 Connecting...
{:else}
📱 Amber: NostrConnect
{/if}
</button>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleReadOnlyLogin}
>
📖 npub (read only)
</button>
<div class="border-t border-gray-200 pt-2 mt-2">
<div class="text-xs text-gray-500 mb-1">Network Status:</div>
<NetworkStatus />
</div>
</div>
</Popover>
{#if result}
<div
class="absolute right-0 top-10 z-50 bg-gray-100 p-3 rounded text-sm break-words whitespace-pre-line max-w-lg shadow-lg border border-gray-300"
>
{result}
<button
class="ml-2 text-gray-500 hover:text-gray-700"
onclick={() => (result = null)}>✖</button
>
</div>
{/if}
</div>
{:else}
<!-- User profile -->
<div class="group">
<button
class="h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer"
id={profileAvatarId}
type="button"
aria-label="Open profile menu"
>
{#if !pfp}
<div class="h-6 w-6 rounded-full bg-gray-300 animate-pulse cursor-pointer"></div>
{:else}
<Avatar <Avatar
rounded rounded
class='h-6 w-6 cursor-pointer' class="h-6 w-6 cursor-pointer"
src={pfp} src={pfp}
alt={username} alt={username || "User"}
id="profile-avatar"
/> />
{#key username || tag} {/if}
</button>
<Popover <Popover
placement="bottom" placement="bottom"
triggeredBy="#profile-avatar" triggeredBy={`#${profileAvatarId}`}
class='popover-leather w-[180px]' class="popover-leather w-[220px]"
trigger='hover' trigger="click"
> >
<div class='flex flex-row justify-between space-x-4'> <div class="flex flex-row justify-between space-x-4">
<div class='flex flex-col'> <div class="flex flex-col">
{#if username} {#if username}
<h3 class='text-lg font-bold'>{username}</h3> <h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class='text-base'>@{tag}</h4>{/if} {#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{:else if !pfp}
<h3 class="text-lg font-bold">Loading profile...</h3>
{:else}
<h3 class="text-lg font-bold">Loading...</h3>
{/if} {/if}
<ul class="space-y-2 mt-2"> <ul class="space-y-2 mt-2">
<li> <li>
<CopyToClipboard displayText={shortenNpub(npub)} copyText={npub} /> <CopyToClipboard
displayText={shortenNpub(npub) || "Loading..."}
copyText={npub || ""}
/>
</li>
<li>
<button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
onclick={handleViewProfile}
>
<UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span>
</button>
</li>
<li class="text-xs text-gray-500">
{#if userState.loginMethod === "extension"}
Logged in with extension
{:else if userState.loginMethod === "amber"}
Logged in with Amber
{:else if userState.loginMethod === "npub"}
Logged in with npub
{:else}
Unknown login method
{/if}
</li> </li>
<li> <li>
<a class='hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0' href='{externalProfileDestination}{npub}' target='_blank'> <NetworkStatus />
<UserOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /><span class='underline'>View profile</span>
</a>
</li> </li>
{#if isNav} {#if isNav}
<li> <li>
<button <button
id='sign-out-button' id="sign-out-button"
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick} onclick={handleSignOutClick}
> >
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out <ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button> </button>
</li> </li>
{:else} {:else}
@ -93,7 +530,101 @@ function shortenNpub(long: string|undefined) {
</div> </div>
</div> </div>
</Popover> </Popover>
{/key}
</div> </div>
{/if} {/if}
</div> </div>
{#if showQrCode && qrCodeDataUrl}
<!-- QR Code Modal -->
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
Scan with Amber
</h2>
<p class="text-sm text-gray-600 mb-4">
Open Amber on your phone and scan this QR code
</p>
<div class="flex justify-center mb-4">
<img
src={qrCodeDataUrl || ""}
alt="Nostr Connect QR Code"
class="border-2 border-gray-300 rounded-lg"
width="256"
height="256"
/>
</div>
<div class="space-y-2">
<label
for="nostr-connect-uri-modal"
class="block text-sm font-medium text-gray-700"
>Or copy the URI manually:</label
>
<div class="flex">
<input
id="nostr-connect-uri-modal"
type="text"
value={nostrConnectUri || ""}
readonly
class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50"
placeholder="nostrconnect://..."
/>
<button
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-r text-sm font-medium transition-colors"
onclick={() => copyToClipboard(nostrConnectUri || "")}
>
📋 Copy
</button>
</div>
</div>
<div class="text-xs text-gray-500 mt-4">
<p>1. Open Amber on your phone</p>
<p>2. Scan the QR code above</p>
<p>3. Approve the connection in Amber</p>
</div>
<button
class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={() => (showQrCode = false)}
>
Close
</button>
</div>
</div>
</div>
{/if}
{#if showAmberFallback}
<div
class="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50"
>
<div
class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg border border-primary-300"
>
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
Amber Session Restored
</h2>
<p class="text-sm text-gray-600 mb-4">
Your Amber wallet session could not be restored automatically, so
you've been switched to read-only mode.<br />
You can still browse and read content, but you'll need to reconnect Amber
to publish or comment.
</p>
<button
class="mt-4 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={handleAmberReconnect}
>
Reconnect Amber
</button>
<button
class="mt-2 ml-4 bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={handleAmberFallbackDismiss}
>
Continue in Read-Only Mode
</button>
</div>
</div>
</div>
{/if}

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

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import QRCode from 'qrcode'; import QRCode from "qrcode";
export let value: string; export let value: string;
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;

143
src/lib/components/util/TocToggle.svelte

@ -1,143 +0,0 @@
<script lang="ts">
import {
Heading,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { pharosInstance, tocUpdate } from "$lib/parser";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId } = $props<{ rootId: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
}
const tocBreakpoint = 1140;
let activeHash = $state(window.location.hash);
interface TocItem {
label: string;
hash: string;
}
// Get TOC items from parser
let tocItems = $state<TocItem[]>([]);
$effect(() => {
// This will re-run whenever tocUpdate changes
tocUpdate;
const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log('TOC rootId:', rootId, 'childIds:', childIds);
const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId);
if (title) {
items.push({
label: title,
hash: `#${nodeId}`
});
}
const children = $pharosInstance.getChildIndexIds(nodeId);
children.forEach(processNode);
};
childIds.forEach(processNode);
tocItems = items;
});
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
}
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
}
}
}
function updateActiveHash() {
activeHash = window.location.hash;
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update(v => ({ ...v, toc: window.innerWidth >= tocBreakpoint }));
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
// Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update(v => ({ ...v, toc: false}));
}
}
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", updateActiveHash);
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
return () => {
window.removeEventListener("hashchange", updateActiveHash);
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
};
});
</script>
<!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc}
<Sidebar class='sidebar-leather left-0'>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather'>
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading>
<p>(This ToC is only for demo purposes, and is not fully-functional.)</p>
{#each tocItems as item}
<SidebarItem
class="sidebar-item-leather {activeHash === item.hash ? 'bg-primary-200 font-bold' : ''}"
label={item.label}
href={item.hash}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}

84
src/lib/components/util/ViewPublicationLink.svelte

@ -0,0 +1,84 @@
<script lang="ts">
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { getEventType } from "$lib/utils/mime";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { communityRelays } from "$lib/consts";
import { goto } from "$app/navigation";
let { event, className = "" } = $props<{
event: NDKEvent;
className?: string;
}>();
function getDeferralNaddr(event: NDKEvent): string | undefined {
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...']
return getMatchingTags(event, "deferral")[0]?.[1];
}
function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable";
}
function getNaddrAddress(event: NDKEvent): string | null {
if (!isAddressableEvent(event)) {
return null;
}
try {
return naddrEncode(event, $activeInboxRelays);
} catch {
return null;
}
}
function getViewPublicationNaddr(event: NDKEvent): string | null {
// First, check for a-tags with 'defer' - these indicate the event is deferring to someone else's version
const aTags = getMatchingTags(event, "a");
for (const tag of aTags) {
if (tag.length >= 2 && tag.includes("defer")) {
// This is a deferral to someone else's addressable event
return tag[1]; // Return the addressable event address
}
}
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function navigateToPublication() {
const naddrAddress = getViewPublicationNaddr(event);
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
naddrAddress,
isAddressable: isAddressableEvent(event),
});
if (naddrAddress) {
console.log(
"ViewPublicationLink: Navigating to publication:",
naddrAddress,
);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
}
}
let naddrAddress = $derived(getViewPublicationNaddr(event));
</script>
{#if naddrAddress}
<button
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg transition-colors {className}"
onclick={navigateToPublication}
tabindex="0"
>
View Publication
</button>
{/if}

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

@ -1,6 +1,6 @@
<script> <script>
export let size = 24; // default size export let size = 24; // default size
export let className = ''; export let className = "";
</script> </script>
<svg <svg
@ -15,5 +15,5 @@
class={className} class={className}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/> <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg> </svg>

61
src/lib/consts.ts

@ -1,23 +1,52 @@
// AI SHOULD NEVER CHANGE THIS FILE
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ]; export const zettelKinds = [30041, 30818];
export const communityRelay = [ 'wss://theforest.nostr1.com' ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ]; export const communityRelays = [
export const fallbackRelays = [ "wss://theforest.nostr1.com",
'wss://purplepag.es', //"wss://theforest.gitcitadel.eu"
'wss://indexer.coracle.social', ];
'wss://relay.noswhere.com',
'wss://relay.damus.io', export const searchRelays = [
'wss://relay.nostr.band', "wss://profiles.nostr1.com",
'wss://relay.lumina.rocks', "wss://aggr.nostr.land",
'wss://nostr.wine', "wss://relay.noswhere.com",
'wss://nostr.land' "wss://nostr.wine",
];
export const secondaryRelays = [
"wss://theforest.nostr1.com",
//"wss://theforest.gitcitadel.eu"
"wss://thecitadel.nostr1.com",
//"wss://thecitadel.gitcitadel.eu",
"wss://nostr.land",
"wss://nostr.wine",
"wss://nostr.sovbit.host",
"wss://nostr21.com",
];
export const anonymousRelays = [
"wss://freelay.sovbit.host",
"wss://thecitadel.nostr1.com"
];
export const lowbandwidthRelays = [
"wss://theforest.nostr1.com",
"wss://thecitadel.nostr1.com",
"wss://aggr.nostr.land"
];
export const localRelays: string[] = [
"wss://localhost:8080",
"wss://localhost:4869"
]; ];
export enum FeedType { export enum FeedType {
StandardRelays = 'standard', CommunityRelays = "standard",
UserRelays = 'user', UserRelays = "user",
} }
export const loginStorageKey = 'alexandria/login/pubkey'; export const loginStorageKey = "alexandria/login/pubkey";
export const feedTypeStorageKey = 'alexandria/feed/type'; export const feedTypeStorageKey = "alexandria/feed/type";

319
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,6 @@
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts"; import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts';
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Branch, Branch,
@ -13,6 +12,16 @@ enum PublicationTreeNodeStatus {
Error, Error,
} }
export enum TreeTraversalMode {
Leaves,
All,
}
enum TreeTraversalDirection {
Forward,
Backward,
}
interface PublicationTreeNode { interface PublicationTreeNode {
type: PublicationTreeNodeType; type: PublicationTreeNodeType;
status: PublicationTreeNodeStatus; status: PublicationTreeNodeStatus;
@ -37,6 +46,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
#events: Map<string, NDKEvent>; #events: Map<string, NDKEvent>;
/**
* Simple cache for fetched events to avoid re-fetching.
*/
#eventCache: Map<string, NDKEvent> = new Map();
/** /**
* An ordered list of the addresses of the leaves of the tree. * An ordered list of the addresses of the leaves of the tree.
*/ */
@ -52,17 +66,26 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
#ndk: NDK; #ndk: NDK;
#nodeAddedObservers: Array<(address: string) => void> = [];
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) { constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress(); const rootAddress = rootEvent.tagAddress();
this.#root = { this.#root = {
type: this.#getNodeType(rootEvent), type: PublicationTreeNodeType.Branch,
status: PublicationTreeNodeStatus.Resolved, status: PublicationTreeNodeStatus.Resolved,
address: rootAddress, address: rootAddress,
children: [], children: [],
}; };
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>(); this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root))); this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
this.#events = new Map<string, NDKEvent>(); this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent); this.#events.set(rootAddress, rootEvent);
@ -85,7 +108,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) { if (!parentNode) {
throw new Error( throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.` `PublicationTree: Parent node with address ${parentAddress} not found.`,
); );
} }
@ -116,7 +139,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) { if (!parentNode) {
throw new Error( throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.` `PublicationTree: Parent node with address ${parentAddress} not found.`,
); );
} }
@ -139,19 +162,24 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/** /**
* Retrieves the addresses of the loaded children, if any, of the node with the given address. * Retrieves the addresses of the loaded children, if any, of the node with the given address.
*
* @param address The address of the parent node. * @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes. * @returns An array of addresses of any loaded child nodes.
*
* Note that this method resolves all children of the node.
*/ */
async getChildAddresses(address: string): Promise<Array<string | null>> { async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value(); const node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`); throw new Error(
`[PublicationTree] Node with address ${address} not found.`,
);
} }
return Promise.all( return Promise.all(
node.children?.map(async child => node.children?.map(
(await child.value())?.address ?? null async (child) => (await child.value())?.address ?? null,
) ?? [] ) ?? [],
); );
} }
/** /**
@ -163,7 +191,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getHierarchy(address: string): Promise<NDKEvent[]> { async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value(); let node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`); throw new Error(
`[PublicationTree] Node with address ${address} not found.`,
);
} }
const hierarchy: NDKEvent[] = [this.#events.get(address)!]; const hierarchy: NDKEvent[] = [this.#events.get(address)!];
@ -182,12 +212,34 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
setBookmark(address: string) { setBookmark(address: string) {
this.#bookmark = address; this.#bookmark = address;
this.#cursor.tryMoveTo(address); this.#cursor.tryMoveTo(address).then((success) => {
if (success) {
this.#bookmarkMovedObservers.forEach((observer) => observer(address));
}
});
}
onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer);
}
onNodeAdded(observer: (address: string) => void) {
this.#nodeAddedObservers.push(observer);
}
/**
* Registers an observer function that is invoked whenever a new node is resolved. Nodes are
* added lazily.
*
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
this.#nodeResolvedObservers.push(observer);
} }
// #region Iteration Cursor // #region Iteration Cursor
#cursor = new class { #cursor = new (class {
target: PublicationTreeNode | null | undefined; target: PublicationTreeNode | null | undefined;
#tree: PublicationTree; #tree: PublicationTree;
@ -199,7 +251,12 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) { async tryMoveTo(address?: string) {
if (!address) { if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve(); const startEvent = await this.#tree.#depthFirstRetrieve();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value(); if (!startEvent) {
return false;
}
this.target = await this.#tree.#nodes
.get(startEvent.tagAddress())
?.value();
} else { } else {
this.target = await this.#tree.#nodes.get(address)?.value(); this.target = await this.#tree.#nodes.get(address)?.value();
} }
@ -213,7 +270,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToFirstChild(): Promise<boolean> { async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("Cursor: Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -231,7 +290,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToLastChild(): Promise<boolean> { async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("Cursor: Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -249,7 +310,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToNextSibling(): Promise<boolean> { async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("Cursor: Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -260,7 +323,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentIndex = await siblings.findIndexAsync( const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
); );
if (currentIndex === -1) { if (currentIndex === -1) {
@ -277,7 +341,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToPreviousSibling(): Promise<boolean> { async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("Cursor: Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -288,7 +354,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentIndex = await siblings.findIndexAsync( const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
); );
if (currentIndex === -1) { if (currentIndex === -1) {
@ -305,7 +372,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
tryMoveToParent(): boolean { tryMoveToParent(): boolean {
if (!this.target) { if (!this.target) {
console.debug("Cursor: Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -317,7 +386,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent; this.target = parent;
return true; return true;
} }
}(this); })(this);
// #endregion // #endregion
@ -327,34 +396,100 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return this; return this;
} }
// TODO: Add `previous()` method. /**
* Return the next event in the tree for the given traversal mode.
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The next event in the tree, or null if the tree is empty.
*/
async next(
mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
return this.#yieldEventAtCursor(false);
}
}
async next(): Promise<IteratorResult<NDKEvent | null>> { switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Forward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Forward);
}
}
/**
* Return the previous event in the tree for the given traversal mode.
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The previous event in the tree, or null if the tree is empty.
*/
async previous(
mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) { if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) { if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address); return this.#yieldEventAtCursor(false);
return { done: false, value: event }; }
}
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Backward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Backward);
}
}
async #yieldEventAtCursor(
done: boolean,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
return { done, value: null };
} }
const value = (await this.getEvent(this.#cursor.target.address)) ?? null;
return { done, value };
} }
// Based on Raymond Chen's tree traversal algorithm example. /**
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300 * Walks the tree in the given direction, yielding the event at each leaf.
*
* @param direction The direction to walk the tree.
* @returns The event at the leaf, or null if the tree is empty.
*
* Based on Raymond Chen's tree traversal algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
*/
async #walkLeaves(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
do { do {
if (await this.#cursor.tryMoveToNextSibling()) { if (await tryMoveToSibling()) {
while (await this.#cursor.tryMoveToFirstChild()) { while (await tryMoveToChild()) {
continue; continue;
} }
if (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 };
} }
const event = await this.getEvent(this.#cursor.target!.address); return this.#yieldEventAtCursor(false);
return { done: false, value: event };
} }
} while (this.#cursor.tryMoveToParent()); } while (this.#cursor.tryMoveToParent());
if (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 };
} }
@ -362,36 +497,43 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: true, value: null }; return { done: true, value: null };
} }
async previous(): Promise<IteratorResult<NDKEvent | null>> { /**
if (!this.#cursor.target) { * Walks the tree in the given direction, yielding the event at each node.
if (await this.#cursor.tryMoveTo(this.#bookmark)) { *
const event = await this.getEvent(this.#cursor.target!.address); * @param direction The direction to walk the tree.
return { done: false, value: event }; * @returns The event at the node, or null if the tree is empty.
} *
* Based on Raymond Chen's preorder walk algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304
*/
async #preorderWalkAll(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
if (await tryMoveToChild()) {
return this.#yieldEventAtCursor(false);
} }
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do { do {
if (await this.#cursor.tryMoveToPreviousSibling()) { if (await tryMoveToSibling()) {
while (await this.#cursor.tryMoveToLastChild()) { return this.#yieldEventAtCursor(false);
continue;
}
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
} }
} while (this.#cursor.tryMoveToParent()); } while (this.#cursor.tryMoveToParent());
if (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 };
} }
return { done: true, value: null }; // If we get to this point, we're at the root node (can't move up any more).
return this.#yieldEventAtCursor(true);
} }
// #endregion // #endregion
@ -412,17 +554,24 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address]; const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root; let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!; let currentEvent: NDKEvent | null | undefined = this.#events.get(
this.#root.address,
)!;
while (stack.length > 0) { while (stack.length > 0) {
const currentAddress = stack.pop(); const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value(); currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) { if (!currentNode) {
throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`); throw new Error(
`[PublicationTree] Node with address ${currentAddress} not found.`,
);
} }
currentEvent = this.#events.get(currentAddress!); currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) { if (!currentEvent) {
throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`); console.warn(
`[PublicationTree] Event with address ${currentAddress} not found.`,
);
return null;
} }
// Stop immediately if the target of the search is found. // Stop immediately if the target of the search is found.
@ -431,8 +580,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentChildAddresses = currentEvent.tags const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a') .filter((tag) => tag[0] === "a")
.map(tag => tag[1]); .map((tag) => tag[1]);
// If the current event has no children, it is a leaf. // If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) { if (currentChildAddresses.length === 0) {
@ -445,13 +594,11 @@ 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.
for (const childAddress of currentChildAddresses) { const childPromises = currentChildAddresses
if (this.#nodes.has(childAddress)) { .filter(childAddress => !this.#nodes.has(childAddress))
continue; .map(childAddress => this.#addNode(childAddress, currentNode!));
}
await this.#addNode(childAddress, currentNode!); await Promise.all(childPromises);
}
// Push the popped address's children onto the stack for the next iteration. // Push the popped address's children onto the stack for the next iteration.
while (currentChildAddresses.length > 0) { while (currentChildAddresses.length > 0) {
@ -464,9 +611,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode)); const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode),
);
parentNode.children!.push(lazyNode); parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode); this.#nodes.set(address, lazyNode);
this.#nodeAddedObservers.forEach((observer) => observer(address));
} }
/** /**
@ -480,18 +631,29 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
async #resolveNode( async #resolveNode(
address: string, address: string,
parentNode: PublicationTreeNode parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> { ): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':'); // Check cache first
const event = await this.#ndk.fetchEvent({ let event = this.#eventCache.get(address);
if (!event) {
const [kind, pubkey, dTag] = address.split(":");
const fetchedEvent = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
'#d': [dTag], "#d": [dTag],
}); });
// Cache the event if found
if (fetchedEvent) {
this.#eventCache.set(address, fetchedEvent);
event = fetchedEvent;
}
}
if (!event) { if (!event) {
console.debug( console.debug(
`PublicationTree: Event with address ${address} not found.` `[PublicationTree] Event with address ${address} not found.`,
); );
return { return {
@ -505,7 +667,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#events.set(address, event); this.#events.set(address, event);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); const childAddresses = event.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
type: this.#getNodeType(event), type: this.#getNodeType(event),
@ -515,15 +679,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], children: [],
}; };
for (const address of childAddresses) { const childPromises = childAddresses.map(address =>
this.addEventByAddress(address, event); this.addEventByAddress(address, event)
} );
await Promise.all(childPromises);
this.#nodeResolvedObservers.forEach((observer) => observer(address));
return node; return node;
} }
#getNodeType(event: NDKEvent): PublicationTreeNodeType { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { if (event.kind === 30040 && event.tags.some((tag) => tag[0] === "a")) {
return PublicationTreeNodeType.Branch; return PublicationTreeNodeType.Branch;
} }

586
src/lib/ndk.ts

@ -1,16 +1,218 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; import NDK, {
import { get, writable, type Writable } from 'svelte/store'; NDKNip07Signer,
import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts'; NDKRelay,
import { feedType } from './stores'; NDKRelayAuthPolicies,
NDKRelaySet,
NDKUser,
NDKEvent,
} from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store";
import {
loginStorageKey,
} from "./consts.ts";
import {
buildCompleteRelaySet,
testRelayConnection,
deduplicateRelayUrls,
} from "./utils/relay_management.ts";
// Re-export testRelayConnection for components that need it
export { testRelayConnection };
import { userStore } from "./stores/userStore.ts";
import { userPubkey } from "./stores/authStore.Svelte.ts";
import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring } from "./stores/networkStore.ts";
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false);
export const activePubkey = writable<string | null>(null);
export const inboxRelays = writable<string[]>([]);
export const outboxRelays = writable<string[]>([]);
// New relay management stores
export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]);
/**
* Custom authentication policy that handles NIP-42 authentication manually
* when the default NDK authentication fails
*/
class CustomRelayAuthPolicy {
private ndk: NDK;
private challenges: Map<string, string> = new Map();
export const ndkSignedIn: Writable<boolean> = writable(false); constructor(ndk: NDK) {
this.ndk = ndk;
}
/**
* Handles authentication for a relay
* @param relay The relay to authenticate with
* @returns Promise that resolves when authentication is complete
*/
authenticate(relay: NDKRelay): void {
if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn(
"[NDK.ts] No signer or active user available for relay authentication",
);
return;
}
try {
console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`);
// Listen for AUTH challenges
relay.on("auth", (challenge: string) => {
console.debug(
`[NDK.ts] Received AUTH challenge from ${relay.url}:`,
challenge,
);
this.challenges.set(relay.url, challenge);
this.handleAuthChallenge(relay, challenge);
});
// Listen for auth-required errors (handle via notice events)
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message);
this.handleAuthRequired(relay);
}
});
// Listen for successful authentication
relay.on("authed", () => {
console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`);
});
// Listen for authentication failures
relay.on("auth:failed", (error) => {
console.error(
`[NDK.ts] Authentication failed for ${relay.url}:`,
error,
);
});
} catch (error) {
console.error(
`[NDK.ts] Error setting up authentication for ${relay.url}:`,
error,
);
}
}
/**
* Handles AUTH challenge from relay
*/
private async handleAuthChallenge(
relay: NDKRelay,
challenge: string,
): Promise<void> {
try {
if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn("[NDK.ts] No signer available for AUTH challenge");
return;
}
// Create NIP-42 authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", relay.url],
["challenge", challenge],
],
content: "",
pubkey: this.ndk.activeUser.pubkey,
};
// Create and sign the authentication event using NDKEvent
const authNDKEvent = new NDKEvent(this.ndk, authEvent);
await authNDKEvent.sign();
// Send AUTH message to relay using the relay's publish method
await relay.publish(authNDKEvent);
console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`);
} catch (error) {
console.error(
`[NDK.ts] Error handling AUTH challenge for ${relay.url}:`,
error,
);
}
}
/**
* Handles auth-required error from relay
*/
private async handleAuthRequired(relay: NDKRelay): Promise<void> {
const challenge = this.challenges.get(relay.url);
if (challenge) {
await this.handleAuthChallenge(relay, challenge);
} else {
console.warn(
`[NDK.ts] Auth required from ${relay.url} but no challenge available`,
);
}
}
}
/**
* Checks if the current environment might cause WebSocket protocol downgrade
*/
export function checkEnvironmentForWebSocketDowngrade(): void {
console.debug("[NDK.ts] Environment Check for WebSocket Protocol:");
const isLocalhost =
globalThis.location.hostname === "localhost" ||
globalThis.location.hostname === "127.0.0.1";
const isHttp = globalThis.location.protocol === "http:";
const isHttps = globalThis.location.protocol === "https:";
console.debug("[NDK.ts] - Is localhost:", isLocalhost);
console.debug("[NDK.ts] - Protocol:", globalThis.location.protocol);
console.debug("[NDK.ts] - Is HTTP:", isHttp);
console.debug("[NDK.ts] - Is HTTPS:", isHttps);
if (isLocalhost && isHttp) {
console.warn(
"[NDK.ts] ⚠ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected",
);
console.warn("[NDK.ts] This is normal for development environments");
} else if (isHttp) {
console.error(
"[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure",
);
console.error("[NDK.ts] Consider using HTTPS in production");
} else if (isHttps) {
console.debug(
"[NDK.ts] ✓ Running on HTTPS - Secure WebSocket connections should work",
);
}
}
/**
* Checks WebSocket protocol support and logs diagnostic information
*/
export function checkWebSocketSupport(): void {
console.debug("[NDK.ts] WebSocket Support Diagnostics:");
console.debug("[NDK.ts] - Protocol:", globalThis.location.protocol);
console.debug("[NDK.ts] - Hostname:", globalThis.location.hostname);
console.debug("[NDK.ts] - Port:", globalThis.location.port);
console.debug("[NDK.ts] - User Agent:", navigator.userAgent);
// Test if secure WebSocket is supported
try {
const testWs = new WebSocket("wss://echo.websocket.org");
testWs.onopen = () => {
console.debug("[NDK.ts] ✓ Secure WebSocket (wss://) is supported");
testWs.close();
};
testWs.onerror = () => {
console.warn("[NDK.ts] ✗ Secure WebSocket (wss://) may not be supported");
};
} catch (error) {
console.warn("[NDK.ts] ✗ WebSocket test failed:", error);
}
}
export const activePubkey: Writable<string | null> = writable(null);
export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]);
/** /**
* Gets the user's pubkey from local storage, if it exists. * Gets the user's pubkey from local storage, if it exists.
@ -47,102 +249,268 @@ export function clearLogin(): void {
* @param type The type of relay list to designate. * @param type The type of relay list to designate.
* @returns The constructed key. * @returns The constructed key.
*/ */
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
return `${loginStorageKey}/${user.pubkey}/${type}`; return `${loginStorageKey}/${user.pubkey}/${type}`;
} }
export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, "outbox"));
}
/** /**
* Stores the user's relay lists in local storage. * Ensures a relay URL uses secure WebSocket protocol
* @param user The user for whom to store the relay lists. * @param url The relay URL to secure
* @param inboxes The user's inbox relays. * @returns The URL with wss:// protocol
* @param outboxes The user's outbox relays.
*/ */
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void { function ensureSecureWebSocket(url: string): string {
localStorage.setItem( // Replace ws:// with wss:// if present
getRelayStorageKey(user, 'inbox'), const secureUrl = url.replace(/^ws:\/\//, "wss://");
JSON.stringify(Array.from(inboxes).map(relay => relay.url))
); if (secureUrl !== url) {
localStorage.setItem( console.warn(
getRelayStorageKey(user, 'outbox'), `[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`,
JSON.stringify(Array.from(outboxes).map(relay => relay.url))
); );
}
return secureUrl;
} }
/** /**
* Retrieves the user's relay lists from local storage. * Creates a relay with proper authentication handling
* @param user The user for whom to retrieve the relay lists.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`. Either set may be
* empty if no relay lists were stored for the user.
*/ */
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
const inboxes = new Set<string>( console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]')
); // Ensure the URL is using wss:// protocol
const outboxes = new Set<string>( const secureUrl = ensureSecureWebSocket(url);
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]')
// Add connection timeout and error handling
const relay = new NDKRelay(
secureUrl,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
); );
return [inboxes, outboxes]; // Set up connection timeout
const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`);
relay.disconnect();
}, 5000); // 5 second timeout
// Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
authPolicy.authenticate(relay);
});
} else {
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
});
}
// Add error handling
relay.on("disconnect", () => {
console.debug(`[NDK.ts] Relay disconnected: ${secureUrl}`);
clearTimeout(connectionTimeout);
});
return relay;
} }
export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'inbox'));
localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
/**
* Gets the active relay set for the current user
* @param ndk NDK instance
* @returns Promise that resolves to object with inbox and outbox relay arrays
*/
export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
const user = get(userStore);
console.debug('[NDK.ts] getActiveRelaySet: User state:', { signedIn: user.signedIn, hasNdkUser: !!user.ndkUser, pubkey: user.pubkey });
if (user.signedIn && user.ndkUser) {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:', user.ndkUser.pubkey);
return await buildCompleteRelaySet(ndk, user.ndkUser);
} else {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for anonymous user');
return await buildCompleteRelaySet(ndk, null);
}
} }
export function getActiveRelays(ndk: NDK): NDKRelaySet { /**
return get(feedType) === FeedType.UserRelays * Updates the active relay stores and NDK pool with new relay URLs
? new NDKRelaySet( * @param ndk NDK instance
new Set(get(inboxRelays).map(relay => new NDKRelay( */
relay, export async function updateActiveRelayStores(ndk: NDK): Promise<void> {
NDKRelayAuthPolicies.signIn({ ndk }), try {
ndk, console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update');
))),
ndk // Get the active relay set from the relay management system
) const relaySet = await getActiveRelaySet(ndk);
: new NDKRelaySet( console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet);
new Set(standardRelays.map(relay => new NDKRelay(
relay, // Update the stores with the new relay configuration
NDKRelayAuthPolicies.signIn({ ndk }), activeInboxRelays.set(relaySet.inboxRelays);
ndk, activeOutboxRelays.set(relaySet.outboxRelays);
))), console.debug('[NDK.ts] updateActiveRelayStores: Updated stores with inbox:', relaySet.inboxRelays.length, 'outbox:', relaySet.outboxRelays.length);
ndk
// Add relays to NDK pool (deduplicated)
const allRelayUrls = deduplicateRelayUrls([...relaySet.inboxRelays, ...relaySet.outboxRelays]);
console.debug('[NDK.ts] updateActiveRelayStores: Adding', allRelayUrls.length, 'relays to NDK pool');
for (const url of allRelayUrls) {
try {
const relay = createRelayWithAuth(url, ndk);
ndk.pool?.addRelay(relay);
} catch (error) {
console.debug('[NDK.ts] updateActiveRelayStores: Failed to add relay', url, ':', error);
}
}
console.debug('[NDK.ts] updateActiveRelayStores: Relay store update completed');
} catch (error) {
console.warn('[NDK.ts] updateActiveRelayStores: Error updating relay stores:', error);
}
}
/**
* Logs the current relay configuration to console
*/
export function logCurrentRelayConfiguration(): void {
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
console.log('🔌 Current Relay Configuration:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
}
/**
* Updates relay stores when user state changes
* @param ndk NDK instance
*/
export async function refreshRelayStores(ndk: NDK): Promise<void> {
console.debug('[NDK.ts] Refreshing relay stores due to user state change');
await updateActiveRelayStores(ndk);
}
/**
* Updates relay stores when network condition changes
* @param ndk NDK instance
*/
export async function refreshRelayStoresOnNetworkChange(ndk: NDK): Promise<void> {
console.debug('[NDK.ts] Refreshing relay stores due to network condition change');
await updateActiveRelayStores(ndk);
}
/**
* Starts network monitoring for relay optimization
* @param ndk NDK instance
*/
export function startNetworkMonitoringForRelays(): void {
// Use centralized network monitoring instead of separate monitoring
startNetworkStatusMonitoring();
}
/**
* Creates NDKRelaySet from relay URLs with proper authentication
* @param relayUrls Array of relay URLs
* @param ndk NDK instance
* @returns NDKRelaySet
*/
function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet {
const relays = relayUrls.map(url =>
new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk)
); );
return new NDKRelaySet(new Set(relays), ndk);
}
/**
* Gets the active relay set as NDKRelaySet for use in queries
* @param ndk NDK instance
* @param useInbox Whether to use inbox relays (true) or outbox relays (false)
* @returns Promise that resolves to NDKRelaySet
*/
export async function getActiveRelaySetAsNDKRelaySet(
ndk: NDK,
useInbox: boolean = true
): Promise<NDKRelaySet> {
const relaySet = await getActiveRelaySet(ndk);
const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays;
return createRelaySetFromUrls(urls, ndk);
} }
/** /**
* Initializes an instance of NDK, and connects it to the logged-in user's preferred relay set * Initializes an instance of NDK with the new relay management system
* (if available), or to Alexandria's standard relay set. * @returns The initialized NDK instance
* @returns The initialized NDK instance.
*/ */
export function initNdk(): NDK { export function initNdk(): NDK {
const startingPubkey = getPersistedLogin(); console.debug("[NDK.ts] Initializing NDK with new relay management system");
const [startingInboxes, _] = startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
const ndk = new NDK({ const ndk = new NDK({
autoConnectUserRelays: true, autoConnectUserRelays: false, // We'll manage relays manually
enableOutboxModel: true, enableOutboxModel: true,
explicitRelayUrls: startingInboxes != null
? Array.from(startingInboxes.values())
: standardRelays,
}); });
// TODO: Should we prompt the user to confirm authentication? // Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
ndk.connect().then(() => console.debug("ndk connected"));
// Connect with better error handling and reduced retry attempts
let retryCount = 0;
const maxRetries = 1; // Reduce to 1 retry
const attemptConnection = async () => {
try {
await ndk.connect();
console.debug("[NDK.ts] NDK connected successfully");
// Update relay stores after connection
await updateActiveRelayStores(ndk);
// Start network monitoring for relay optimization
startNetworkMonitoringForRelays();
} catch (error) {
console.warn("[NDK.ts] Failed to connect NDK:", error);
// Only retry a limited number of times
if (retryCount < maxRetries) {
retryCount++;
console.debug(`[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`);
setTimeout(attemptConnection, 2000); // Reduce timeout to 2 seconds
} else {
console.warn("[NDK.ts] Max retries reached, continuing with limited functionality");
// Still try to update relay stores even if connection failed
try {
await updateActiveRelayStores(ndk);
startNetworkMonitoringForRelays();
} catch (storeError) {
console.warn("[NDK.ts] Failed to update relay stores:", storeError);
}
}
}
};
attemptConnection();
return ndk; return ndk;
} }
/** /**
* Signs in with a NIP-07 browser extension, and determines the user's preferred inbox and outbox * Signs in with a NIP-07 browser extension using the new relay management system
* relays. * @returns The user's profile, if it is available
* @returns The user's profile, if it is available. * @throws If sign-in fails
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because
* NDK is unable to fetch the user's profile or relay lists.
*/ */
export async function loginWithExtension(pubkey?: string): Promise<NDKUser | null> { export async function loginWithExtension(
pubkey?: string,
): Promise<NDKUser | null> {
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const signer = new NDKNip07Signer(); const signer = new NDKNip07Signer();
@ -150,23 +518,16 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
// TODO: Handle changing pubkeys. // TODO: Handle changing pubkeys.
if (pubkey && signerUser.pubkey !== pubkey) { if (pubkey && signerUser.pubkey !== pubkey) {
console.debug('Switching pubkeys from last login.'); console.debug("[NDK.ts] Switching pubkeys from last login.");
} }
activePubkey.set(signerUser.pubkey); activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const user = ndk.getUser({ pubkey: signerUser.pubkey }); const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url));
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url));
persistRelays(signerUser, inboxes, outboxes); // Update relay stores with the new system
await updateActiveRelayStores(ndk);
ndk.signer = signer; ndk.signer = signer;
ndk.activeUser = user; ndk.activeUser = user;
@ -188,58 +549,19 @@ 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);
}
/**
* Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox
* relay sets.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`.
*/
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
fallbacks: readonly string[] = fallbackRelays
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(fallbacks, ndk),
);
const inboxRelays = new Set<NDKRelay>(); // Clear relay stores
const outboxRelays = new Set<NDKRelay>(); activeInboxRelays.set([]);
activeOutboxRelays.set([]);
if (relayList == null) { // Stop network monitoring
const relayMap = await window.nostr?.getRelays?.(); stopNetworkStatusMonitoring();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
});
} else {
relayList.tags.forEach(tag => {
switch (tag[0]) {
case 'r':
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
case 'w':
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
}
});
}
return [inboxRelays, outboxRelays]; // Re-initialize with anonymous instance
const newNdk = initNdk();
ndkInstance.set(newNdk);
} }

474
src/lib/parser.ts

@ -1,19 +1,19 @@
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'; // deno-lint-ignore-file no-this-alias
import asciidoctor from 'asciidoctor'; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import Processor from "asciidoctor";
import type { import type {
AbstractBlock, AbstractBlock,
AbstractNode, AbstractNode,
Asciidoctor,
Block, Block,
Document, Document,
Extensions, Extensions,
Section, Section,
ProcessorOptions, ProcessorOptions,
} from 'asciidoctor'; } from "asciidoctor";
import he from 'he'; import he from "he";
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from "svelte/store";
import { zettelKinds } from './consts.ts'; import { zettelKinds } from "./consts.ts";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "./utils/nostrUtils.ts";
interface IndexMetadata { interface IndexMetadata {
authors?: string[]; authors?: string[];
@ -28,12 +28,12 @@ interface IndexMetadata {
export enum SiblingSearchDirection { export enum SiblingSearchDirection {
Previous, Previous,
Next Next,
} }
export enum InsertLocation { export enum InsertLocation {
Before, Before,
After After,
} }
/** /**
@ -65,7 +65,7 @@ export default class Pharos {
* hierarchically to form the Abstract Syntax Tree (AST) representation of the document. * hierarchically to form the Abstract Syntax Tree (AST) representation of the document.
*/ */
private asciidoctor: Asciidoctor; private asciidoctor;
private pharosExtensions: Extensions.Registry; private pharosExtensions: Extensions.Registry;
@ -112,7 +112,10 @@ export default class Pharos {
/** /**
* A map of index IDs to the IDs of the nodes they reference. * A map of index IDs to the IDs of the nodes they reference.
*/ */
private indexToChildEventsMap: Map<string, Set<string>> = new Map<string, Set<string>>(); private indexToChildEventsMap: Map<string, Set<string>> = new Map<
string,
Set<string>
>();
/** /**
* A map of node IDs to the Nostr event IDs of the events they generate. * A map of node IDs to the Nostr event IDs of the events they generate.
@ -137,7 +140,7 @@ export default class Pharos {
// #region Public API // #region Public API
constructor(ndk: NDK) { constructor(ndk: NDK) {
this.asciidoctor = asciidoctor(); this.asciidoctor = Processor();
this.pharosExtensions = this.asciidoctor.Extensions.create(); this.pharosExtensions = this.asciidoctor.Extensions.create();
this.ndk = ndk; this.ndk = ndk;
@ -150,21 +153,47 @@ export default class Pharos {
pharos.treeProcessor(treeProcessor, document); pharos.treeProcessor(treeProcessor, document);
}); });
}); });
// Add advanced extensions for math, PlantUML, BPMN, and TikZ
this.loadAdvancedExtensions();
} }
parse(content: string, options?: ProcessorOptions | undefined): void { /**
* Loads advanced extensions for math, PlantUML, BPMN, and TikZ rendering
*/
private async loadAdvancedExtensions(): Promise<void> {
try {
const { createAdvancedExtensions } = await import(
"./utils/markup/asciidoctorExtensions.ts"
);
createAdvancedExtensions();
// Note: Extensions merging might not be available in this version
// We'll handle this in the parse method instead
} catch (error) {
console.warn("Advanced extensions not available:", error);
}
}
parse(content: string, options?: ProcessorOptions | undefined): void {
// Ensure the content is valid AsciiDoc and has a header and the doctype book // Ensure the content is valid AsciiDoc and has a header and the doctype book
content = ensureAsciiDocHeader(content); content = ensureAsciiDocHeader(content);
try { try {
const mergedAttributes = Object.assign(
{},
options && typeof options.attributes === "object"
? options.attributes
: {},
{ "source-highlighter": "highlightjs" },
);
this.html = this.asciidoctor.convert(content, { this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions,
...options, ...options,
extension_registry: this.pharosExtensions,
attributes: mergedAttributes,
}) as string | Document | undefined; }) as string | Document | undefined;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new Error('Failed to parse AsciiDoc document.'); throw new Error("Failed to parse AsciiDoc document.");
} }
} }
@ -176,10 +205,10 @@ export default class Pharos {
async fetch(event: NDKEvent | string): Promise<void> { async fetch(event: NDKEvent | string): Promise<void> {
let content: string; let content: string;
if (typeof event === 'string') { if (typeof event === "string") {
const index = await this.ndk.fetchEvent({ ids: [event] }); const index = await this.ndk.fetchEvent({ ids: [event] });
if (!index) { if (!index) {
throw new Error('Failed to fetch publication.'); throw new Error("Failed to fetch publication.");
} }
content = await this.getPublicationContent(index); content = await this.getPublicationContent(index);
@ -229,7 +258,7 @@ export default class Pharos {
* @returns The HTML content of the converted document. * @returns The HTML content of the converted document.
*/ */
getHtml(): string { getHtml(): string {
return this.html?.toString() || ''; return this.html?.toString() || "";
} }
/** /**
@ -237,7 +266,7 @@ export default class Pharos {
* @remarks The root index ID may be used to retrieve metadata or children from the root index. * @remarks The root index ID may be used to retrieve metadata or children from the root index.
*/ */
getRootIndexId(): string { getRootIndexId(): string {
return this.normalizeId(this.rootNodeId) ?? ''; return this.normalizeId(this.rootNodeId) ?? "";
} }
/** /**
@ -245,7 +274,7 @@ export default class Pharos {
*/ */
getIndexTitle(id: string): string | undefined { getIndexTitle(id: string): string | undefined {
const section = this.nodes.get(id) as Section; const section = this.nodes.get(id) as Section;
const title = section.getTitle() ?? ''; const title = section.getTitle() ?? "";
return he.decode(title); return he.decode(title);
} }
@ -253,16 +282,18 @@ export default class Pharos {
* @returns The IDs of any child indices of the index with the given ID. * @returns The IDs of any child indices of the index with the given ID.
*/ */
getChildIndexIds(id: string): string[] { getChildIndexIds(id: string): string[] {
return Array.from(this.indexToChildEventsMap.get(id) ?? []) return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter(
.filter(id => this.eventToKindMap.get(id) === 30040); (id) => this.eventToKindMap.get(id) === 30040,
);
} }
/** /**
* @returns The IDs of any child zettels of the index with the given ID. * @returns The IDs of any child zettels of the index with the given ID.
*/ */
getChildZettelIds(id: string): string[] { getChildZettelIds(id: string): string[] {
return Array.from(this.indexToChildEventsMap.get(id) ?? []) return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter(
.filter(id => this.eventToKindMap.get(id) !== 30040); (id) => this.eventToKindMap.get(id) !== 30040,
);
} }
/** /**
@ -284,8 +315,8 @@ export default class Pharos {
const block = this.nodes.get(normalizedId!) as AbstractBlock; const block = this.nodes.get(normalizedId!) as AbstractBlock;
switch (block.getContext()) { switch (block.getContext()) {
case 'paragraph': case "paragraph":
return block.getContent() ?? ''; return block.getContent() ?? "";
} }
return block.convert(); return block.convert();
@ -303,7 +334,7 @@ export default class Pharos {
} }
const context = this.eventToContextMap.get(normalizedId); const context = this.eventToContextMap.get(normalizedId);
return context === 'floating_title'; return context === "floating_title";
} }
/** /**
@ -338,7 +369,7 @@ export default class Pharos {
getNearestSibling( getNearestSibling(
targetDTag: string, targetDTag: string,
depth: number, depth: number,
direction: SiblingSearchDirection direction: SiblingSearchDirection,
): [string | null, string | null] { ): [string | null, string | null] {
const eventsAtLevel = this.eventsByLevelMap.get(depth); const eventsAtLevel = this.eventsByLevelMap.get(depth);
if (!eventsAtLevel) { if (!eventsAtLevel) {
@ -348,13 +379,17 @@ export default class Pharos {
const targetIndex = eventsAtLevel.indexOf(targetDTag); const targetIndex = eventsAtLevel.indexOf(targetDTag);
if (targetIndex === -1) { if (targetIndex === -1) {
throw new Error(`The event indicated by #d:${targetDTag} does not exist at level ${depth} of the event tree.`); throw new Error(
`The event indicated by #d:${targetDTag} does not exist at level ${depth} of the event tree.`,
);
} }
const parentDTag = this.getParent(targetDTag); const parentDTag = this.getParent(targetDTag);
if (!parentDTag) { if (!parentDTag) {
throw new Error(`The event indicated by #d:${targetDTag} does not have a parent.`); throw new Error(
`The event indicated by #d:${targetDTag} does not have a parent.`,
);
} }
const grandparentDTag = this.getParent(parentDTag); const grandparentDTag = this.getParent(parentDTag);
@ -372,7 +407,10 @@ export default class Pharos {
// If the target is the last node at its level and we're searching for a next sibling, // If the target is the last node at its level and we're searching for a next sibling,
// look among the siblings of the target's parent at the previous level. // look among the siblings of the target's parent at the previous level.
if (targetIndex === eventsAtLevel.length - 1 && direction === SiblingSearchDirection.Next) { if (
targetIndex === eventsAtLevel.length - 1 &&
direction === SiblingSearchDirection.Next
) {
// * Base case: The target is at the last level of the tree and has no subsequent sibling. // * Base case: The target is at the last level of the tree and has no subsequent sibling.
if (!grandparentDTag) { if (!grandparentDTag) {
return [null, null]; return [null, null];
@ -401,7 +439,9 @@ export default class Pharos {
getParent(dTag: string): string | null { getParent(dTag: string): string | null {
// Check if the event exists in the parser tree. // Check if the event exists in the parser tree.
if (!this.eventIds.has(dTag)) { if (!this.eventIds.has(dTag)) {
throw new Error(`The event indicated by #d:${dTag} does not exist in the parser tree.`); throw new Error(
`The event indicated by #d:${dTag} does not exist in the parser tree.`,
);
} }
// Iterate through all the index to child mappings. // Iterate through all the index to child mappings.
@ -426,7 +466,11 @@ export default class Pharos {
* @remarks Moving the target event within the tree changes the hash of several events, so the * @remarks Moving the target event within the tree changes the hash of several events, so the
* event tree will be regenerated when the consumer next invokes `getEvents()`. * event tree will be regenerated when the consumer next invokes `getEvents()`.
*/ */
moveEvent(targetDTag: string, destinationDTag: string, insertAfter: boolean = false): void { moveEvent(
targetDTag: string,
destinationDTag: string,
insertAfter: boolean = false,
): void {
const targetEvent = this.events.get(targetDTag); const targetEvent = this.events.get(targetDTag);
const destinationEvent = this.events.get(destinationDTag); const destinationEvent = this.events.get(destinationDTag);
const targetParent = this.getParent(targetDTag); const targetParent = this.getParent(targetDTag);
@ -441,11 +485,15 @@ export default class Pharos {
} }
if (!targetParent) { if (!targetParent) {
throw new Error(`The event indicated by #d:${targetDTag} does not have a parent.`); throw new Error(
`The event indicated by #d:${targetDTag} does not have a parent.`,
);
} }
if (!destinationParent) { if (!destinationParent) {
throw new Error(`The event indicated by #d:${destinationDTag} does not have a parent.`); throw new Error(
`The event indicated by #d:${destinationDTag} does not have a parent.`,
);
} }
// Remove the target from among the children of its current parent. // Remove the target from among the children of its current parent.
@ -455,16 +503,22 @@ export default class Pharos {
this.indexToChildEventsMap.get(destinationParent)?.delete(targetDTag); this.indexToChildEventsMap.get(destinationParent)?.delete(targetDTag);
// Get the index of the destination event among the children of its parent. // Get the index of the destination event among the children of its parent.
const destinationIndex = Array.from(this.indexToChildEventsMap.get(destinationParent) ?? []) const destinationIndex = Array.from(
.indexOf(destinationDTag); this.indexToChildEventsMap.get(destinationParent) ?? [],
).indexOf(destinationDTag);
// Insert next to the index of the destination event, either before or after as specified by // Insert next to the index of the destination event, either before or after as specified by
// the insertAfter flag. // the insertAfter flag.
const destinationChildren = Array.from(this.indexToChildEventsMap.get(destinationParent) ?? []); const destinationChildren = Array.from(
this.indexToChildEventsMap.get(destinationParent) ?? [],
);
insertAfter insertAfter
? destinationChildren.splice(destinationIndex + 1, 0, targetDTag) ? destinationChildren.splice(destinationIndex + 1, 0, targetDTag)
: destinationChildren.splice(destinationIndex, 0, targetDTag); : destinationChildren.splice(destinationIndex, 0, targetDTag);
this.indexToChildEventsMap.set(destinationParent, new Set(destinationChildren)); this.indexToChildEventsMap.set(
destinationParent,
new Set(destinationChildren),
);
this.shouldUpdateEventTree = true; this.shouldUpdateEventTree = true;
} }
@ -494,7 +548,10 @@ export default class Pharos {
* - Each node ID is mapped to an integer event kind that will be used to represent the node. * - Each node ID is mapped to an integer event kind that will be used to represent the node.
* - Each ID of a node containing children is mapped to the set of IDs of its children. * - Each ID of a node containing children is mapped to the set of IDs of its children.
*/ */
private treeProcessor(treeProcessor: Extensions.TreeProcessor, document: Document) { private treeProcessor(
_: Extensions.TreeProcessor,
document: Document,
) {
this.rootNodeId = this.generateNodeId(document); this.rootNodeId = this.generateNodeId(document);
document.setId(this.rootNodeId); document.setId(this.rootNodeId);
this.nodes.set(this.rootNodeId, document); this.nodes.set(this.rootNodeId, document);
@ -510,7 +567,7 @@ export default class Pharos {
continue; continue;
} }
if (block.getContext() === 'section') { if (block.getContext() === "section") {
const children = this.processSection(block as Section); const children = this.processSection(block as Section);
nodeQueue.push(...children); nodeQueue.push(...children);
} else { } else {
@ -568,7 +625,7 @@ export default class Pharos {
// Obtain or generate a unique ID for the block. // Obtain or generate a unique ID for the block.
let blockId = this.normalizeId(block.getId()); let blockId = this.normalizeId(block.getId());
if (!blockId) { if (!blockId) {
blockId = this.generateNodeId(block) ; blockId = this.generateNodeId(block);
block.setId(blockId); block.setId(blockId);
} }
@ -625,21 +682,24 @@ export default class Pharos {
* @remarks This function does a depth-first crawl of the event tree using the relays specified * @remarks This function does a depth-first crawl of the event tree using the relays specified
* on the NDK instance. * on the NDK instance.
*/ */
private async getPublicationContent(event: NDKEvent, depth: number = 0): Promise<string> { private async getPublicationContent(
let content: string = ''; event: NDKEvent,
depth: number = 0,
): Promise<string> {
let content: string = "";
// Format title into AsciiDoc header. // Format title into AsciiDoc header.
const title = getMatchingTags(event, 'title')[0][1]; const title = getMatchingTags(event, "title")[0][1];
let titleLevel = ''; let titleLevel = "";
for (let i = 0; i <= depth; i++) { for (let i = 0; i <= depth; i++) {
titleLevel += '='; titleLevel += "=";
} }
content += `${titleLevel} ${title}\n\n`; content += `${titleLevel} ${title}\n\n`;
// TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62. // TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62.
let tags = getMatchingTags(event, 'a'); let tags = getMatchingTags(event, "a");
if (tags.length === 0) { if (tags.length === 0) {
tags = getMatchingTags(event, 'e'); tags = getMatchingTags(event, "e");
} }
// Base case: The event is a zettel. // Base case: The event is a zettel.
@ -650,24 +710,29 @@ export default class Pharos {
// Recursive case: The event is an index. // Recursive case: The event is an index.
const childEvents = await Promise.all( const childEvents = await Promise.all(
tags.map(tag => this.ndk.fetchEventFromTag(tag, event)) tags.map((tag) => this.ndk.fetchEventFromTag(tag, event)),
); );
// if a blog, save complete events for later // if a blog, save complete events for later
if (getMatchingTags(event, 'type').length > 0 && getMatchingTags(event, 'type')[0][1] === 'blog') { if (
childEvents.forEach(child => { getMatchingTags(event, "type").length > 0 &&
getMatchingTags(event, "type")[0][1] === "blog"
) {
childEvents.forEach((child) => {
if (child) { if (child) {
this.blogEntries.set(getMatchingTags(child, 'd')?.[0]?.[1], child); this.blogEntries.set(getMatchingTags(child, "d")?.[0]?.[1], child);
} }
}) });
} }
// populate metadata // populate metadata
if (event.created_at) { if (event.created_at) {
this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString(); this.rootIndexMetadata.publicationDate = new Date(
event.created_at * 1000,
).toDateString();
} }
if (getMatchingTags(event, 'image').length > 0) { if (getMatchingTags(event, "image").length > 0) {
this.rootIndexMetadata.coverImage = getMatchingTags(event, 'image')[0][1]; this.rootIndexMetadata.coverImage = getMatchingTags(event, "image")[0][1];
} }
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching // Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
@ -682,11 +747,13 @@ export default class Pharos {
continue; continue;
} }
childContentPromises.push(this.getPublicationContent(childEvent, depth + 1)); childContentPromises.push(
this.getPublicationContent(childEvent, depth + 1),
);
} }
const childContents = await Promise.all(childContentPromises); const childContents = await Promise.all(childContentPromises);
content += childContents.join('\n\n'); content += childContents.join("\n\n");
return content; return content;
} }
@ -760,17 +827,14 @@ export default class Pharos {
private generateIndexEvent(nodeId: string, pubkey: string): NDKEvent { private generateIndexEvent(nodeId: string, pubkey: string): NDKEvent {
const title = (this.nodes.get(nodeId)! as AbstractBlock).getTitle(); const title = (this.nodes.get(nodeId)! as AbstractBlock).getTitle();
// TODO: Use a tags as per NIP-62. // TODO: Use a tags as per NIP-62.
const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!) const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!).map(
.map(id => ['#e', this.eventIds.get(id)!]); (id) => ["#e", this.eventIds.get(id)!],
);
const event = new NDKEvent(this.ndk); const event = new NDKEvent(this.ndk);
event.kind = 30040; event.kind = 30040;
event.content = ''; event.content = "";
event.tags = [ event.tags = [["title", title!], ["#d", nodeId], ...childTags];
['title', title!],
['#d', nodeId],
...childTags
];
event.created_at = Date.now(); event.created_at = Date.now();
event.pubkey = pubkey; event.pubkey = pubkey;
@ -782,29 +846,33 @@ export default class Pharos {
this.rootIndexMetadata = { this.rootIndexMetadata = {
authors: document authors: document
.getAuthors() .getAuthors()
.map(author => author.getName()) .map((author) => author.getName())
.filter(name => name != null), .filter((name): name is string => name != null),
version: document.getRevisionNumber(), version: document.getRevisionNumber(),
edition: document.getRevisionRemark(), edition: document.getRevisionRemark(),
publicationDate: document.getRevisionDate(), publicationDate: document.getRevisionDate(),
}; };
if (this.rootIndexMetadata.authors) { if (this.rootIndexMetadata.authors) {
event.tags.push(['author', ...this.rootIndexMetadata.authors!]); event.tags.push(["author", ...this.rootIndexMetadata.authors!]);
} }
if (this.rootIndexMetadata.version || this.rootIndexMetadata.edition) { if (this.rootIndexMetadata.version || this.rootIndexMetadata.edition) {
event.tags.push( const versionTags: string[] = ["version"];
[ if (this.rootIndexMetadata.version) {
'version', versionTags.push(this.rootIndexMetadata.version);
this.rootIndexMetadata.version!, }
this.rootIndexMetadata.edition! if (this.rootIndexMetadata.edition) {
].filter(value => value != null) versionTags.push(this.rootIndexMetadata.edition);
); }
event.tags.push(versionTags);
} }
if (this.rootIndexMetadata.publicationDate) { if (this.rootIndexMetadata.publicationDate) {
event.tags.push(['published_on', this.rootIndexMetadata.publicationDate!]); event.tags.push([
"published_on",
this.rootIndexMetadata.publicationDate!,
]);
} }
} }
@ -834,10 +902,17 @@ export default class Pharos {
event.kind = 30041; event.kind = 30041;
event.content = content!; event.content = content!;
event.tags = [ event.tags = [
['title', title!], ["title", title!],
['#d', nodeId], ["#d", nodeId],
...this.extractAndNormalizeWikilinks(content!), ...this.extractAndNormalizeWikilinks(content!),
]; ];
// Extract image from content if present
const imageUrl = this.extractImageFromContent(content!);
if (imageUrl) {
event.tags.push(["image", imageUrl]);
}
event.created_at = Date.now(); event.created_at = Date.now();
event.pubkey = pubkey; event.pubkey = pubkey;
@ -878,172 +953,172 @@ export default class Pharos {
const context = block.getContext(); const context = block.getContext();
switch (context) { switch (context) {
case 'admonition': case "admonition":
blockNumber = this.contextCounters.get('admonition') ?? 0; blockNumber = this.contextCounters.get("admonition") ?? 0;
blockId = `${documentId}-admonition-${blockNumber++}`; blockId = `${documentId}-admonition-${blockNumber++}`;
this.contextCounters.set('admonition', blockNumber); this.contextCounters.set("admonition", blockNumber);
break; break;
case 'audio': case "audio":
blockNumber = this.contextCounters.get('audio') ?? 0; blockNumber = this.contextCounters.get("audio") ?? 0;
blockId = `${documentId}-audio-${blockNumber++}`; blockId = `${documentId}-audio-${blockNumber++}`;
this.contextCounters.set('audio', blockNumber); this.contextCounters.set("audio", blockNumber);
break; break;
case 'colist': case "colist":
blockNumber = this.contextCounters.get('colist') ?? 0; blockNumber = this.contextCounters.get("colist") ?? 0;
blockId = `${documentId}-colist-${blockNumber++}`; blockId = `${documentId}-colist-${blockNumber++}`;
this.contextCounters.set('colist', blockNumber); this.contextCounters.set("colist", blockNumber);
break; break;
case 'dlist': case "dlist":
blockNumber = this.contextCounters.get('dlist') ?? 0; blockNumber = this.contextCounters.get("dlist") ?? 0;
blockId = `${documentId}-dlist-${blockNumber++}`; blockId = `${documentId}-dlist-${blockNumber++}`;
this.contextCounters.set('dlist', blockNumber); this.contextCounters.set("dlist", blockNumber);
break; break;
case 'document': case "document":
blockNumber = this.contextCounters.get('document') ?? 0; blockNumber = this.contextCounters.get("document") ?? 0;
blockId = `${documentId}-document-${blockNumber++}`; blockId = `${documentId}-document-${blockNumber++}`;
this.contextCounters.set('document', blockNumber); this.contextCounters.set("document", blockNumber);
break; break;
case 'example': case "example":
blockNumber = this.contextCounters.get('example') ?? 0; blockNumber = this.contextCounters.get("example") ?? 0;
blockId = `${documentId}-example-${blockNumber++}`; blockId = `${documentId}-example-${blockNumber++}`;
this.contextCounters.set('example', blockNumber); this.contextCounters.set("example", blockNumber);
break; break;
case 'floating_title': case "floating_title":
blockNumber = this.contextCounters.get('floating_title') ?? 0; blockNumber = this.contextCounters.get("floating_title") ?? 0;
blockId = `${documentId}-floating-title-${blockNumber++}`; blockId = `${documentId}-floating-title-${blockNumber++}`;
this.contextCounters.set('floating_title', blockNumber); this.contextCounters.set("floating_title", blockNumber);
break; break;
case 'image': case "image":
blockNumber = this.contextCounters.get('image') ?? 0; blockNumber = this.contextCounters.get("image") ?? 0;
blockId = `${documentId}-image-${blockNumber++}`; blockId = `${documentId}-image-${blockNumber++}`;
this.contextCounters.set('image', blockNumber); this.contextCounters.set("image", blockNumber);
break; break;
case 'list_item': case "list_item":
blockNumber = this.contextCounters.get('list_item') ?? 0; blockNumber = this.contextCounters.get("list_item") ?? 0;
blockId = `${documentId}-list-item-${blockNumber++}`; blockId = `${documentId}-list-item-${blockNumber++}`;
this.contextCounters.set('list_item', blockNumber); this.contextCounters.set("list_item", blockNumber);
break; break;
case 'listing': case "listing":
blockNumber = this.contextCounters.get('listing') ?? 0; blockNumber = this.contextCounters.get("listing") ?? 0;
blockId = `${documentId}-listing-${blockNumber++}`; blockId = `${documentId}-listing-${blockNumber++}`;
this.contextCounters.set('listing', blockNumber); this.contextCounters.set("listing", blockNumber);
break; break;
case 'literal': case "literal":
blockNumber = this.contextCounters.get('literal') ?? 0; blockNumber = this.contextCounters.get("literal") ?? 0;
blockId = `${documentId}-literal-${blockNumber++}`; blockId = `${documentId}-literal-${blockNumber++}`;
this.contextCounters.set('literal', blockNumber); this.contextCounters.set("literal", blockNumber);
break; break;
case 'olist': case "olist":
blockNumber = this.contextCounters.get('olist') ?? 0; blockNumber = this.contextCounters.get("olist") ?? 0;
blockId = `${documentId}-olist-${blockNumber++}`; blockId = `${documentId}-olist-${blockNumber++}`;
this.contextCounters.set('olist', blockNumber); this.contextCounters.set("olist", blockNumber);
break; break;
case 'open': case "open":
blockNumber = this.contextCounters.get('open') ?? 0; blockNumber = this.contextCounters.get("open") ?? 0;
blockId = `${documentId}-open-${blockNumber++}`; blockId = `${documentId}-open-${blockNumber++}`;
this.contextCounters.set('open', blockNumber); this.contextCounters.set("open", blockNumber);
break; break;
case 'page_break': case "page_break":
blockNumber = this.contextCounters.get('page_break') ?? 0; blockNumber = this.contextCounters.get("page_break") ?? 0;
blockId = `${documentId}-page-break-${blockNumber++}`; blockId = `${documentId}-page-break-${blockNumber++}`;
this.contextCounters.set('page_break', blockNumber); this.contextCounters.set("page_break", blockNumber);
break; break;
case 'paragraph': case "paragraph":
blockNumber = this.contextCounters.get('paragraph') ?? 0; blockNumber = this.contextCounters.get("paragraph") ?? 0;
blockId = `${documentId}-paragraph-${blockNumber++}`; blockId = `${documentId}-paragraph-${blockNumber++}`;
this.contextCounters.set('paragraph', blockNumber); this.contextCounters.set("paragraph", blockNumber);
break; break;
case 'pass': case "pass":
blockNumber = this.contextCounters.get('pass') ?? 0; blockNumber = this.contextCounters.get("pass") ?? 0;
blockId = `${documentId}-pass-${blockNumber++}`; blockId = `${documentId}-pass-${blockNumber++}`;
this.contextCounters.set('pass', blockNumber); this.contextCounters.set("pass", blockNumber);
break; break;
case 'preamble': case "preamble":
blockNumber = this.contextCounters.get('preamble') ?? 0; blockNumber = this.contextCounters.get("preamble") ?? 0;
blockId = `${documentId}-preamble-${blockNumber++}`; blockId = `${documentId}-preamble-${blockNumber++}`;
this.contextCounters.set('preamble', blockNumber); this.contextCounters.set("preamble", blockNumber);
break; break;
case 'quote': case "quote":
blockNumber = this.contextCounters.get('quote') ?? 0; blockNumber = this.contextCounters.get("quote") ?? 0;
blockId = `${documentId}-quote-${blockNumber++}`; blockId = `${documentId}-quote-${blockNumber++}`;
this.contextCounters.set('quote', blockNumber); this.contextCounters.set("quote", blockNumber);
break; break;
case 'section': case "section":
blockNumber = this.contextCounters.get('section') ?? 0; blockNumber = this.contextCounters.get("section") ?? 0;
blockId = `${documentId}-section-${blockNumber++}`; blockId = `${documentId}-section-${blockNumber++}`;
this.contextCounters.set('section', blockNumber); this.contextCounters.set("section", blockNumber);
break; break;
case 'sidebar': case "sidebar":
blockNumber = this.contextCounters.get('sidebar') ?? 0; blockNumber = this.contextCounters.get("sidebar") ?? 0;
blockId = `${documentId}-sidebar-${blockNumber++}`; blockId = `${documentId}-sidebar-${blockNumber++}`;
this.contextCounters.set('sidebar', blockNumber); this.contextCounters.set("sidebar", blockNumber);
break; break;
case 'table': case "table":
blockNumber = this.contextCounters.get('table') ?? 0; blockNumber = this.contextCounters.get("table") ?? 0;
blockId = `${documentId}-table-${blockNumber++}`; blockId = `${documentId}-table-${blockNumber++}`;
this.contextCounters.set('table', blockNumber); this.contextCounters.set("table", blockNumber);
break; break;
case 'table_cell': case "table_cell":
blockNumber = this.contextCounters.get('table_cell') ?? 0; blockNumber = this.contextCounters.get("table_cell") ?? 0;
blockId = `${documentId}-table-cell-${blockNumber++}`; blockId = `${documentId}-table-cell-${blockNumber++}`;
this.contextCounters.set('table_cell', blockNumber); this.contextCounters.set("table_cell", blockNumber);
break; break;
case 'thematic_break': case "thematic_break":
blockNumber = this.contextCounters.get('thematic_break') ?? 0; blockNumber = this.contextCounters.get("thematic_break") ?? 0;
blockId = `${documentId}-thematic-break-${blockNumber++}`; blockId = `${documentId}-thematic-break-${blockNumber++}`;
this.contextCounters.set('thematic_break', blockNumber); this.contextCounters.set("thematic_break", blockNumber);
break; break;
case 'toc': case "toc":
blockNumber = this.contextCounters.get('toc') ?? 0; blockNumber = this.contextCounters.get("toc") ?? 0;
blockId = `${documentId}-toc-${blockNumber++}`; blockId = `${documentId}-toc-${blockNumber++}`;
this.contextCounters.set('toc', blockNumber); this.contextCounters.set("toc", blockNumber);
break; break;
case 'ulist': case "ulist":
blockNumber = this.contextCounters.get('ulist') ?? 0; blockNumber = this.contextCounters.get("ulist") ?? 0;
blockId = `${documentId}-ulist-${blockNumber++}`; blockId = `${documentId}-ulist-${blockNumber++}`;
this.contextCounters.set('ulist', blockNumber); this.contextCounters.set("ulist", blockNumber);
break; break;
case 'verse': case "verse":
blockNumber = this.contextCounters.get('verse') ?? 0; blockNumber = this.contextCounters.get("verse") ?? 0;
blockId = `${documentId}-verse-${blockNumber++}`; blockId = `${documentId}-verse-${blockNumber++}`;
this.contextCounters.set('verse', blockNumber); this.contextCounters.set("verse", blockNumber);
break; break;
case 'video': case "video":
blockNumber = this.contextCounters.get('video') ?? 0; blockNumber = this.contextCounters.get("video") ?? 0;
blockId = `${documentId}-video-${blockNumber++}`; blockId = `${documentId}-video-${blockNumber++}`;
this.contextCounters.set('video', blockNumber); this.contextCounters.set("video", blockNumber);
break; break;
default: default:
blockNumber = this.contextCounters.get('block') ?? 0; blockNumber = this.contextCounters.get("block") ?? 0;
blockId = `${documentId}-block-${blockNumber++}`; blockId = `${documentId}-block-${blockNumber++}`;
this.contextCounters.set('block', blockNumber); this.contextCounters.set("block", blockNumber);
break; break;
} }
@ -1058,18 +1133,19 @@ export default class Pharos {
return null; return null;
} }
return he.decode(input) return he
.decode(input)
.toLowerCase() .toLowerCase()
.replace(/[_]/g, ' ') // Replace underscores with spaces. .replace(/[_]/g, " ") // Replace underscores with spaces.
.trim() .trim()
.replace(/\s+/g, '-') // Replace spaces with dashes. .replace(/\s+/g, "-") // Replace spaces with dashes.
.replace(/[^a-z0-9\-]/g, ''); // Remove non-alphanumeric characters except dashes. .replace(/[^a-z0-9\-]/g, ""); // Remove non-alphanumeric characters except dashes.
} }
private updateEventByContext(dTag: string, value: string, context: string) { private updateEventByContext(dTag: string, value: string, context: string) {
switch (context) { switch (context) {
case 'document': case "document":
case 'section': case "section":
this.updateEventTitle(dTag, value); this.updateEventTitle(dTag, value);
break; break;
@ -1107,12 +1183,42 @@ export default class Pharos {
while ((match = wikilinkPattern.exec(content)) !== null) { while ((match = wikilinkPattern.exec(content)) !== null) {
const linkName = match[1]; const linkName = match[1];
const normalizedText = this.normalizeId(linkName); const normalizedText = this.normalizeId(linkName);
wikilinks.push(['wikilink', normalizedText!]); wikilinks.push(["wikilink", normalizedText!]);
} }
return wikilinks; return wikilinks;
} }
/**
* Extracts the first image URL from AsciiDoc content.
* @param content The AsciiDoc content to search for images.
* @returns The first image URL found, or null if no images are present.
*/
private extractImageFromContent(content: string): string | null {
// Look for AsciiDoc image syntax: image::url[alt text]
const imageRegex = /image::([^\s\[]+)/g;
let match = imageRegex.exec(content);
if (match) {
return match[1];
}
// Look for AsciiDoc image syntax: image:url[alt text]
const inlineImageRegex = /image:([^\s\[]+)/g;
match = inlineImageRegex.exec(content);
if (match) {
return match[1];
}
// Look for markdown-style image syntax: ![alt](url)
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
match = markdownImageRegex.exec(content);
if (match) {
return match[2];
}
return null;
}
// TODO: Add search-based wikilink resolution. // TODO: Add search-based wikilink resolution.
// #endregion // #endregion
@ -1123,7 +1229,7 @@ export const pharosInstance: Writable<Pharos> = writable();
export const tocUpdate = writable(0); export const tocUpdate = writable(0);
// Whenever you update the publication tree, call: // Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1); tocUpdate.update((n) => n + 1);
function ensureAsciiDocHeader(content: string): string { function ensureAsciiDocHeader(content: string): string {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
@ -1132,36 +1238,36 @@ function ensureAsciiDocHeader(content: string): string {
// Find the first non-empty line as header // Find the first non-empty line as header
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue; if (lines[i].trim() === "") continue;
if (lines[i].trim().startsWith('=')) { if (lines[i].trim().startsWith("=")) {
headerIndex = i; headerIndex = i;
console.debug('[Pharos] AsciiDoc document header:', lines[i].trim());
break; break;
} else { } else {
throw new Error('AsciiDoc document is missing a header at the top.'); throw new Error("AsciiDoc document is missing a header at the top.");
} }
} }
if (headerIndex === -1) { if (headerIndex === -1) {
throw new Error('AsciiDoc document is missing a header.'); throw new Error("AsciiDoc document is missing a header.");
} }
// Check for doctype in the next non-empty line after header // Check for doctype in the next non-empty line after header
let nextLine = headerIndex + 1; let nextLine = headerIndex + 1;
while (nextLine < lines.length && lines[nextLine].trim() === '') { while (nextLine < lines.length && lines[nextLine].trim() === "") {
nextLine++; nextLine++;
} }
if (nextLine < lines.length && lines[nextLine].trim().startsWith(':doctype:')) { if (
nextLine < lines.length &&
lines[nextLine].trim().startsWith(":doctype:")
) {
hasDoctype = true; hasDoctype = true;
} }
// Insert doctype immediately after header if not present // Insert doctype immediately after header if not present
if (!hasDoctype) { if (!hasDoctype) {
lines.splice(headerIndex + 1, 0, ':doctype: book'); lines.splice(headerIndex + 1, 0, ":doctype: book");
} }
// Log the state of the lines before returning return lines.join("\n");
console.debug('[Pharos] AsciiDoc lines after header/doctype normalization:', lines.slice(0, 5));
return lines.join('\n');
} }

111
src/lib/services/publisher.ts

@ -0,0 +1,111 @@
import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts";
import { getMimeTags } from "../utils/mime.ts";
import { parseAsciiDocSections } from "../utils/ZettelParser.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
export interface PublishResult {
success: boolean;
eventId?: string;
error?: string;
}
export interface PublishOptions {
content: string;
kind?: number;
onSuccess?: (eventId: string) => void;
onError?: (error: string) => void;
}
/**
* Publishes AsciiDoc content as Nostr events
* @param options - Publishing options
* @returns Promise resolving to publish result
*/
export async function publishZettel(
options: PublishOptions,
): Promise<PublishResult> {
const { content, kind = 30041, onSuccess, onError } = options;
if (!content.trim()) {
const error = "Please enter some content";
onError?.(error);
return { success: false, error };
}
// Get the current NDK instance from the store
const ndk = get(ndkInstance);
if (!ndk?.activeUser) {
const error = "Please log in first";
onError?.(error);
return { success: false, error };
}
try {
// Parse content into sections
const sections = parseAsciiDocSections(content, 2);
if (sections.length === 0) {
throw new Error("No valid sections found in content");
}
// For now, publish only the first section
const firstSection = sections[0];
const title = firstSection.title;
const cleanContent = firstSection.content;
const sectionTags = firstSection.tags || [];
// Generate d-tag and create event
const dTag = generateDTag(title);
const [mTag, MTag] = getMimeTags(kind);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", title]];
if (sectionTags) {
tags.push(...sectionTags);
}
// Create and sign NDK event
const ndkEvent = new NDKEvent(ndk);
ndkEvent.kind = kind;
ndkEvent.created_at = Math.floor(Date.now() / 1000);
ndkEvent.tags = tags;
ndkEvent.content = cleanContent;
ndkEvent.pubkey = ndk.activeUser.pubkey;
await ndkEvent.sign();
// Publish to relays
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
if (allRelayUrls.length === 0) {
throw new Error("No relays available in NDK pool");
}
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
const publishedToRelays = await ndkEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
const result = { success: true, eventId: ndkEvent.id };
onSuccess?.(ndkEvent.id);
return result;
} else {
// Try fallback publishing logic here...
throw new Error("Failed to publish to any relays");
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
function generateDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
}

14
src/lib/snippets/PublicationSnippets.svelte

@ -1,5 +1,5 @@
<script module lang='ts'> <script module lang="ts">
import { P } from 'flowbite-svelte'; import { P } from "flowbite-svelte";
export { contentParagraph, sectionHeading }; export { contentParagraph, sectionHeading };
</script> </script>
@ -8,13 +8,17 @@
{@const headingLevel = Math.min(depth + 1, 6)} {@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. --> <!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class='h-leather'> <svelte:element this={`h${headingLevel}`} class="h-leather">
{title} {title}
</svelte:element> </svelte:element>
{/snippet} {/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} {#snippet contentParagraph(
<section class='whitespace-normal publication-leather'> content: string,
publicationType: string,
isSectionStart: boolean,
)}
<section class="whitespace-normal publication-leather">
{@html content} {@html content}
</section> </section>
{/snippet} {/snippet}

78
src/lib/snippets/UserSnippets.svelte

@ -1,19 +1,81 @@
<script module lang='ts'> <script module lang="ts">
import { createProfileLink, createProfileLinkWithVerification, toNpub } from '$lib/utils/nostrUtils'; import { goto } from "$app/navigation";
import {
createProfileLinkWithVerification,
toNpub,
getUserMetadata,
} from "$lib/utils/nostrUtils";
// 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>
{#snippet userBadge(identifier: string, displayText: string | undefined)} {#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)} {@const npub = toNpub(identifier)}
{#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)} {#if npub}
{@html createProfileLink(toNpub(identifier) as string, displayText)} {#if !displayText || displayText.trim().toLowerCase() === "unknown"}
{#await getUserMetadata(npub) then profile}
{@const p = profile as NostrProfileWithLegacy}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{p.displayName ||
p.display_name ||
p.name ||
npub.slice(0, 8) + "..." + npub.slice(-4)}
</button>
</span>
{:catch}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{npub.slice(0, 8) + "..." + npub.slice(-4)}
</button>
</span>
{/await}
{:else}
{#await createProfileLinkWithVerification(npub as string, displayText)}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
</button>
</span>
{:then html} {:then html}
{@html html} <span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
</button>
{@html html.replace(/([\s\S]*<\/a>)/, "").trim()}
</span>
{:catch} {:catch}
{@html createProfileLink(toNpub(identifier) as string, displayText)} <span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
</button>
</span>
{/await} {/await}
{/if}
{:else} {:else}
{displayText ?? ''} {displayText ?? ""}
{/if} {/if}
{/snippet} {/snippet}

2
src/lib/state.ts

@ -1,6 +1,6 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import type { Tab } from "./types"; import type { Tab } from "./types.ts";
export const pathLoaded: Writable<boolean> = writable(false); export const pathLoaded: Writable<boolean> = writable(false);

28
src/lib/stores.ts

@ -1,30 +1,40 @@
import { readable, writable } from "svelte/store"; import { writable } from "svelte/store";
import { FeedType } from "./consts";
export let idList = writable<string[]>([]); // The old feedType store is no longer needed since we use the new relay management system
// All relay selection is now handled by the activeInboxRelays and activeOutboxRelays stores in ndk.ts
export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]); export const idList = writable<string[]>([]);
export let feedType = writable<FeedType>(FeedType.StandardRelays); export const alexandriaKinds = writable<number[]>([30040, 30041, 30818]);
export interface PublicationLayoutVisibility {
toc: boolean;
blog: boolean;
main: boolean;
inner: boolean;
discussion: boolean;
editing: boolean;
}
const defaultVisibility = { const defaultVisibility: PublicationLayoutVisibility = {
toc: false, toc: false,
blog: true, blog: true,
main: true, main: true,
inner: false, inner: false,
discussion: false, discussion: false,
editing: false editing: false,
}; };
function createVisibilityStore() { function createVisibilityStore() {
const { subscribe, set, update } = writable({ ...defaultVisibility }); const { subscribe, set, update } = writable<PublicationLayoutVisibility>({
...defaultVisibility,
});
return { return {
subscribe, subscribe,
set, set,
update, update,
reset: () => set({ ...defaultVisibility }) reset: () => set({ ...defaultVisibility }),
}; };
} }

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

@ -0,0 +1,11 @@
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);

55
src/lib/stores/networkStore.ts

@ -0,0 +1,55 @@
import { writable } from "svelte/store";
import { detectNetworkCondition, NetworkCondition, startNetworkMonitoring } from '../utils/network_detection.ts';
// Network status store
export const networkCondition = writable<NetworkCondition>(NetworkCondition.ONLINE);
export const isNetworkChecking = writable<boolean>(false);
// Network monitoring state
let stopNetworkMonitoring: (() => void) | null = null;
/**
* Starts network monitoring if not already running
*/
export function startNetworkStatusMonitoring(): void {
if (stopNetworkMonitoring) {
return; // Already monitoring
}
console.debug('[networkStore.ts] Starting network status monitoring');
stopNetworkMonitoring = startNetworkMonitoring(
(condition: NetworkCondition) => {
console.debug(`[networkStore.ts] Network condition changed to: ${condition}`);
networkCondition.set(condition);
},
60000 // Check every 60 seconds to reduce spam
);
}
/**
* Stops network monitoring
*/
export function stopNetworkStatusMonitoring(): void {
if (stopNetworkMonitoring) {
console.debug('[networkStore.ts] Stopping network status monitoring');
stopNetworkMonitoring();
stopNetworkMonitoring = null;
}
}
/**
* Manually check network status (for immediate updates)
*/
export async function checkNetworkStatus(): Promise<void> {
try {
isNetworkChecking.set(true);
const condition = await detectNetworkCondition();
networkCondition.set(condition);
} catch (error) {
console.warn('[networkStore.ts] Failed to check network status:', error);
networkCondition.set(NetworkCondition.OFFLINE);
} finally {
isNetworkChecking.set(false);
}
}

4
src/lib/stores/relayStore.ts

@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
// Initialize with empty array, will be populated from user preferences
export const userRelays = writable<string[]>([]);

427
src/lib/stores/userStore.ts

@ -0,0 +1,427 @@
import { writable, get } from "svelte/store";
import type { NostrProfile } from "../utils/nostrUtils.ts";
import type { NDKUser, NDKSigner } from "@nostr-dev-kit/ndk";
import NDK, {
NDKNip07Signer,
NDKRelayAuthPolicies,
NDKRelaySet,
NDKRelay,
} from "@nostr-dev-kit/ndk";
import { getUserMetadata } from "../utils/nostrUtils.ts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from "../ndk.ts";
import { loginStorageKey } from "../consts.ts";
import { nip19 } from "nostr-tools";
import { userPubkey } from "../stores/authStore.Svelte.ts";
export interface UserState {
pubkey: string | null;
npub: string | null;
profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] };
loginMethod: "extension" | "amber" | "npub" | null;
ndkUser: NDKUser | null;
signer: NDKSigner | null;
signedIn: boolean;
}
export const userStore = writable<UserState>({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
// Helper functions for relay management
function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
return `${loginStorageKey}/${user.pubkey}/${type}`;
}
function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
localStorage.setItem(
getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
);
localStorage.setItem(
getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
);
}
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
);
const outboxes = new Set<string>(
JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
);
return [inboxes, outboxes];
}
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)],
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(fallbacks, ndk),
);
const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>();
if (relayList == null) {
const relayMap = await globalThis.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(
([url, relayType]: [string, Record<string, boolean | undefined>]) => {
const relay = new NDKRelay(
url,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
},
);
} else {
relayList.tags.forEach((tag: string[]) => {
switch (tag[0]) {
case "r":
inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break;
case "w":
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break;
default:
inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break;
}
});
}
return [inboxRelays, outboxRelays];
}
// --- Unified login/logout helpers ---
export const loginMethodStorageKey = "alexandria/login/method";
function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") {
localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method);
}
function clearLogin() {
localStorage.removeItem(loginStorageKey);
localStorage.removeItem(loginMethodStorageKey);
}
/**
* Login with NIP-07 browser extension
*/
export async function loginWithExtension() {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const signer = new NDKNip07Signer();
const user = await signer.user();
const npub = user.npub;
console.log("Login with extension - fetching profile for npub:", npub);
// Try to fetch user metadata, but don't fail if it times out
let profile: NostrProfile | null = null;
try {
console.log("Login with extension - attempting to fetch profile...");
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with extension - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with extension - using fallback profile:", profile);
}
// Fetch user's preferred relays
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
const userState = {
pubkey: user.pubkey,
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
},
loginMethod: "extension" as const,
ndkUser: user,
signer,
signedIn: true,
};
console.log("Login with extension - setting userStore with:", userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithExtension: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithExtension: Failed to update relay stores:', error);
}
clearLogin();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "extension");
}
/**
* Login with Amber (NIP-46)
*/
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const npub = user.npub;
console.log("Login with Amber - fetching profile for npub:", npub);
let profile: NostrProfile | null = null;
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with Amber - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during Amber login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with Amber - using fallback profile:", profile);
}
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = amberSigner;
ndk.activeUser = user;
const userState = {
pubkey: user.pubkey,
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
},
loginMethod: "amber" as const,
ndkUser: user,
signer: amberSigner,
signedIn: true,
};
console.log("Login with Amber - setting userStore with:", userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithAmber: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithAmber: Failed to update relay stores:', error);
}
clearLogin();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "amber");
}
/**
* Login with npub (read-only)
*/
export async function loginWithNpub(pubkeyOrNpub: string) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub")) {
try {
hexPubkey = nip19.decode(pubkeyOrNpub).data as string;
} catch (e) {
console.error("Failed to decode hex pubkey from npub:", pubkeyOrNpub, e);
throw e;
}
} else {
hexPubkey = pubkeyOrNpub;
}
let npub: string;
try {
npub = nip19.npubEncode(hexPubkey);
} catch (e) {
console.error("Failed to encode npub from hex pubkey:", hexPubkey, e);
throw e;
}
console.log("Login with npub - fetching profile for npub:", npub);
const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null;
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with npub - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during npub login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with npub - using fallback profile:", profile);
}
ndk.signer = undefined;
ndk.activeUser = user;
const userState = {
pubkey: user.pubkey,
npub,
profile,
relays: { inbox: [], outbox: [] },
loginMethod: "npub" as const,
ndkUser: user,
signer: null,
signedIn: true,
};
console.log("Login with npub - setting userStore with:", 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();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "npub");
}
/**
* Logout and clear all user state
*/
export function logoutUser() {
console.log("Logging out user...");
const currentUser = get(userStore);
if (currentUser.ndkUser) {
// Clear persisted relays for the user
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox"));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "outbox"));
}
// Clear all possible login states from localStorage
clearLogin();
// Also clear any other potential login keys that might exist
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (
key &&
(key.includes("login") ||
key.includes("nostr") ||
key.includes("user") ||
key.includes("alexandria") ||
key === "pubkey")
) {
keysToRemove.push(key);
}
}
// Specifically target the login storage key
keysToRemove.push("alexandria/login/pubkey");
keysToRemove.push("alexandria/login/method");
keysToRemove.forEach((key) => {
console.log("Removing localStorage key:", key);
localStorage.removeItem(key);
});
// Clear Amber-specific flags
localStorage.removeItem("alexandria/amber/fallback");
// Set a flag to prevent auto-login on next page load
localStorage.setItem("alexandria/logout/flag", "true");
console.log("Cleared all login data from localStorage");
userStore.set({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
userPubkey.set(null);
const ndk = get(ndkInstance);
if (ndk) {
ndk.activeUser = undefined;
ndk.signer = undefined;
}
console.log("Logout complete");
}

10
src/lib/types.ts

@ -3,7 +3,13 @@ export type Tab = {
type: TabType; type: TabType;
parent?: number; parent?: number;
previous?: Tab; previous?: Tab;
data?: any; data?: unknown;
}; };
export type TabType = 'welcome' | 'find' | 'article' | 'user' | 'settings' | 'editor'; export type TabType =
| "welcome"
| "find"
| "article"
| "user"
| "settings"
| "editor";

36
src/lib/utils.ts

@ -1,6 +1,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils"; import { getMatchingTags } from "./utils/nostrUtils.ts";
export function neventEncode(event: NDKEvent, relays: string[]) { export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({ return nip19.neventEncode({
@ -12,9 +12,9 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
} }
export function naddrEncode(event: NDKEvent, relays: string[]) { export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = getMatchingTags(event, 'd')[0]?.[1]; const dTag = getMatchingTags(event, "d")[0]?.[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");
} }
return nip19.naddrEncode({ return nip19.naddrEncode({
@ -97,8 +97,8 @@ export function isElementInViewport(el: string | HTMLElement) {
rect.top >= 0 && rect.top >= 0 &&
rect.left >= 0 && rect.left >= 0 &&
rect.bottom <= rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) && (globalThis.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (globalThis.innerWidth || document.documentElement.clientWidth)
); );
} }
@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) {
export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// The filter object supports only limited parameters, so we need to filter out events that // The filter object supports only limited parameters, so we need to filter out events that
// don't respect NKBIP-01. // don't respect NKBIP-01.
events.forEach(event => { events.forEach((event) => {
// Index events have no content, and they must have `title`, `d`, and `e` tags. // Index events have no content, and they must have `title`, `d`, and `e` tags.
if ( if (
(event.content != null && event.content.length > 0) (event.content != null && event.content.length > 0) ||
|| getMatchingTags(event, 'title').length === 0 getMatchingTags(event, "title").length === 0 ||
|| getMatchingTags(event, 'd').length === 0 getMatchingTags(event, "d").length === 0 ||
|| ( (getMatchingTags(event, "a").length === 0 &&
getMatchingTags(event, 'a').length === 0 getMatchingTags(event, "e").length === 0)
&& getMatchingTags(event, 'e').length === 0
)
) { ) {
events.delete(event); events.delete(event);
} }
@ -138,7 +136,7 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
*/ */
export async function findIndexAsync<T>( export async function findIndexAsync<T>(
array: T[], array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean> predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> { ): Promise<number> {
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
if (await predicate(array[i], i, array)) { if (await predicate(array[i], i, array)) {
@ -152,14 +150,14 @@ export async function findIndexAsync<T>(
declare global { declare global {
interface Array<T> { interface Array<T> {
findIndexAsync( findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean> predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number>; ): Promise<number>;
} }
} }
Array.prototype.findIndexAsync = function<T>( Array.prototype.findIndexAsync = function <T>(
this: T[], this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean> predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> { ): Promise<number> {
return findIndexAsync(this, predicate); return findIndexAsync(this, predicate);
}; };
@ -171,9 +169,9 @@ Array.prototype.findIndexAsync = function<T>(
* @param wait The number of milliseconds to delay * @param wait The number of milliseconds to delay
* @returns A debounced version of the function * @returns A debounced version of the function
*/ */
export function debounce<T extends (...args: any[]) => any>( export function debounce<T extends (...args: unknown[]) => unknown>(
func: T, func: T,
wait: number wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined; let timeout: ReturnType<typeof setTimeout> | undefined;

110
src/lib/utils/ZettelParser.ts

@ -0,0 +1,110 @@
export interface ZettelSection {
title: string;
content: string;
tags?: string[][];
}
/**
* Splits AsciiDoc content into sections at the specified heading level.
* Each section starts with the heading and includes all lines up to the next heading of the same level.
* @param content The AsciiDoc string.
* @param level The heading level (2 for '==', 3 for '===', etc.).
* @returns Array of section strings, each starting with the heading.
*/
export function splitAsciiDocByHeadingLevel(
content: string,
level: number,
): string[] {
if (level < 1 || level > 6) throw new Error("Heading level must be 1-6");
const heading = "^" + "=".repeat(level) + " ";
const regex = new RegExp(`(?=${heading})`, "gm");
return content
.split(regex)
.map((section) => section.trim())
.filter((section) => section.length > 0);
}
/**
* Parses a single AsciiDoc section string into a ZettelSection object.
* @param section The section string (must start with heading).
*/
export function parseZettelSection(section: string): ZettelSection {
const lines = section.split("\n");
let title = "Untitled";
const contentLines: string[] = [];
let inHeader = true;
let tags: string[][] = [];
tags = extractTags(section);
for (const line of lines) {
const trimmed = line.trim();
if (inHeader && trimmed.startsWith("==")) {
title = trimmed.replace(/^==+/, "").trim();
continue;
} else if (inHeader && trimmed.startsWith(":")) {
continue;
}
inHeader = false;
contentLines.push(line);
}
return {
title,
content: contentLines.join("\n").trim(),
tags,
};
}
/**
* Parses AsciiDoc into an array of ZettelSection objects at the given heading level.
*/
export function parseAsciiDocSections(
content: string,
level: number,
): ZettelSection[] {
return splitAsciiDocByHeadingLevel(content, level).map(parseZettelSection);
}
/**
* Extracts tag names and values from the content.
* :tagname: tagvalue // tags are optional
* @param content The AsciiDoc string.
* @returns Array of tags.
*/
export function extractTags(content: string): string[][] {
const tags: string[][] = [];
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value
const match = trimmed.match(/^:([^:]+):\s*(.*)$/);
if (match) {
const tagName = match[1].trim();
const tagValue = match[2].trim();
// Special handling for tags attribute
if (tagName === "tags") {
// Split comma-separated values and create individual "t" tags
const tagValues = tagValue
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
for (const value of tagValues) {
tags.push(["t", value]);
}
} else {
// Regular attribute becomes a tag
tags.push([tagName, tagValue]);
}
}
}
}
console.log("Extracted tags:", tags);
return tags;
}
// You can add publishing logic here as needed, e.g.,
// export async function publishZettelSection(...) { ... }

106
src/lib/utils/community_checker.ts

@ -0,0 +1,106 @@
import { communityRelays } from "$lib/consts";
import { RELAY_CONSTANTS, SEARCH_LIMITS } from "./search_constants";
// Cache for pubkeys with kind 1 events on communityRelay
const communityCache = new Map<string, boolean>();
/**
* Check if a pubkey has posted to the community relay
*/
export async function checkCommunity(pubkey: string): Promise<boolean> {
if (communityCache.has(pubkey)) {
return communityCache.get(pubkey)!;
}
try {
// Try each community relay until we find one that works
for (const relayUrl of communityRelays) {
try {
const ws = new WebSocket(relayUrl);
const result = await new Promise<boolean>((resolve) => {
ws.onopen = () => {
ws.send(
JSON.stringify([
"REQ",
RELAY_CONSTANTS.COMMUNITY_REQUEST_ID,
{
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK,
},
]),
);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === "EVENT" && data[2]?.kind === 1) {
communityCache.set(pubkey, true);
ws.close();
resolve(true);
} else if (data[0] === "EOSE") {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
}
};
ws.onerror = () => {
ws.close();
resolve(false);
};
});
if (result) {
return true;
}
} catch {
// Continue to next relay if this one fails
continue;
}
}
// If we get here, no relay found the user
communityCache.set(pubkey, false);
return false;
} catch {
communityCache.set(pubkey, false);
return false;
}
}
/**
* Check community status for multiple profiles
*/
export async function checkCommunityStatus(
profiles: Array<{ pubkey?: string }>,
): Promise<Record<string, boolean>> {
const communityStatus: Record<string, boolean> = {};
// Run all community checks in parallel with timeout
const checkPromises = profiles.map(async (profile) => {
if (!profile.pubkey) return { pubkey: "", status: false };
try {
const status = await Promise.race([
checkCommunity(profile.pubkey),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 2000); // 2 second timeout per check
}),
]);
return { pubkey: profile.pubkey, status };
} catch (error) {
console.warn("Community status check failed for", profile.pubkey, error);
return { pubkey: profile.pubkey, status: false };
}
});
// Wait for all checks to complete
const results = await Promise.allSettled(checkPromises);
for (const result of results) {
if (result.status === "fulfilled" && result.value.pubkey) {
communityStatus[result.value.pubkey] = result.value.status;
}
}
return communityStatus;
}

437
src/lib/utils/event_input_utils.ts

@ -0,0 +1,437 @@
import type { NDKEvent } from "./nostrUtils.ts";
import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants";
// =========================
// Validation
// =========================
/**
* Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/
export function requiresDTag(kind: number): boolean {
return (
kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX
);
}
/**
* Returns true if the tags array contains at least one d-tag with a non-empty value.
*/
export function hasDTag(tags: [string, string][]): boolean {
return tags.some(([k, v]) => k === "d" && v && v.trim() !== "");
}
/**
* Returns true if the content contains AsciiDoc headers (lines starting with '=' or '==').
*/
function containsAsciiDocHeaders(content: string): boolean {
return /^={1,}\s+/m.test(content);
}
/**
* Validates that content does NOT contain AsciiDoc headers (for kind 30023).
* Returns { valid, reason }.
*/
export function validateNotAsciidoc(content: string): {
valid: boolean;
reason?: string;
} {
if (containsAsciiDocHeaders(content)) {
return {
valid: false,
reason:
"Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).",
};
}
return { valid: true };
}
/**
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header.
* Returns { valid, reason }.
*/
export function validateAsciiDoc(content: string): {
valid: boolean;
reason?: string;
} {
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: 'AsciiDoc must start with a document title ("=").',
};
}
if (!/^==\s+/m.test(content)) {
return {
valid: false,
reason: 'AsciiDoc must contain at least one section header ("==").',
};
}
return { valid: true };
}
/**
* Validates that a 30040 event set will be created correctly.
* Returns { valid, reason }.
*/
export function validate30040EventSet(content: string): {
valid: boolean;
reason?: string;
} {
// First validate as AsciiDoc
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check that we have at least one section
const sectionsResult = splitAsciiDocSections(content);
if (sectionsResult.sections.length === 0) {
return {
valid: false,
reason: "30040 events must contain at least one section.",
};
}
// Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) {
return {
valid: false,
reason:
'30040 events must have a document title (line starting with "=").',
};
}
// Check that the content will result in an empty 30040 event
// The 30040 event should have empty content, with all content split into 30041 events
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: '30040 events must start with a document title ("=").',
};
}
return { valid: true };
}
// =========================
// Extraction & Normalization
// =========================
/**
* Normalize a string for use as a d-tag: lowercase, hyphens, alphanumeric only.
*/
function normalizeDTagValue(header: string): string {
return header
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Converts a title string to a valid d-tag (lowercase, hyphens, no punctuation).
*/
export function titleToDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
}
/**
* Extracts the first AsciiDoc document header (line starting with '= ').
*/
function extractAsciiDocDocumentHeader(content: string): string | null {
const match = content.match(/^=\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/**
* Extracts the topmost Markdown # header (line starting with '# ').
*/
function extractMarkdownTopHeader(content: string): string | null {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/**
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings.
* Document title (= header) is excluded from sections and only used for the index event title.
* Section headers (==) are discarded from content.
* Text between document header and first section becomes a "Preamble" section.
*/
function splitAsciiDocSections(content: string): {
sections: string[];
sectionHeaders: string[];
hasPreamble: boolean;
} {
const lines = content.split(/\r?\n/);
const sections: string[] = [];
const sectionHeaders: string[] = [];
let current: string[] = [];
let foundFirstSection = false;
let hasPreamble = false;
const preambleContent: string[] = [];
for (const line of lines) {
// Skip document title lines (= header)
if (/^=\s+/.test(line)) {
continue;
}
// If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) {
if (current.length > 0) {
sections.push(current.join("\n").trim());
current = [];
}
// Extract section header for title tag
const headerMatch = line.match(/^==\s+(.+)$/);
if (headerMatch) {
sectionHeaders.push(headerMatch[1].trim());
}
foundFirstSection = true;
} else if (foundFirstSection) {
// Only add lines to current section if we've found the first section
current.push(line);
} else {
// Text before first section becomes preamble
if (line.trim() !== "") {
preambleContent.push(line);
}
}
}
// Add the last section
if (current.length > 0) {
sections.push(current.join("\n").trim());
}
// Add preamble as first section if it exists
if (preambleContent.length > 0) {
sections.unshift(preambleContent.join("\n").trim());
sectionHeaders.unshift("Preamble");
hasPreamble = true;
}
return { sections, sectionHeaders, hasPreamble };
}
// =========================
// 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.
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header).
* The 30040 index event references all 30041s by their d-tag.
*/
export function build30040EventSet(
content: string,
tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number },
): { 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);
const sectionsResult = splitAsciiDocSections(content);
const sections = sectionsResult.sections;
const sectionHeaders = sectionsResult.sectionHeaders;
console.log("Sections:", sections);
console.log("Section headers:", sectionHeaders);
const dTags =
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log("D tags:", dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i];
console.log(`Creating section ${i}:`, { header, dTag, content: section });
return new NDKEventClass(ndk, {
kind: 30041,
content: section,
tags: [...tags, ["d", dTag], ["title", header]],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map(
(dTag) => ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string],
);
console.log("A tags:", aTags);
// Extract document title for the index event
const documentTitle = extractAsciiDocDocumentHeader(content);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
const indexTags = [
...tags,
["d", indexDTag],
["title", documentTitle || "Untitled"],
...aTags,
];
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: "",
tags: indexTags,
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
console.log("Final index event:", indexEvent);
console.log("=== build30040EventSet completed ===");
return { indexEvent, sectionEvents };
}
/**
* Returns the appropriate title tag for a given event kind and content.
* - 30041, 30818: AsciiDoc document header (first '= ' line)
* - 30023: Markdown topmost '# ' header
*/
export function getTitleTagForEvent(
kind: number,
content: string,
): string | null {
if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content);
}
if (kind === 30023) {
return extractMarkdownTopHeader(content);
}
return null;
}
/**
* Returns the appropriate d-tag value for a given event kind and content.
* - 30023: Normalized markdown header
* - 30041, 30818: Normalized AsciiDoc document header
* - 30040: Uses existing d-tag or generates from content
*/
export function getDTagForEvent(
kind: number,
content: string,
existingDTag?: string,
): string | null {
if (existingDTag && existingDTag.trim() !== "") {
return existingDTag.trim();
}
if (kind === 30023) {
const title = extractMarkdownTopHeader(content);
return title ? normalizeDTagValue(title) : null;
}
if (kind === 30041 || kind === 30818) {
const title = extractAsciiDocDocumentHeader(content);
return title ? normalizeDTagValue(title) : null;
}
return null;
}
/**
* Returns a description of what a 30040 event structure should be.
*/
export function get30040EventDescription(): string {
return `30040 events are publication indexes that contain:
- Empty content (metadata only)
- A d-tag for the publication identifier
- A title tag for the publication title
- A tags referencing 30041 content events (one per section)
The content is split into sections, each published as a separate 30041 event.`;
}
/**
* Analyzes a 30040 event to determine if it was created correctly.
* Returns { valid, issues } where issues is an array of problems found.
*/
export function analyze30040Event(event: {
content: string;
tags: [string, string][];
kind: number;
}): { valid: boolean; issues: string[] } {
const issues: string[] = [];
// Check if it's actually a 30040 event
if (event.kind !== 30040) {
issues.push("Event is not kind 30040");
return { valid: false, issues };
}
// Check if content is empty (30040 should be metadata only)
if (event.content && event.content.trim() !== "") {
issues.push("30040 events should have empty content (metadata only)");
issues.push("Content should be split into separate 30041 events");
}
// Check for required tags
const hasTitle = event.tags.some(([k, v]) => k === "title" && v);
const hasDTag = event.tags.some(([k, v]) => k === "d" && v);
const hasATags = event.tags.some(([k, v]) => k === "a" && v);
if (!hasTitle) {
issues.push("Missing title tag");
}
if (!hasDTag) {
issues.push("Missing d tag");
}
if (!hasATags) {
issues.push("Missing a tags (should reference 30041 content events)");
}
// Check if a tags have the correct format (kind:pubkey:d-tag)
const aTags = event.tags.filter(([k, v]) => k === "a" && v);
for (const [, value] of aTags) {
if (!value.includes(":")) {
issues.push(
`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`,
);
}
}
return { valid: issues.length === 0, issues };
}
/**
* Returns guidance on how to fix incorrect 30040 events.
*/
export function get30040FixGuidance(): string {
return `To fix a 30040 event:
1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events.
2. **Structure**: A proper 30040 event should contain:
- Empty content
- d tag: publication identifier
- title tag: publication title
- a tags: references to 30041 content events (format: "30041:pubkey:d-tag")
3. **Process**: When creating a 30040 event:
- Write your content with document title (= Title) and sections (== Section)
- The system will automatically split it into one 30040 index event and multiple 30041 content events
- The 30040 will have empty content and reference the 30041s via a tags`;
}

224
src/lib/utils/event_search.ts

@ -0,0 +1,224 @@
import { ndkInstance } from "../ndk.ts";
import { fetchEventWithFallback } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts";
import { TIMEOUTS, VALIDATION } from "./search_constants.ts";
/**
* Search for a single event by ID or filter
*/
export async function searchEvent(query: string): Promise<NDKEvent | null> {
// Clean the query and normalize to lowercase
const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: NDKFilter | string = cleanedQuery;
// If it's a valid hex string, try as event id first, then as pubkey (profile)
if (
new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(cleanedQuery)
) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback(
get(ndkInstance),
filterOrId,
TIMEOUTS.EVENT_FETCH,
);
// Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback(
get(ndkInstance),
profileFilter,
TIMEOUTS.EVENT_FETCH,
);
// Prefer profile if found and pubkey matches query
if (
profileEvent &&
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()
) {
return profileEvent;
} else if (eventResult) {
return eventResult;
}
} else if (
new RegExp(
`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`,
"i",
).test(cleanedQuery)
) {
try {
const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error("Invalid identifier");
switch (decoded.type) {
case "nevent":
filterOrId = decoded.data.id;
break;
case "note":
filterOrId = decoded.data;
break;
case "naddr":
filterOrId = {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
"#d": [decoded.data.identifier],
};
break;
case "nprofile":
filterOrId = {
kinds: [0],
authors: [decoded.data.pubkey],
};
break;
case "npub":
filterOrId = {
kinds: [0],
authors: [decoded.data],
};
break;
default:
filterOrId = cleanedQuery;
}
} catch (e) {
console.error("[Search] Invalid Nostr identifier:", cleanedQuery, e);
throw new Error("Invalid Nostr identifier.");
}
}
try {
const event = await fetchEventWithFallback(
get(ndkInstance),
filterOrId,
TIMEOUTS.EVENT_FETCH,
);
if (!event) {
console.warn("[Search] Event not found for filterOrId:", filterOrId);
return null;
} else {
return event;
}
} catch (err) {
console.error("[Search] Error fetching event:", err, "Query:", query);
throw new Error("Error fetching event. Please check the ID and try again.");
}
}
/**
* Search for NIP-05 address
*/
export async function searchNip05(
nip05Address: string,
): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) {
throw new Error("Invalid NIP-05 address format. Expected: user@domain");
}
try {
const [name, domain] = nip05Address.split("@");
const res = await fetch(wellKnownUrl(domain, name));
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback(
get(ndkInstance),
profileFilter,
TIMEOUTS.EVENT_FETCH,
);
if (profileEvent) {
return profileEvent;
} else {
throw new Error(
`No profile found for ${name}@${domain} (pubkey: ${pubkey})`,
);
}
} else {
throw new Error(`NIP-05 address not found: ${name}@${domain}`);
}
} catch (e) {
console.error(
`[Search] Error resolving NIP-05 address ${nip05Address}:`,
e,
);
const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`);
}
}
/**
* Find containing 30040 index events for a given content event
* @param contentEvent The content event to find containers for (30041, 30818, etc.)
* @returns Array of containing 30040 index events
*/
export async function findContainingIndexEvents(
contentEvent: NDKEvent,
): Promise<NDKEvent[]> {
// Support all content event kinds that can be contained in indexes
const contentEventKinds = [30041, 30818, 30040, 30023];
if (!contentEventKinds.includes(contentEvent.kind!)) {
return [];
}
try {
const ndk = get(ndkInstance);
// 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
const contentEventId = contentEvent.id;
const contentEventAddress = contentEvent.tagAddress();
// Search for index events that reference this content event
const indexEvents = await ndk.fetchEvents(
{
kinds: [30040],
"#a": [contentEventAddress],
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
// Also search for events with 'e' tags (legacy format)
const indexEventsWithETags = await ndk.fetchEvents(
{
kinds: [30040],
"#e": [contentEventId],
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
// Combine and deduplicate results
const allIndexEvents = new Set([...indexEvents, ...indexEventsWithETags]);
// Filter to only include valid index events
const validIndexEvents = Array.from(allIndexEvents).filter((event) => {
// Check if it's a valid index event (has title, d tag, and either a or e tags)
const hasTitle = event.getMatchingTags("title").length > 0;
const hasDTag = event.getMatchingTags("d").length > 0;
const hasATags = event.getMatchingTags("a").length > 0;
const hasETags = event.getMatchingTags("e").length > 0;
return hasTitle && hasDTag && (hasATags || hasETags);
});
return validIndexEvents;
} catch (error) {
console.error("[Search] Error finding containing index events:", error);
return [];
}
}

31
src/lib/utils/image_utils.ts

@ -0,0 +1,31 @@
/**
* Generate a dark-pastel color based on a string (like an event ID)
* @param seed - The string to generate a color from
* @returns A dark-pastel hex color
*/
export function generateDarkPastelColor(seed: string): string {
// Create a simple hash from the seed string
let hash = 0;
for (let i = 0; i < seed.length; i++) {
const char = seed.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Use the hash to generate lighter pastel colors
// Keep values in the 120-200 range for better pastel effect
const r = Math.abs(hash) % 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
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
/**
* Test function to verify color generation
* @param eventId - The event ID to test
* @returns The generated color
*/
export function testColorGeneration(eventId: string): string {
return generateDarkPastelColor(eventId);
}

139
src/lib/utils/indexEventCache.ts

@ -0,0 +1,139 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { CACHE_DURATIONS, TIMEOUTS } from "./search_constants.ts";
export interface IndexEventCacheEntry {
events: NDKEvent[];
timestamp: number;
relayUrls: string[];
}
class IndexEventCache {
private cache: Map<string, IndexEventCacheEntry> = new Map();
private readonly CACHE_DURATION = CACHE_DURATIONS.INDEX_EVENT_CACHE;
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached relay combinations
/**
* Generate a cache key based on relay URLs
*/
private generateKey(relayUrls: string[]): string {
return relayUrls.sort().join("|");
}
/**
* Check if a cached entry is still valid
*/
private isExpired(entry: IndexEventCacheEntry): boolean {
return Date.now() - entry.timestamp > this.CACHE_DURATION;
}
/**
* Get cached index events for a set of relays
*/
get(relayUrls: string[]): NDKEvent[] | null {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
if (!entry || this.isExpired(entry)) {
if (entry) {
this.cache.delete(key);
}
return null;
}
console.log(
`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`,
);
return entry.events;
}
/**
* Store index events in cache
*/
set(relayUrls: string[], events: NDKEvent[]): void {
const key = this.generateKey(relayUrls);
// Implement LRU eviction if cache is full
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
events,
timestamp: Date.now(),
relayUrls: [...relayUrls],
});
console.log(
`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`,
);
}
/**
* Check if index events are cached for a set of relays
*/
has(relayUrls: string[]): boolean {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
return entry !== undefined && !this.isExpired(entry);
}
/**
* Clear expired entries from cache
*/
cleanup(): void {
for (const [key, entry] of this.cache.entries()) {
if (this.isExpired(entry)) {
this.cache.delete(key);
}
}
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
/**
* Get cache statistics
*/
getStats(): {
size: number;
totalEvents: number;
oldestEntry: number | null;
} {
let totalEvents = 0;
let oldestTimestamp: number | null = null;
for (const entry of this.cache.values()) {
totalEvents += entry.events.length;
if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) {
oldestTimestamp = entry.timestamp;
}
}
return {
size: this.cache.size,
totalEvents,
oldestEntry: oldestTimestamp,
};
}
}
export const indexEventCache = new IndexEventCache();
// Clean up expired entries periodically
setInterval(() => {
indexEventCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

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

@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus:
- **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 shall be placed, and will be displayed as unique, consecutive numbers
- **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 - **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](./wiki?d=nip-54) - **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](./events?d=nip-54)
## Publications and Wikis ## Publications and Wikis
@ -42,13 +42,88 @@ AsciiDoc supports a much broader set of formatting, semantic, and structural fea
- Advanced tables, callouts, admonitions - Advanced tables, callouts, admonitions
- Cross-references, footnotes, and bibliography - Cross-references, footnotes, and bibliography
- Custom attributes and macros - Custom attributes and macros
- **Math rendering** (Asciimath and LaTeX)
- **Diagram rendering** (PlantUML, BPMN, TikZ)
- And much more - And much more
### Advanced Content Types
Alexandria supports rendering of advanced content types commonly used in academic, technical, and business documents:
#### Math Rendering
Use `[stem]` blocks for mathematical expressions:
```asciidoc
[stem]
++++
\frac{\partial f}{\partial x} = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}
++++
```
Inline math is also supported using `$...$` or `\(...\)` syntax.
#### PlantUML Diagrams
PlantUML diagrams are automatically detected and rendered:
```asciidoc
[source,plantuml]
----
@startuml
participant User
participant System
User -> System: Login Request
System --> User: Login Response
@enduml
----
```
#### BPMN Diagrams
BPMN (Business Process Model and Notation) diagrams are supported:
```asciidoc
[source,bpmn]
----
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
<bpmn:process id="Process_1">
<bpmn:startEvent id="StartEvent_1" name="Start"/>
<bpmn:task id="Task_1" name="Process Task"/>
<bpmn:endEvent id="EndEvent_1" name="End"/>
</bpmn:process>
</bpmn:definitions>
----
```
#### TikZ Diagrams
TikZ diagrams for mathematical illustrations:
```asciidoc
[source,tikz]
----
\begin{tikzpicture}
\draw[thick,red] (0,0) circle (1cm);
\draw[thick,blue] (2,0) rectangle (3,1);
\end{tikzpicture}
----
```
### Rendering Features
- **Automatic Detection**: Content types are automatically detected based on syntax
- **Fallback Display**: If rendering fails, the original source code is displayed
- **Source Code**: Click "Show source" to view the original code
- **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 user-generated content.
- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility. - Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility.
- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format. - All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format.

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

@ -0,0 +1,371 @@
import { postProcessAsciidoctorHtml } from "./asciidoctorPostProcessor.ts";
import plantumlEncoder from "plantuml-encoder";
/**
* Unified post-processor for Asciidoctor HTML that handles:
* - Math rendering (Asciimath/Latex, stem blocks)
* - PlantUML diagrams
* - BPMN diagrams
* - TikZ diagrams
*/
export async function postProcessAdvancedAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html;
try {
// First apply the basic post-processing (wikilinks, nostr addresses)
let processedHtml = await postProcessAsciidoctorHtml(html);
// Unified math block processing
processedHtml = fixAllMathBlocks(processedHtml);
// Process PlantUML blocks
processedHtml = processPlantUMLBlocks(processedHtml);
// Process BPMN blocks
processedHtml = processBPMNBlocks(processedHtml);
// Process TikZ blocks
processedHtml = processTikZBlocks(processedHtml);
// After all processing, apply highlight.js if available
if (
typeof globalThis !== "undefined" &&
typeof globalThis.hljs?.highlightAll === "function"
) {
setTimeout(() => globalThis.hljs!.highlightAll(), 0);
}
if (
typeof globalThis !== "undefined" &&
typeof globalThis.MathJax?.typesetPromise === "function"
) {
setTimeout(() => globalThis.MathJax.typesetPromise(), 0);
}
return processedHtml;
} catch (error) {
console.error("Error in postProcessAdvancedAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails
}
}
/**
* Fixes all math blocks for MathJax rendering.
* Now only processes LaTeX within inline code blocks.
*/
function fixAllMathBlocks(html: string): string {
// Unescape \$ to $ for math delimiters
html = html.replace(/\\\$/g, "$");
// Process inline code blocks that contain LaTeX
html = html.replace(
/<code[^>]*class="[^"]*language-[^"]*"[^>]*>([\s\S]*?)<\/code>/g,
(match, codeContent) => {
const trimmedCode = codeContent.trim();
if (isLaTeXContent(trimmedCode)) {
return `<span class="math-inline">$${trimmedCode}$</span>`;
}
return match; // Return original if not LaTeX
},
);
// Also process code blocks without language class
html = html.replace(
/<code[^>]*>([\s\S]*?)<\/code>/g,
(match, codeContent) => {
const trimmedCode = codeContent.trim();
if (isLaTeXContent(trimmedCode)) {
return `<span class="math-inline">$${trimmedCode}$</span>`;
}
return match; // Return original if not LaTeX
},
);
return html;
}
/**
* Checks if content contains LaTeX syntax
*/
function isLaTeXContent(content: string): boolean {
const trimmed = content.trim();
// Check for common LaTeX patterns
const latexPatterns = [
/\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
/\\[\(\)\[\]]/, // LaTeX delimiters like \(, \), \[, \]
/\\begin\{/, // LaTeX environments
/\\end\{/, // LaTeX environments
/\$\$/, // Display math delimiters
/\$[^$]+\$/, // Inline math delimiters
/\\text\{/, // LaTeX text command
/\\mathrm\{/, // LaTeX mathrm command
/\\mathbf\{/, // LaTeX bold command
/\\mathit\{/, // LaTeX italic command
/\\sqrt/, // Square root
/\\frac/, // Fraction
/\\sum/, // Sum
/\\int/, // Integral
/\\lim/, // Limit
/\\infty/, // Infinity
/\\alpha/, // Greek letters
/\\beta/,
/\\gamma/,
/\\delta/,
/\\theta/,
/\\lambda/,
/\\mu/,
/\\pi/,
/\\sigma/,
/\\phi/,
/\\omega/,
/\\partial/, // Partial derivative
/\\nabla/, // Nabla
/\\cdot/, // Dot product
/\\times/, // Times
/\\div/, // Division
/\\pm/, // Plus-minus
/\\mp/, // Minus-plus
/\\leq/, // Less than or equal
/\\geq/, // Greater than or equal
/\\neq/, // Not equal
/\\approx/, // Approximately equal
/\\equiv/, // Equivalent
/\\propto/, // Proportional
/\\in/, // Element of
/\\notin/, // Not element of
/\\subset/, // Subset
/\\supset/, // Superset
/\\cup/, // Union
/\\cap/, // Intersection
/\\emptyset/, // Empty set
/\\mathbb\{/, // Blackboard bold
/\\mathcal\{/, // Calligraphic
/\\mathfrak\{/, // Fraktur
/\\mathscr\{/, // Script
];
return latexPatterns.some((pattern) => pattern.test(trimmed));
}
/**
* Processes PlantUML blocks in HTML content
*/
function processPlantUMLBlocks(html: string): string {
// Only match code blocks with class 'language-plantuml' or 'plantuml'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-plantuml|plantuml)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
try {
// Unescape HTML for PlantUML server, but escape for <code>
const rawContent = decodeHTMLEntities(content);
const encoded = plantumlEncoder.encode(rawContent);
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
return `<div class="plantuml-block my-4">
<img src="${plantUMLUrl}" alt="PlantUML diagram"
class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg"
loading="lazy" decoding="async">
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show PlantUML source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(rawContent)}</code>
</pre>
</details>
</div>`;
} catch (error) {
console.warn("Failed to process PlantUML block:", error);
return match;
}
},
);
// Fallback: match <pre> blocks whose content starts with @startuml or @start (global, robust)
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
const lines = content.trim().split("\n");
if (
lines[0].trim().startsWith("@startuml") ||
lines[0].trim().startsWith("@start")
) {
try {
const rawContent = decodeHTMLEntities(content);
const encoded = plantumlEncoder.encode(rawContent);
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
return `<div class="plantuml-block my-4">
<img src="${plantUMLUrl}" alt="PlantUML diagram"
class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg"
loading="lazy" decoding="async">
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show PlantUML source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(rawContent)}</code>
</pre>
</details>
</div>`;
} catch (error) {
console.warn("Failed to process PlantUML fallback block:", error);
return match;
}
}
return match;
},
);
return html;
}
function decodeHTMLEntities(text: string): string {
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
}
/**
* Processes BPMN blocks in HTML content
*/
function processBPMNBlocks(html: string): string {
// Only match code blocks with class 'language-bpmn' or 'bpmn'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-bpmn|bpmn)[^\"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
try {
return `<div class="bpmn-block my-4">
<div class="bpmn-diagram p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700">
<div class="text-center text-blue-600 dark:text-blue-400 mb-2">
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
BPMN Diagram
</div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show BPMN source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(content)}</code>
</pre>
</details>
</div>
</div>`;
} catch (error) {
console.warn("Failed to process BPMN block:", error);
return match;
}
},
);
// Fallback: match <pre> blocks whose content contains 'bpmn:' or '<?xml' and 'bpmn'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
const text = content.trim();
if (
text.includes("bpmn:") ||
(text.startsWith("<?xml") && text.includes("bpmn"))
) {
try {
return `<div class="bpmn-block my-4">
<div class="bpmn-diagram p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700">
<div class="text-center text-blue-600 dark:text-blue-400 mb-2">
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
BPMN Diagram
</div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show BPMN source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(content)}</code>
</pre>
</details>
</div>
</div>`;
} catch (error) {
console.warn("Failed to process BPMN fallback block:", error);
return match;
}
}
return match;
},
);
return html;
}
/**
* Processes TikZ blocks in HTML content
*/
function processTikZBlocks(html: string): string {
// Only match code blocks with class 'language-tikz' or 'tikz'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-tikz|tikz)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
try {
return `<div class="tikz-block my-4">
<div class="tikz-diagram p-4 bg-green-50 dark:bg-green-900 rounded-lg border border-green-200 dark:border-green-700">
<div class="text-center text-green-600 dark:text-green-400 mb-2">
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
</svg>
TikZ Diagram
</div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show TikZ source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(content)}</code>
</pre>
</details>
</div>
</div>`;
} catch (error) {
console.warn("Failed to process TikZ block:", error);
return match;
}
},
);
// Fallback: match <pre> blocks whose content starts with \begin{tikzpicture} or contains tikz
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
const lines = content.trim().split("\n");
if (
lines[0].trim().startsWith("\\begin{tikzpicture}") ||
content.includes("tikz")
) {
try {
return `<div class="tikz-block my-4">
<div class="tikz-diagram p-4 bg-green-50 dark:bg-green-900 rounded-lg border border-green-200 dark:border-green-700">
<div class="text-center text-green-600 dark:text-green-400 mb-2">
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
</svg>
TikZ Diagram
</div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show TikZ source
</summary>
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto">
<code>${escapeHtml(content)}</code>
</pre>
</details>
</div>
</div>`;
} catch (error) {
console.warn("Failed to process TikZ fallback block:", error);
return match;
}
}
return match;
},
);
return html;
}
/**
* Escapes HTML characters for safe display
*/
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}

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

@ -1,13 +1,30 @@
import { parseBasicmarkup } from './basicMarkupParser'; import { parseBasicmarkup } from "./basicMarkupParser.ts";
import hljs from 'highlight.js'; import hljs from "highlight.js";
import 'highlight.js/lib/common'; // Import common languages import "highlight.js/lib/common"; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only import "highlight.js/styles/github-dark.css"; // Dark theme only
// Register common languages // Register common languages
hljs.configure({ hljs.configure({
ignoreUnescapedHTML: true ignoreUnescapedHTML: true,
}); });
// Escapes HTML characters for safe display
function escapeHtml(text: string): string {
const div =
typeof document !== "undefined" ? document.createElement("div") : null;
if (div) {
div.textContent = text;
return div.innerHTML;
}
// Fallback for non-browser environments
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Regular expressions for advanced markup elements // Regular expressions for advanced markup elements
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;
@ -17,18 +34,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
const CODE_BLOCK_REGEX = /^```(\w*)$/; const CODE_BLOCK_REGEX = /^```(\w*)$/;
// LaTeX math regex patterns
// const INLINE_MATH_REGEX = /\$([^$\n]+?)\$/g;
// const DISPLAY_MATH_REGEX = /\$\$([\s\S]*?)\$\$/g;
// const LATEX_BLOCK_REGEX = /\\\[([\s\S]*?)\\\]/g;
// const LATEX_INLINE_REGEX = /\\\(([^)]+?)\\\)/g;
// Add regex for LaTeX display math environments (e.g., \begin{pmatrix}...\end{pmatrix})
// Improved regex: match optional whitespace/linebreaks before and after, and allow for indented environments
// const LATEX_ENV_BLOCK_REGEX =
// /(?:^|\n)\s*\\begin\{([a-zA-Z*]+)\}([\s\S]*?)\\end\{\1\}\s*(?=\n|$)/gm;
/** /**
* Process headings (both styles) * Process headings (both styles)
*/ */
function processHeadings(content: string): string { function processHeadings(content: string): string {
// Tailwind classes for each heading level // Tailwind classes for each heading level
const headingClasses = [ const headingClasses = [
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1 "text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h1
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2 "text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h2
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3 "text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h3
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4 "text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h4
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5 "text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h5
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6 "text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h6
]; ];
// Process ATX-style headings (# Heading) // Process ATX-style headings (# Heading)
@ -39,11 +66,14 @@ function processHeadings(content: string): string {
}); });
// Process Setext-style headings (Heading\n====) // Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { processedContent = processedContent.replace(
const headingLevel = level[0] === '=' ? 1 : 2; ALTERNATE_HEADING_REGEX,
(_, text, level) => {
const headingLevel = level[0] === "=" ? 1 : 2;
const classes = headingClasses[headingLevel - 1]; const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`; return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
}); },
);
return processedContent; return processedContent;
} }
@ -53,24 +83,25 @@ function processHeadings(content: string): string {
*/ */
function processTables(content: string): string { function processTables(content: string): string {
try { try {
if (!content) return ''; if (!content) return "";
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => { return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try { try {
// Split into rows and clean up // Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim()); const rows = match.split("\n").filter((row) => row.trim());
if (rows.length < 1) return match; if (rows.length < 1) return match;
// Helper to process a row into cells // Helper to process a row into cells
const processCells = (row: string): string[] => { const processCells = (row: string): string[] => {
return row return row
.split('|') .split("|")
.slice(1, -1) // Remove empty cells from start/end .slice(1, -1) // Remove empty cells from start/end
.map(cell => cell.trim()); .map((cell) => cell.trim());
}; };
// Check if second row is a delimiter row (only hyphens) // Check if second row is a delimiter row (only hyphens)
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); const hasHeader =
rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows // Extract header and body rows
let headerCells: string[] = []; let headerCells: string[] = [];
@ -91,33 +122,33 @@ function processTables(content: string): string {
// Add header if exists // Add header if exists
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";
} }
// Add body // Add body
html += '<tbody>\n'; html += "<tbody>\n";
bodyRows.forEach(row => { bodyRows.forEach((row) => {
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";
}); });
html += '</tbody>\n</table>\n</div>'; html += "</tbody>\n</table>\n</div>";
return html; return html;
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error processing table row:', e); console.error("Error processing table row:", e);
return match; return match;
} }
}); });
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processTables:', e); console.error("Error in processTables:", e);
return content; return content;
} }
} }
@ -126,8 +157,9 @@ function processTables(content: string): string {
* Process horizontal rules * Process horizontal rules
*/ */
function processHorizontalRules(content: string): string { function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX, return content.replace(
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">' HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">',
); );
} }
@ -136,7 +168,7 @@ function processHorizontalRules(content: string): string {
*/ */
function processFootnotes(content: string): string { function processFootnotes(content: string): string {
try { try {
if (!content) return ''; if (!content) return "";
// Collect all footnote definitions (but do not remove them from the text yet) // Collect all footnote definitions (but do not remove them from the text yet)
const footnotes = new Map<string, string>(); const footnotes = new Map<string, string>();
@ -146,15 +178,19 @@ function processFootnotes(content: string): string {
}); });
// Remove all footnote definition lines from the main content // Remove all footnote definition lines from the main content
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, ''); let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, "");
// Track all references to each footnote // Track all references to each footnote
const referenceOrder: { id: string, refNum: number, label: string }[] = []; const referenceOrder: { id: string; refNum: number; label: string }[] = [];
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...] const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
let globalRefNum = 1; let globalRefNum = 1;
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { processedContent = processedContent.replace(
FOOTNOTE_REFERENCE_REGEX,
(match, id) => {
if (!footnotes.has(id)) { if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`); console.warn(
`Footnote reference [^${id}] found but no definition exists`,
);
return match; return match;
} }
const refNum = globalRefNum++; const refNum = globalRefNum++;
@ -162,32 +198,37 @@ function processFootnotes(content: string): string {
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>`;
}); },
);
// Only render footnotes section if there are actual definitions and at least one reference // Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) { if (footnotes.size > 0 && referenceOrder.length > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n'; processedContent +=
'\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
// Only include each unique footnote once, in order of first reference // Only include each unique footnote once, in order of first reference
const seen = new Set<string>(); const seen = new Set<string>();
for (const { id, label } of referenceOrder) { for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue; if (seen.has(id)) continue;
seen.add(id); seen.add(id);
const text = footnotes.get(id) || ''; const text = footnotes.get(id) || "";
// List of backrefs for this footnote // List of backrefs for this footnote
const refs = referenceMap.get(id) || []; const refs = referenceMap.get(id) || [];
const backrefs = refs.map((num, i) => const backrefs = refs
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>` .map(
).join(' '); (num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`,
)
.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>";
} }
return processedContent; return processedContent;
} catch (error) { } catch (error) {
console.error('Error processing footnotes:', error); console.error("Error processing footnotes:", error);
return content; return content;
} }
} }
@ -202,9 +243,9 @@ function processBlockquotes(content: string): string {
return content.replace(blockquoteRegex, (match) => { return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks // Remove the '>' prefix from each line and preserve line breaks
const text = match const text = match
.split('\n') .split("\n")
.map(line => line.replace(/^>[ \t]?/, '')) .map((line) => line.replace(/^>[ \t]?/, ""))
.join('\n') .join("\n")
.trim(); .trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`; return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
@ -214,13 +255,16 @@ function processBlockquotes(content: string): string {
/** /**
* Process code blocks by finding consecutive code lines and preserving their content * Process code blocks by finding consecutive code lines and preserving their content
*/ */
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } { function processCodeBlocks(text: string): {
const lines = text.split('\n'); text: string;
blocks: Map<string, string>;
} {
const lines = text.split("\n");
const processedLines: string[] = []; const processedLines: string[] = [];
const blocks = new Map<string, string>(); const blocks = new Map<string, string>();
let inCodeBlock = false; let inCodeBlock = false;
let currentCode: string[] = []; let currentCode: string[] = [];
let currentLanguage = ''; let currentLanguage = "";
let blockCount = 0; let blockCount = 0;
let lastWasCodeBlock = false; let lastWasCodeBlock = false;
@ -239,36 +283,39 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
// Ending current code block // Ending current code block
blockCount++; blockCount++;
const id = `CODE_BLOCK_${blockCount}`; const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n'); const code = currentCode.join("\n");
// Try to format JSON if specified // Try to format JSON if specified
let formattedCode = code; let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') { if (currentLanguage.toLowerCase() === "json") {
try { try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2); formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) { } catch {
formattedCode = code; formattedCode = code;
} }
} }
blocks.set(id, JSON.stringify({ blocks.set(
id,
JSON.stringify({
code: formattedCode, code: formattedCode,
language: currentLanguage, language: currentLanguage,
raw: true raw: true,
})); }),
);
processedLines.push(''); // Add spacing before code block processedLines.push(""); // Add spacing before code block
processedLines.push(id); processedLines.push(id);
processedLines.push(''); // Add spacing after code block processedLines.push(""); // Add spacing after code block
inCodeBlock = false; inCodeBlock = false;
currentCode = []; currentCode = [];
currentLanguage = ''; currentLanguage = "";
} }
} else if (inCodeBlock) { } else if (inCodeBlock) {
currentCode.push(line); currentCode.push(line);
} else { } else {
if (lastWasCodeBlock && line.trim()) { if (lastWasCodeBlock && line.trim()) {
processedLines.push(''); processedLines.push("");
lastWasCodeBlock = false; lastWasCodeBlock = false;
} }
processedLines.push(line); processedLines.push(line);
@ -279,31 +326,34 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
if (inCodeBlock && currentCode.length > 0) { if (inCodeBlock && currentCode.length > 0) {
blockCount++; blockCount++;
const id = `CODE_BLOCK_${blockCount}`; const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n'); const code = currentCode.join("\n");
// Try to format JSON if specified // Try to format JSON if specified
let formattedCode = code; let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') { if (currentLanguage.toLowerCase() === "json") {
try { try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2); formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) { } catch {
formattedCode = code; formattedCode = code;
} }
} }
blocks.set(id, JSON.stringify({ blocks.set(
id,
JSON.stringify({
code: formattedCode, code: formattedCode,
language: currentLanguage, language: currentLanguage,
raw: true raw: true,
})); }),
processedLines.push(''); );
processedLines.push("");
processedLines.push(id); processedLines.push(id);
processedLines.push(''); processedLines.push("");
} }
return { return {
text: processedLines.join('\n'), text: processedLines.join("\n"),
blocks blocks,
}; };
} }
@ -322,12 +372,12 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
try { try {
const highlighted = hljs.highlight(code, { const highlighted = hljs.highlight(code, {
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>`;
@ -335,55 +385,346 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
result = result.replace(id, html); result = result.replace(id, html);
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error restoring code block:', e); console.error("Error restoring code block:", e);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>'); result = result.replace(
id,
'<pre class="code-block"><code class="hljs">Error processing code block</code></pre>',
);
} }
} }
return result; return result;
} }
/**
* Process $...$ and $$...$$ math blocks: render as LaTeX if recognized, otherwise as AsciiMath
* This must run BEFORE any paragraph or inline code formatting.
*/
function processDollarMath(content: string): string {
// Display math: $$...$$ (multi-line, not empty)
content = content.replace(/\$\$([\s\S]*?\S[\s\S]*?)\$\$/g, (_match, expr) => {
if (isLaTeXContent(expr)) {
return `<div class="math-block">$$${expr}$$</div>`;
} else {
// Strip all $ or $$ from AsciiMath
const clean = expr.replace(/\$+/g, "").trim();
return `<div class="math-block" data-math-type="asciimath">${clean}</div>`;
}
});
// 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 LaTeX math expressions only within inline code blocks
*/
function processMathExpressions(content: string): string {
// Only process LaTeX within inline code blocks (backticks)
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 as regular inline code
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>`;
}
});
}
/**
* 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));
}
/** /**
* Parse markup text with advanced formatting * Parse markup text with advanced formatting
*/ */
export async function parseAdvancedmarkup(text: string): Promise<string> { export async function parseAdvancedmarkup(text: string): Promise<string> {
if (!text) return ''; if (!text) return "";
try { try {
// Step 1: Extract and save code blocks first // Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text); const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode; let processedText = withoutCode;
// Step 2: Process block-level elements // Step 2: Process $...$ and $$...$$ math blocks (LaTeX or AsciiMath)
processedText = processDollarMath(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)
processedText = processTables(processedText); processedText = processTables(processedText);
processedText = processBlockquotes(processedText); processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText); processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText); processedText = processHorizontalRules(processedText);
// Process inline elements // Step 5: Process footnotes (only references, not definitions)
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code
.trim()
.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>`;
});
// Process footnotes (only references, not definitions)
processedText = processFootnotes(processedText); processedText = processFootnotes(processedText);
// Process basic markup (which will also handle Nostr identifiers) // Step 6: Process basic markup (which will also handle Nostr identifiers)
// This includes paragraphs, inline code, links, lists, etc.
processedText = await parseBasicmarkup(processedText); processedText = await parseBasicmarkup(processedText);
// Step 3: Restore code blocks // Step 7: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks); processedText = restoreCodeBlocks(processedText, blocks);
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>`;
} }
} }

202
src/lib/utils/markup/asciidoctorExtensions.ts

@ -0,0 +1,202 @@
// deno-lint-ignore-file no-this-alias no-explicit-any
import Processor from "asciidoctor";
import { renderTikZ } from "./tikzRenderer.ts";
// Simple PlantUML rendering using PlantUML server
function renderPlantUML(content: string): string {
// Encode content for PlantUML server
const encoded = btoa(unescape(encodeURIComponent(content)));
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
return `<img src="${plantUMLUrl}" alt="PlantUML diagram" class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
/**
* Creates Asciidoctor extensions for advanced content rendering
* including Asciimath/Latex, PlantUML, BPMN, and TikZ
*/
export function createAdvancedExtensions(): any {
const Asciidoctor = Processor();
const extensions = Asciidoctor.Extensions.create();
// Math rendering extension (Asciimath/Latex)
extensions.treeProcessor(function (this: any) {
const dsl = this;
dsl.process(function (this: any, document: any) {
const treeProcessor = this;
processMathBlocks(treeProcessor, document);
});
});
// PlantUML rendering extension
extensions.treeProcessor(function (this: any) {
const dsl = this;
dsl.process(function (this: any, document: any) {
const treeProcessor = this;
processPlantUMLBlocks(treeProcessor, document);
});
});
// TikZ rendering extension
extensions.treeProcessor(function (this: any) {
const dsl = this;
dsl.process(function (this: any, document: any) {
const treeProcessor = this;
processTikZBlocks(treeProcessor, document);
});
});
// --- NEW: Support [plantuml], [tikz], [bpmn] as source blocks ---
// Helper to register a block for a given name and treat it as a source block
function registerDiagramBlock(name: string) {
extensions.block(name, function (this: any) {
const self = this;
self.process(function (parent: any, reader: any, attrs: any) {
// Read the block content
const lines = reader.getLines();
// Create a source block with the correct language and lang attributes
const block = self.createBlock(parent, "source", lines, {
...attrs,
language: name,
lang: name,
style: "source",
role: name,
});
block.setAttribute("language", name);
block.setAttribute("lang", name);
block.setAttribute("style", "source");
block.setAttribute("role", name);
block.setOption("source", true);
block.setOption("listing", true);
block.setStyle("source");
return block;
});
});
}
registerDiagramBlock("plantuml");
registerDiagramBlock("tikz");
registerDiagramBlock("bpmn");
// --- END NEW ---
return extensions;
}
/**
* Processes math blocks (stem blocks) and converts them to rendered HTML
*/
function processMathBlocks(_: any, document: any): void {
const blocks = document.getBlocks();
for (const block of blocks) {
if (block.getContext() === "stem") {
const content = block.getContent();
if (content) {
try {
// Output as a single div with delimiters for MathJax
const rendered = `<div class="math-block">$$${content}$$</div>`;
block.setContent(rendered);
} catch (error) {
console.warn("Failed to render math:", error);
}
}
}
// Inline math: context 'inline' and style 'stem' or 'latexmath'
if (
block.getContext() === "inline" &&
(block.getStyle() === "stem" || block.getStyle() === "latexmath")
) {
const content = block.getContent();
if (content) {
try {
const rendered = `<span class="math-inline">$${content}$</span>`;
block.setContent(rendered);
} catch (error) {
console.warn("Failed to render inline math:", error);
}
}
}
}
}
/**
* Processes PlantUML blocks and converts them to rendered SVG
*/
function processPlantUMLBlocks(_: any, document: any): void {
const blocks = document.getBlocks();
for (const block of blocks) {
if (block.getContext() === "listing" && isPlantUMLBlock(block)) {
const content = block.getContent();
if (content) {
try {
// Use simple PlantUML rendering
const rendered = renderPlantUML(content);
// Replace the block content with the image
block.setContent(rendered);
} catch (error) {
console.warn("Failed to render PlantUML:", error);
// Keep original content if rendering fails
}
}
}
}
}
/**
* Processes TikZ blocks and converts them to rendered SVG
*/
function processTikZBlocks(_: any, document: any): void {
const blocks = document.getBlocks();
for (const block of blocks) {
if (block.getContext() === "listing" && isTikZBlock(block)) {
const content = block.getContent();
if (content) {
try {
// Render TikZ to SVG
const svg = renderTikZ(content);
// Replace the block content with the SVG
block.setContent(svg);
} catch (error) {
console.warn("Failed to render TikZ:", error);
// Keep original content if rendering fails
}
}
}
}
}
/**
* Checks if a block contains PlantUML content
*/
function isPlantUMLBlock(block: any): boolean {
const content = block.getContent() || "";
const lines = content.split("\n");
// Check for PlantUML indicators
return lines.some(
(line: string) =>
line.trim().startsWith("@startuml") ||
line.trim().startsWith("@start") ||
line.includes("plantuml") ||
line.includes("uml"),
);
}
/**
* Checks if a block contains TikZ content
*/
function isTikZBlock(block: any): boolean {
const content = block.getContent() || "";
const lines = content.split("\n");
// Check for TikZ indicators
return lines.some(
(line: string) =>
line.trim().startsWith("\\begin{tikzpicture}") ||
line.trim().startsWith("\\tikz") ||
line.includes("tikzpicture") ||
line.includes("tikz"),
);
}

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

@ -0,0 +1,133 @@
import { processNostrIdentifiers } from "../nostrUtils";
/**
* 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, "");
}
/**
* 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
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${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}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}
/**
* Processes nostr addresses in HTML content, but skips addresses that are
* already within hyperlink tags.
*/
async function processNostrAddresses(html: string): Promise<string> {
// Helper to check if a match is within an existing <a> tag
function isWithinLink(text: string, index: number): boolean {
// Look backwards from the match position to find the nearest <a> tag
const before = text.slice(0, index);
const lastOpenTag = before.lastIndexOf("<a");
const lastCloseTag = before.lastIndexOf("</a>");
// If we find an opening <a> tag after the last closing </a> tag, we're inside a link
return lastOpenTag > lastCloseTag;
}
// Process nostr addresses that are not within existing links
const nostrPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedHtml = html;
// Find all nostr addresses
const matches = Array.from(processedHtml.matchAll(nostrPattern));
// 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 already within a link
if (isWithinLink(processedHtml, matchIndex)) {
continue;
}
// Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch);
// Replace the match in the HTML
processedHtml =
processedHtml.slice(0, matchIndex) +
processedMatch +
processedHtml.slice(matchIndex + fullMatch.length);
}
return processedHtml;
}
/**
* Fixes AsciiDoctor stem blocks for MathJax rendering.
* Joins split spans and wraps content in $$...$$ for block math.
*/
function fixStemBlocks(html: string): string {
// Replace <div class="stemblock"><div class="content"><span>$</span>...<span>$</span></div></div>
// with <div class="stemblock"><div class="content">$$...$$</div></div>
return html.replace(
/<div class="stemblock">\s*<div class="content">\s*<span>\$<\/span>([\s\S]*?)<span>\$<\/span>\s*<\/div>\s*<\/div>/g,
(_match, mathContent) => {
// Remove any extra tags inside mathContent
const cleanMath = mathContent.replace(/<\/?span[^>]*>/g, "").trim();
return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`;
},
);
}
/**
* Post-processes asciidoctor HTML output to add wikilink and nostr address rendering.
* This function should be called after asciidoctor.convert() to enhance the HTML output.
*/
export async function postProcessAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html;
try {
// First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain)
processedHtml = replaceWikilinks(processedHtml);
// Then process nostr addresses (but not those already in links)
processedHtml = await processNostrAddresses(processedHtml);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
return processedHtml;
} catch (error) {
console.error("Error in postProcessAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails
}
}

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

@ -1,6 +1,6 @@
import { processNostrIdentifiers } from '../nostrUtils'; import { processNostrIdentifiers } from "../nostrUtils.ts";
import * as emoji from 'node-emoji'; import * as emoji from "node-emoji";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
/* Regex constants for basic markup parsing */ /* Regex constants for basic markup parsing */
@ -23,19 +23,23 @@ const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/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 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; 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 {
// Regex for Alexandria/localhost URLs // Regex for Alexandria/localhost URLs
const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; const alexandriaPattern =
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers // Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex // Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/; const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links // 1. Alexandria/localhost markup links
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => { text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) { if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match; if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern); const hexMatch = url.match(hexPattern);
@ -53,7 +57,8 @@ function replaceAlexandriaNostrLinks(text: string): string {
} }
} }
return match; return match;
}); },
);
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers // 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
@ -96,12 +101,18 @@ function replaceAlexandriaNostrLinks(text: string): string {
// Utility to strip tracking parameters from URLs // Utility to strip tracking parameters from URLs
function stripTrackingParams(url: string): string { function stripTrackingParams(url: string): string {
// List of tracking params to remove // List of tracking params to remove
const trackingParams = [/^utm_/i, /^fbclid$/i, /^gclid$/i, /^tracking$/i, /^ref$/i]; const trackingParams = [
/^utm_/i,
/^fbclid$/i,
/^gclid$/i,
/^tracking$/i,
/^ref$/i,
];
try { try {
// Absolute URL // Absolute URL
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
const parsed = new URL(url); const parsed = new URL(url);
trackingParams.forEach(pattern => { trackingParams.forEach((pattern) => {
for (const key of Array.from(parsed.searchParams.keys())) { for (const key of Array.from(parsed.searchParams.keys())) {
if (pattern.test(key)) { if (pattern.test(key)) {
parsed.searchParams.delete(key); parsed.searchParams.delete(key);
@ -109,19 +120,24 @@ function stripTrackingParams(url: string): string {
} }
}); });
const queryString = parsed.searchParams.toString(); const queryString = parsed.searchParams.toString();
return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || ''); return (
parsed.origin +
parsed.pathname +
(queryString ? "?" + queryString : "") +
(parsed.hash || "")
);
} else { } else {
// Relative URL: parse query string manually // Relative URL: parse query string manually
const [path, queryAndHash = ''] = url.split('?'); const [path, queryAndHash = ""] = url.split("?");
const [query = '', hash = ''] = queryAndHash.split('#'); const [query = "", hash = ""] = queryAndHash.split("#");
if (!query) return url; if (!query) return url;
const params = query.split('&').filter(Boolean); const params = query.split("&").filter(Boolean);
const filtered = params.filter(param => { const filtered = params.filter((param) => {
const [key] = param.split('='); const [key] = param.split("=");
return !trackingParams.some(pattern => pattern.test(key)); return !trackingParams.some((pattern) => pattern.test(key));
}); });
const queryString = filtered.length ? '?' + filtered.join('&') : ''; const queryString = filtered.length ? "?" + filtered.join("&") : "";
const hashString = hash ? '#' + hash : ''; const hashString = hash ? "#" + hash : "";
return path + queryString + hashString; return path + queryString + hashString;
} }
} catch { } catch {
@ -132,38 +148,45 @@ function stripTrackingParams(url: string): string {
function normalizeDTag(input: string): string { function normalizeDTag(input: string): string {
return input return input
.toLowerCase() .toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, '-') .replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, '-') .replace(/-+/g, "-")
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, "");
} }
function replaceWikilinks(text: string): string { function replaceWikilinks(text: string): string {
// [[target page]] or [[target page|display text]] // [[target page]] or [[target page|display text]]
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim()); const normalized = normalizeDTag(target.trim());
const display = (label || target).trim(); const display = (label || target).trim();
const url = `./wiki?d=${normalized}`; const url = `./events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors // Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
}); },
);
} }
function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string { function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList(start: number, indent: number, type: 'ol' | 'ul'): [string, number] { function parseList(
let html = ''; start: number,
indent: number,
type: "ol" | "ul",
): [string, number] {
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]+(.*)$/);
if (!match) break; if (!match) break;
const lineIndent = match[1].replace(/\t/g, ' ').length; const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]); const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? 'ol' : 'ul'; const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) { if (lineIndent > indent) {
// Nested list // Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, '') + nestedHtml + '</li>'; html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed; i = consumed;
continue; continue;
} }
@ -175,32 +198,36 @@ function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string {
if (i + 1 < lines.length) { if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) { if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, ' ').length; const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? 'ol' : 'ul'; const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) { if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(i + 1, nextIndent, nextType); const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml; html += nestedHtml;
i = consumed - 1; i = consumed - 1;
} }
} }
} }
html += '</li>'; html += "</li>";
i++; i++;
} }
html += `</${type}>`; html += `</${type}>`;
return [html, i]; return [html, i];
} }
if (!lines.length) return ''; if (!lines.length) return "";
const firstLine = lines[0]; const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, ' ').length : 0; const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? 'ol' : 'ul'); const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type); const [html] = parseList(0, indent, type);
return html; return html;
} }
function processBasicFormatting(content: string): string { function processBasicFormatting(content: string): string {
if (!content) return ''; if (!content) return "";
let processedText = content; let processedText = content;
@ -209,22 +236,22 @@ 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); url = stripTrackingParams(url);
if (YOUTUBE_URL_REGEX.test(url)) { if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = extractYouTubeVideoId(url); const videoId = extractYouTubeVideoId(url);
if (videoId) { if (videoId) {
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>`; 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)) { 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>`; 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)) { if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`; 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 // Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split('?')[0])) { 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">`; 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 // Otherwise, render as a clickable link
@ -232,19 +259,21 @@ function processBasicFormatting(content: string): string {
}); });
// Process markup links // Process markup links
processedText = processedText.replace(MARKUP_LINK, (match, text, url) => processedText = processedText.replace(
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>` 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 // Process WebSocket URLs
processedText = processedText.replace(WSS_URL, match => { processedText = processedText.replace(WSS_URL, (match) => {
// Remove 'wss://' from the start and any trailing slashes // Remove 'wss://' from the start and any trailing slashes
const cleanUrl = match.slice(6).replace(/\/+$/, ''); 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>`; 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); const clean = stripTrackingParams(match);
if (YOUTUBE_URL_REGEX.test(clean)) { if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean); const videoId = extractYouTubeVideoId(clean);
@ -259,7 +288,7 @@ function processBasicFormatting(content: string): string {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`; 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 // Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) { 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">`; 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 // Otherwise, render as a clickable link
@ -267,22 +296,28 @@ function processBasicFormatting(content: string): string {
}); });
// Process text formatting // Process text formatting
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>'); processedText = processedText.replace(BOLD_REGEX, "<strong>$2</strong>");
processedText = processedText.replace(ITALIC_REGEX, match => { processedText = processedText.replace(ITALIC_REGEX, (match) => {
const text = match.replace(/^_+|_+$/g, ''); const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`; return `<em>${text}</em>`;
}); });
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { processedText = processedText.replace(
STRIKETHROUGH_REGEX,
(_match, doubleText, singleText) => {
const text = doubleText || singleText; const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`; return `<del class="line-through">${text}</del>`;
}); },
);
// Process hashtags // Process hashtags
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-primary-600 dark:text-primary-500">#$1</span>'); processedText = processedText.replace(
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");
let output = ''; let output = "";
let buffer: string[] = []; let buffer: string[] = [];
let inList = false; let inList = false;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
@ -294,23 +329,22 @@ function processBasicFormatting(content: string): string {
if (inList) { if (inList) {
const firstLine = buffer[0]; const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = []; buffer = [];
inList = false; inList = false;
} }
output += (output && !output.endsWith('\n') ? '\n' : '') + line + '\n'; output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
} }
} }
if (buffer.length) { if (buffer.length) {
const firstLine = buffer[0]; const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
} }
processedText = output; processedText = output;
// --- End Improved List Grouping and Parsing --- // --- End Improved List Grouping and Parsing ---
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processBasicFormatting:', e); console.error("Error in processBasicFormatting:", e);
} }
return processedText; return processedText;
@ -318,43 +352,47 @@ function processBasicFormatting(content: string): string {
// Helper function to extract YouTube video ID // Helper function to extract YouTube video ID
function extractYouTubeVideoId(url: string): string | null { 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})/); 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; return match ? match[1] : null;
} }
function processBlockquotes(content: string): string { function processBlockquotes(content: string): string {
try { try {
if (!content) return ''; if (!content) return "";
return content.replace(BLOCKQUOTE_REGEX, match => { return content.replace(BLOCKQUOTE_REGEX, (match) => {
const lines = match.split('\n').map(line => { const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, '').trim(); return line.replace(/^[ \t]*>[ \t]?/, "").trim();
}); });
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${ return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${lines.join(
lines.join('\n') "\n",
}</blockquote>`; )}</blockquote>`;
}); });
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processBlockquotes:', e); console.error("Error in processBlockquotes:", e);
return content; return content;
} }
} }
function processEmojiShortcuts(content: string): string { function processEmojiShortcuts(content: string): string {
try { try {
return emoji.emojify(content, { fallback: (name: string) => { return emoji.emojify(content, {
fallback: (name: string) => {
const emojiChar = emoji.get(name); const emojiChar = emoji.get(name);
return emojiChar || `:${name}:`; return emojiChar || `:${name}:`;
}}); },
});
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processEmojiShortcuts:', e); console.error("Error in processEmojiShortcuts:", e);
return content; return content;
} }
} }
export async function parseBasicmarkup(text: string): Promise<string> { export async function parseBasicmarkup(text: string): Promise<string> {
if (!text) return ''; if (!text) return "";
try { try {
// Process basic text formatting first // Process basic text formatting first
@ -367,12 +405,23 @@ export async function parseBasicmarkup(text: string): Promise<string> {
processedText = processBlockquotes(processedText); processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags // Process paragraphs - split by double newlines and wrap in p tags
// Skip wrapping if content already contains block-level elements
processedText = processedText processedText = processedText
.split(/\n\n+/) .split(/\n\n+/)
.map(para => para.trim()) .map((para) => para.trim())
.filter(para => para.length > 0) .filter((para) => para.length > 0)
.map(para => `<p class="my-4">${para}</p>`) .map((para) => {
.join('\n'); // Skip wrapping if para already contains block-level elements or math blocks
if (
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test(
para,
)
) {
return para;
}
return `<p class="my-4">${para}</p>`;
})
.join("\n");
// Process Nostr identifiers last // Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText); processedText = await processNostrIdentifiers(processedText);
@ -382,7 +431,7 @@ export async function parseBasicmarkup(text: string): Promise<string> {
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>`;
} }
} }

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

@ -0,0 +1,60 @@
/**
* TikZ renderer using node-tikzjax
* Converts TikZ LaTeX code to SVG for browser rendering
*/
// We'll use a simple approach for now since node-tikzjax might not be available
// This is a placeholder implementation that can be enhanced later
export function renderTikZ(tikzCode: string): string {
try {
// For now, we'll create a simple SVG placeholder
// In a full implementation, this would use node-tikzjax or similar library
// Extract TikZ content and create a basic SVG
const svgContent = createBasicSVG(tikzCode);
return svgContent;
} catch (error) {
console.error("Failed to render TikZ:", error);
return `<div class="tikz-error text-red-500 p-4 border border-red-300 rounded">
<p class="font-bold">TikZ Rendering Error</p>
<p class="text-sm">Failed to render TikZ diagram. Original code:</p>
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-x-auto">${tikzCode}</pre>
</div>`;
}
}
/**
* Creates a basic SVG placeholder for TikZ content
* This is a temporary implementation until proper TikZ rendering is available
*/
function createBasicSVG(tikzCode: string): string {
// Create a simple SVG with the TikZ code as text
const width = 400;
const height = 300;
return `<svg width="${width}" height="${height}" class="tikz-diagram max-w-full h-auto rounded-lg shadow-lg my-4" viewBox="0 0 ${width} ${height}">
<rect width="${width}" height="${height}" fill="white" stroke="#ccc" stroke-width="1"/>
<text x="10" y="20" font-family="monospace" font-size="12" fill="#666">
TikZ Diagram
</text>
<text x="10" y="40" font-family="monospace" font-size="10" fill="#999">
(Rendering not yet implemented)
</text>
<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;">
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(tikzCode)}</pre>
</div>
</foreignObject>
</svg>`;
}
/**
* Escapes HTML characters for safe display
*/
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}

33
src/lib/utils/mime.ts

@ -1,3 +1,5 @@
import { EVENT_KINDS } from "./search_constants";
/** /**
* Determine the type of Nostr event based on its kind number * Determine the type of Nostr event based on its kind number
* Following NIP specification for kind ranges: * Following NIP specification for kind ranges:
@ -6,22 +8,34 @@
* - Addressable: 30000-39999 (latest per d-tag stored) * - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays) * - Regular: all other kinds (stored by relays)
*/ */
export function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { export function getEventType(
kind: number,
): "regular" | "replaceable" | "ephemeral" | "addressable" {
// Check special ranges first // Check special ranges first
if (kind >= 30000 && kind < 40000) { if (
return 'addressable'; kind >= EVENT_KINDS.ADDRESSABLE.MIN &&
kind < EVENT_KINDS.ADDRESSABLE.MAX
) {
return "addressable";
} }
if (kind >= 20000 && kind < 30000) { if (
return 'ephemeral'; kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN &&
kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX
) {
return "ephemeral";
} }
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { if (
return 'replaceable'; (kind >= EVENT_KINDS.REPLACEABLE.MIN &&
kind < EVENT_KINDS.REPLACEABLE.MAX) ||
EVENT_KINDS.REPLACEABLE.SPECIFIC.includes(kind as 0 | 3)
) {
return "replaceable";
} }
// Everything else is regular // Everything else is regular
return 'regular'; return "regular";
} }
/** /**
@ -36,7 +50,8 @@ export function getMimeTags(kind: number): [string, string][] {
// Determine replaceability based on event type // Determine replaceability based on event type
const eventType = getEventType(kind); const eventType = getEventType(kind);
const replaceability = (eventType === 'replaceable' || eventType === 'addressable') const replaceability =
eventType === "replaceable" || eventType === "addressable"
? "replaceable" ? "replaceable"
: "nonreplaceable"; : "nonreplaceable";

188
src/lib/utils/network_detection.ts

@ -0,0 +1,188 @@
import { deduplicateRelayUrls } from "./relay_management.ts";
/**
* Network conditions for relay selection
*/
export enum NetworkCondition {
ONLINE = 'online',
SLOW = 'slow',
OFFLINE = 'offline'
}
/**
* Network connectivity test endpoints
*/
const NETWORK_ENDPOINTS = [
'https://www.google.com/favicon.ico',
'https://httpbin.org/status/200',
'https://api.github.com/zen'
];
/**
* Detects if the network is online using more reliable endpoints
* @returns Promise that resolves to true if online, false otherwise
*/
export async function isNetworkOnline(): Promise<boolean> {
for (const endpoint of NETWORK_ENDPOINTS) {
try {
// Use a simple fetch without HEAD method to avoid CORS issues
await fetch(endpoint, {
method: 'GET',
cache: 'no-cache',
signal: AbortSignal.timeout(3000),
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
return true;
} catch (error) {
console.debug(`[network_detection.ts] Failed to reach ${endpoint}:`, error);
continue;
}
}
console.debug('[network_detection.ts] All network endpoints failed');
return false;
}
/**
* Tests network speed by measuring response time
* @returns Promise that resolves to network speed in milliseconds
*/
export async function testNetworkSpeed(): Promise<number> {
const startTime = performance.now();
for (const endpoint of NETWORK_ENDPOINTS) {
try {
await fetch(endpoint, {
method: 'GET',
cache: 'no-cache',
signal: AbortSignal.timeout(5000),
mode: 'no-cors' // Use no-cors mode to avoid CORS issues
});
const endTime = performance.now();
return endTime - startTime;
} catch (error) {
console.debug(`[network_detection.ts] Speed test failed for ${endpoint}:`, error);
continue;
}
}
console.debug('[network_detection.ts] Network speed test failed for all endpoints');
return Infinity; // Very slow if it fails
}
/**
* Determines network condition based on connectivity and speed
* @returns Promise that resolves to NetworkCondition
*/
export async function detectNetworkCondition(): Promise<NetworkCondition> {
const isOnline = await isNetworkOnline();
if (!isOnline) {
console.debug('[network_detection.ts] Network condition: OFFLINE');
return NetworkCondition.OFFLINE;
}
const speed = await testNetworkSpeed();
// Consider network slow if response time > 2000ms
if (speed > 2000) {
console.debug(`[network_detection.ts] Network condition: SLOW (${speed.toFixed(0)}ms)`);
return NetworkCondition.SLOW;
}
console.debug(`[network_detection.ts] Network condition: ONLINE (${speed.toFixed(0)}ms)`);
return NetworkCondition.ONLINE;
}
/**
* Gets the appropriate relay sets based on network condition
* @param networkCondition The detected network condition
* @param discoveredLocalRelays Array of discovered local relay URLs
* @param lowbandwidthRelays Array of low bandwidth relay URLs
* @param fullRelaySet The complete relay set for normal conditions
* @returns Object with inbox and outbox relay arrays
*/
export function getRelaySetForNetworkCondition(
networkCondition: NetworkCondition,
discoveredLocalRelays: string[],
lowbandwidthRelays: string[],
fullRelaySet: { inboxRelays: string[]; outboxRelays: string[] }
): { inboxRelays: string[]; outboxRelays: string[] } {
switch (networkCondition) {
case NetworkCondition.OFFLINE:
// When offline, use local relays if available, otherwise rely on cache
// This will be improved when IndexedDB local relay is implemented
if (discoveredLocalRelays.length > 0) {
console.debug('[network_detection.ts] Using local relays (offline)');
return {
inboxRelays: discoveredLocalRelays,
outboxRelays: discoveredLocalRelays
};
} else {
console.debug('[network_detection.ts] No local relays available, will rely on cache (offline)');
return {
inboxRelays: [],
outboxRelays: []
};
}
case NetworkCondition.SLOW: {
// Local relays + low bandwidth relays when slow (deduplicated)
console.debug('[network_detection.ts] Using local + low bandwidth relays (slow network)');
const slowInboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...lowbandwidthRelays]);
const slowOutboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...lowbandwidthRelays]);
return {
inboxRelays: slowInboxRelays,
outboxRelays: slowOutboxRelays
};
}
case NetworkCondition.ONLINE:
default:
// Full relay set when online
console.debug('[network_detection.ts] Using full relay set (online)');
return fullRelaySet;
}
}
/**
* Starts periodic network monitoring with reduced frequency to avoid spam
* @param onNetworkChange Callback function called when network condition changes
* @param checkInterval Interval in milliseconds between network checks (default: 60 seconds)
* @returns Function to stop the monitoring
*/
export function startNetworkMonitoring(
onNetworkChange: (condition: NetworkCondition) => void,
checkInterval: number = 60000 // Increased to 60 seconds to reduce spam
): () => void {
let lastCondition: NetworkCondition | null = null;
let intervalId: number | null = null;
const checkNetwork = async () => {
try {
const currentCondition = await detectNetworkCondition();
if (currentCondition !== lastCondition) {
console.debug(`[network_detection.ts] Network condition changed: ${lastCondition} -> ${currentCondition}`);
lastCondition = currentCondition;
onNetworkChange(currentCondition);
}
} catch (error) {
console.warn('[network_detection.ts] Network monitoring error:', error);
}
};
// Initial check
checkNetwork();
// Set up periodic monitoring
intervalId = globalThis.setInterval(checkNetwork, checkInterval);
// Return function to stop monitoring
return () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
}

444
src/lib/utils/nostrEventService.ts

@ -0,0 +1,444 @@
import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils.ts";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { EVENT_KINDS, TIME_CONSTANTS } from "./search_constants.ts";
import { ndkInstance } from "../ndk.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
export interface RootEventInfo {
rootId: string;
rootPubkey: string;
rootRelay: string;
rootKind: number;
rootAddress: string;
rootIValue: string;
rootIRelay: string;
isRootA: boolean;
isRootI: boolean;
}
export interface ParentEventInfo {
parentId: string;
parentPubkey: string;
parentRelay: string;
parentKind: number;
parentAddress: string;
}
export interface EventPublishResult {
success: boolean;
relay?: string;
eventId?: string;
error?: string;
}
/**
* Helper function to find a tag by its first element
*/
function findTag(tags: string[][], tagName: string): string[] | undefined {
return tags?.find((t: string[]) => t[0] === tagName);
}
/**
* Helper function to get tag value safely
*/
function getTagValue(
tags: string[][],
tagName: string,
index: number = 1,
): string {
const tag = findTag(tags, tagName);
return tag?.[index] || "";
}
/**
* Helper function to create a tag array
*/
function createTag(name: string, ...values: (string | number)[]): string[] {
return [name, ...values.map((v) => String(v))];
}
/**
* Helper function to add tags to an array
*/
function addTags(tags: string[][], ...newTags: string[][]): void {
tags.push(...newTags);
}
/**
* Extract root event information from parent event tags
*/
export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
const rootInfo: RootEventInfo = {
rootId: parent.id,
rootPubkey: getPubkeyString(parent.pubkey),
rootRelay: getRelayString(parent.relay),
rootKind: parent.kind || 1,
rootAddress: "",
rootIValue: "",
rootIRelay: "",
isRootA: false,
isRootI: false,
};
if (!parent.tags) return rootInfo;
const rootE = findTag(parent.tags, "E");
const rootA = findTag(parent.tags, "A");
const rootI = findTag(parent.tags, "I");
rootInfo.isRootA = !!rootA;
rootInfo.isRootI = !!rootI;
if (rootE) {
rootInfo.rootId = rootE[1];
rootInfo.rootRelay = getRelayString(rootE[2]);
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey);
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
} else if (rootA) {
rootInfo.rootAddress = rootA[1];
rootInfo.rootRelay = getRelayString(rootA[2]);
rootInfo.rootPubkey = getPubkeyString(
getTagValue(parent.tags, "P") || rootInfo.rootPubkey,
);
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
} else if (rootI) {
rootInfo.rootIValue = rootI[1];
rootInfo.rootIRelay = getRelayString(rootI[2]);
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
}
return rootInfo;
}
/**
* Extract parent event information
*/
export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
const dTag = getTagValue(parent.tags || [], "d");
const parentAddress = dTag
? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}`
: "";
return {
parentId: parent.id,
parentPubkey: getPubkeyString(parent.pubkey),
parentRelay: getRelayString(parent.relay),
parentKind: parent.kind || 1,
parentAddress,
};
}
/**
* Build root scope tags for NIP-22 threading
*/
function buildRootScopeTags(
rootInfo: RootEventInfo,
): string[][] {
const tags: string[][] = [];
if (rootInfo.rootAddress) {
const tagType = rootInfo.isRootA ? "A" : rootInfo.isRootI ? "I" : "E";
addTags(
tags,
createTag(
tagType,
rootInfo.rootAddress || rootInfo.rootId,
rootInfo.rootRelay,
),
);
} else if (rootInfo.rootIValue) {
addTags(tags, createTag("I", rootInfo.rootIValue, rootInfo.rootIRelay));
} else {
addTags(tags, createTag("E", rootInfo.rootId, rootInfo.rootRelay));
}
addTags(tags, createTag("K", rootInfo.rootKind));
if (rootInfo.rootPubkey && !rootInfo.rootIValue) {
addTags(tags, createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay));
}
return tags;
}
/**
* Build parent scope tags for NIP-22 threading
*/
function buildParentScopeTags(
parent: NDKEvent,
parentInfo: ParentEventInfo,
rootInfo: RootEventInfo,
): string[][] {
const tags: string[][] = [];
if (parentInfo.parentAddress) {
const tagType = rootInfo.isRootA ? "a" : rootInfo.isRootI ? "i" : "e";
addTags(
tags,
createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay),
);
}
addTags(
tags,
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
return tags;
}
/**
* Build tags for a reply event based on parent and root information
*/
export function buildReplyTags(
parent: NDKEvent,
rootInfo: RootEventInfo,
parentInfo: ParentEventInfo,
kind: number,
): string[][] {
const tags: string[][] = [];
const isParentReplaceable =
parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN &&
parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) {
// Kind 1 replies use simple e/p tags
addTags(
tags,
createTag("e", parent.id, parentInfo.parentRelay, "root"),
createTag("p", parentInfo.parentPubkey),
);
// Add address for replaceable events
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], "d");
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
addTags(tags, createTag("a", parentAddress, "", "root"));
}
}
} else {
// Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], "d");
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
if (isReplyToComment) {
// Root scope (uppercase) - use the original article
addTags(
tags,
createTag("A", parentAddress, parentInfo.parentRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
);
// Parent scope (lowercase) - the comment we're replying to
addTags(
tags,
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
// Top-level comment - root and parent are the same
addTags(
tags,
createTag("A", parentAddress, parentInfo.parentRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("a", parentAddress, parentInfo.parentRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
}
} else {
// Fallback to E/e tags if no d-tag found
if (isReplyToComment) {
addTags(
tags,
createTag("E", rootInfo.rootId, rootInfo.rootRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
addTags(
tags,
createTag("E", parent.id, rootInfo.rootRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
}
}
} else {
// For regular events, use E/e tags
if (isReplyToComment) {
// Reply to a comment - distinguish root from parent
addTags(tags, ...buildRootScopeTags(rootInfo));
addTags(
tags,
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
// Top-level comment or regular event
addTags(tags, ...buildRootScopeTags(rootInfo));
addTags(tags, ...buildParentScopeTags(parent, parentInfo, rootInfo));
}
}
}
return tags;
}
/**
* Create and sign a Nostr event
*/
export async function createSignedEvent(
content: string,
pubkey: string,
kind: number,
tags: string[][],
// deno-lint-ignore no-explicit-any
): Promise<{ id: string; sig: string; event: any }> {
const prefixedContent = prefixNostrAddresses(content);
const eventToSign = {
kind: Number(kind),
created_at: Number(
Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR),
),
tags: tags.map((tag) => [
String(tag[0]),
String(tag[1]),
String(tag[2] || ""),
String(tag[3] || ""),
]),
content: String(prefixedContent),
pubkey: pubkey,
};
let sig, id;
if (typeof window !== "undefined" && globalThis.nostr && globalThis.nostr.signEvent) {
const signed = await globalThis.nostr.signEvent(eventToSign);
sig = signed.sig as string;
id = "id" in signed ? (signed.id as string) : getEventHash(eventToSign);
} else {
id = getEventHash(eventToSign);
sig = await signEvent(eventToSign);
}
return {
id,
sig,
event: {
...eventToSign,
id,
sig,
},
};
}
/**
* Publishes an event to relays using the new relay management system
* @param event The event to publish (can be NDKEvent or plain event object)
* @param relayUrls Array of relay URLs to publish to
* @returns Promise that resolves to array of successful relay URLs
*/
export async function publishEvent(
event: NDKEvent,
relayUrls: string[],
): Promise<string[]> {
const successfulRelays: string[] = [];
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("NDK instance not available");
}
// Create relay set from URLs
const relaySet = NDKRelaySet.fromRelayUrls(relayUrls, ndk);
try {
// If event is a plain object, create an NDKEvent from it
let ndkEvent: NDKEvent;
if (event.publish && typeof event.publish === 'function') {
// It's already an NDKEvent
ndkEvent = event;
} else {
// It's a plain event object, create NDKEvent
ndkEvent = new NDKEvent(ndk, event);
}
// Publish with timeout
await ndkEvent.publish(relaySet).withTimeout(5000);
// For now, assume all relays were successful
// In a more sophisticated implementation, you'd track individual relay responses
successfulRelays.push(...relayUrls);
console.debug("[nostrEventService] Published event successfully:", {
eventId: ndkEvent.id,
relayCount: relayUrls.length,
successfulRelays
});
} catch (error) {
console.error("[nostrEventService] Failed to publish event:", error);
throw new Error(`Failed to publish event: ${error}`);
}
return successfulRelays;
}
/**
* Navigate to the published event
*/
export function navigateToEvent(eventId: string): void {
try {
// Validate that eventId is a valid hex string
if (!/^[0-9a-fA-F]{64}$/.test(eventId)) {
console.warn("Invalid event ID format:", eventId);
return;
}
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
} catch (error) {
console.error("Failed to encode event ID for navigation:", eventId, error);
}
}
// Helper functions to ensure relay and pubkey are always strings
// deno-lint-ignore no-explicit-any
function getRelayString(relay: any): string {
if (!relay) return "";
if (typeof relay === "string") return relay;
if (typeof relay.url === "string") return relay.url;
return "";
}
// deno-lint-ignore no-explicit-any
function getPubkeyString(pubkey: any): string {
if (!pubkey) return "";
if (typeof pubkey === "string") return pubkey;
if (typeof pubkey.hex === "function") return pubkey.hex();
if (typeof pubkey.pubkey === "string") return pubkey.pubkey;
return "";
}

470
src/lib/utils/nostrUtils.ts

@ -1,22 +1,29 @@
import { get } from 'svelte/store'; import { get } from "svelte/store";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "../ndk.ts";
import { npubCache } from './npubCache'; 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 { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; import type { NDKFilter, NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { communityRelays, secondaryRelays } from "../consts.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk'; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { sha256 } from '@noble/hashes/sha256'; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { schnorr } from '@noble/curves/secp256k1'; import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from '@noble/hashes/utils'; import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/hashes/utils";
const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>' import { wellKnownUrl } from "./search_utility.ts";
import { VALIDATION } from "./search_constants.ts";
const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12.4472 4.10557c-.2815-.14076-.6129-.14076-.8944 0L2.76981 8.49706l9.21949 4.39024L21 8.38195l-8.5528-4.27638Z"/><path d="M5 17.2222v-5.448l6.5701 3.1286c.278.1325.6016.1293.8771-.0084L19 11.618v5.6042c0 .2857-.1229.5583-.3364.7481l-.0025.0022-.0041.0036-.0103.009-.0119.0101-.0181.0152c-.024.02-.0562.0462-.0965.0776-.0807.0627-.1942.1465-.3405.2441-.2926.195-.7171.4455-1.2736.6928C15.7905 19.5208 14.1527 20 12 20c-2.15265 0-3.79045-.4792-4.90614-.9751-.5565-.2473-.98098-.4978-1.27356-.6928-.14631-.0976-.2598-.1814-.34049-.2441-.04036-.0314-.07254-.0576-.09656-.0776-.01201-.01-.02198-.0185-.02991-.0253l-.01038-.009-.00404-.0036-.00174-.0015-.0008-.0007s-.00004 0 .00978-.0112l-.00009-.0012-.01043.0117C5.12215 17.7799 5 17.5079 5 17.2222Zm-3-6.8765 2 .9523V17c0 .5523-.44772 1-1 1s-1-.4477-1-1v-6.6543Z"/></svg>';
const badgeCheckSvg =
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>';
const graduationCapSvg =
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12.4472 4.10557c-.2815-.14076-.6129-.14076-.8944 0L2.76981 8.49706l9.21949 4.39024L21 8.38195l-8.5528-4.27638Z"/><path d="M5 17.2222v-5.448l6.5701 3.1286c.278.1325.6016.1293.8771-.0084L19 11.618v5.6042c0 .2857-.1229.5583-.3364.7481l-.0025.0022-.0041.0036-.0103.009-.0119.0101-.0181.0152c-.024.02-.0562.0462-.0965.0776-.0807.0627-.1942.1465-.3405.2441-.2926.195-.7171.4455-1.2736.6928C15.7905 19.5208 14.1527 20 12 20c-2.15265 0-3.79045-.4792-4.90614-.9751-.5565-.2473-.98098-.4978-1.27356-.6928-.14631-.0976-.2598-.1814-.34049-.2441-.04036-.0314-.07254-.0576-.09656-.0776-.01201-.01-.02198-.0185-.02991-.0253l-.01038-.009-.00404-.0036-.00174-.0015-.0008-.0007s-.00004 0 .00978-.0112l-.00009-.0012-.01043.0117C5.12215 17.7799 5 17.5079 5 17.2222Zm-3-6.8765 2 .9523V17c0 .5523-.44772 1-1 1s-1-.4477-1-1v-6.6543Z"/></svg>';
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix // Regular expressions for Nostr identifiers - match the entire identifier including any prefix
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; export const NOSTR_PROFILE_REGEX =
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX =
/(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile { export interface NostrProfile {
name?: string; name?: string;
@ -34,24 +41,31 @@ export interface NostrProfile {
*/ */
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = { const htmlEscapes: { [key: string]: string } = {
'&': '&amp;', "&": "&amp;",
'<': '&lt;', "<": "&lt;",
'>': '&gt;', ">": "&gt;",
'"': '&quot;', '"': "&quot;",
"'": '&#039;' "'": "&#039;",
}; };
return text.replace(/[&<>"']/g, char => htmlEscapes[char]); return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
} }
/** /**
* Get user metadata for a nostr identifier (npub or nprofile) * Get user metadata for a nostr identifier (npub or nprofile)
*/ */
export async function getUserMetadata(identifier: string): Promise<NostrProfile> { export async function getUserMetadata(
identifier: string,
force = false,
): Promise<NostrProfile> {
// Remove nostr: prefix if present // Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
console.log("getUserMetadata called with identifier:", identifier, "force:", force);
if (npubCache.has(cleanId)) { if (!force && npubCache.has(cleanId)) {
return npubCache.get(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)}` }; const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
@ -59,44 +73,62 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.warn("getUserMetadata: No NDK instance available");
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
const decoded = nip19.decode(cleanId); const decoded = nip19.decode(cleanId);
if (!decoded) { if (!decoded) {
console.warn("getUserMetadata: Failed to decode identifier:", cleanId);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
// Handle different identifier types // Handle different identifier types
let pubkey: string; let pubkey: string;
if (decoded.type === 'npub') { if (decoded.type === "npub") {
pubkey = decoded.data; pubkey = decoded.data;
} else if (decoded.type === 'nprofile') { } else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey; pubkey = decoded.data.pubkey;
} else { } else {
console.warn("getUserMetadata: Unsupported identifier type:", decoded.type);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] }); console.log("getUserMetadata: Fetching profile for pubkey:", pubkey);
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
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 = { const metadata: NostrProfile = {
name: profile?.name || fallback.name, name: profile?.name || fallback.name,
displayName: profile?.displayName, displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05, nip05: profile?.nip05,
picture: profile?.image, picture: profile?.picture || profile?.image,
about: profile?.about, about: profile?.about,
banner: profile?.banner, banner: profile?.banner,
website: profile?.website, website: profile?.website,
lud16: profile?.lud16 lud16: profile?.lud16,
}; };
console.log("getUserMetadata: Final metadata:", metadata);
npubCache.set(cleanId, metadata); npubCache.set(cleanId, metadata);
return metadata; return metadata;
} catch (e) { } catch (e) {
console.error("getUserMetadata: Error fetching profile:", e);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
@ -105,27 +137,34 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
/** /**
* Create a profile link element * Create a profile link element
*/ */
export function createProfileLink(identifier: string, displayText: string | undefined): string { export function createProfileLink(
const cleanId = identifier.replace(/^nostr:/, ''); identifier: string,
displayText: string | undefined,
): string {
const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
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);
return `<a href="./events?id=${escapedId}" class="npub-badge" target="_blank">@${escapedText}</a>`; // Remove target="_blank" for internal navigation
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
} }
/** /**
* Create a profile link element with a NIP-05 verification indicator. * Create a profile link element with a NIP-05 verification indicator.
*/ */
export async function createProfileLinkWithVerification(identifier: string, displayText: string | undefined): Promise<string> { export async function createProfileLinkWithVerification(
identifier: string,
displayText: string | undefined,
): Promise<string> {
const ndk = get(ndkInstance) as NDK; const ndk = get(ndkInstance) as NDK;
if (!ndk) { if (!ndk) {
return createProfileLink(identifier, displayText); return createProfileLink(identifier, displayText);
} }
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const isNpub = cleanId.startsWith('npub'); const isNpub = cleanId.startsWith("npub");
let user: NDKUser; let user: NDKUser;
if (isNpub) { if (isNpub) {
@ -134,19 +173,39 @@ export async function createProfileLinkWithVerification(identifier: string, disp
user = ndk.getUser({ pubkey: cleanId }); user = ndk.getUser({ pubkey: cleanId });
} }
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url); const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
// Filter out problematic relays
const filterProblematicRelays = (relays: string[]) => {
return relays.filter((relay) => {
if (relay.includes("gitcitadel.nostr1.com")) {
console.info(
`[nostrUtils.ts] Filtering out problematic relay: ${relay}`,
);
return false;
}
return true;
});
};
const allRelays = [ const allRelays = [
...standardRelays, ...communityRelays,
...userRelays, ...userRelays,
...fallbackRelays ...secondaryRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx); ].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const filteredRelays = filterProblematicRelays(allRelays);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(filteredRelays, ndk);
const profileEvent = await ndk.fetchEvent( const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] }, { kinds: [0], authors: [user.pubkey] },
undefined, undefined,
relaySet relaySet,
); );
const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null; const profile = profileEvent?.content
? JSON.parse(profileEvent.content)
: null;
const nip05 = profile?.nip05; const nip05 = profile?.nip05;
if (!nip05) { if (!nip05) {
@ -155,7 +214,11 @@ export async function createProfileLinkWithVerification(identifier: string, disp
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 = profile?.displayName ?? profile?.name ?? escapedText; const displayIdentifier =
profile?.displayName ??
profile?.display_name ??
profile?.name ??
escapedText;
const isVerified = await user.validateNip05(nip05); const isVerified = await user.validateNip05(nip05);
@ -164,30 +227,32 @@ export async function createProfileLinkWithVerification(identifier: string, disp
} }
// TODO: Make this work with an enum in case we add more types. // TODO: Make this work with an enum in case we add more types.
const type = nip05.endsWith('edu') ? 'edu' : 'standard'; const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) { switch (type) {
case 'edu': case "edu":
return `<span class="npub-badge"><a href="./events?id=${escapedId}" target="_blank">@${displayIdentifier}</a>${graduationCapSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case 'standard': case "standard":
return `<span class="npub-badge"><a href="./events?id=${escapedId}" target="_blank">@${displayIdentifier}</a>${badgeCheckSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
} }
} }
/** /**
* Create a note link element * Create a note link element
*/ */
function createNoteLink(identifier: string): string { function createNoteLink(identifier: string): string {
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`;
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId); const escapedText = escapeHtml(shortId);
return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`;
} }
/** /**
* Process Nostr identifiers in text * Process Nostr identifiers in text
*/ */
export async function processNostrIdentifiers(content: string): Promise<string> { export async function processNostrIdentifiers(
content: string,
): Promise<string> {
let processedContent = content; let processedContent = content;
// Helper to check if a match is part of a URL // Helper to check if a match is part of a URL
@ -206,8 +271,8 @@ export async function processNostrIdentifiers(content: string): Promise<string>
continue; // skip if part of a URL continue; // skip if part of a URL
} }
let identifier = fullMatch; let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) { if (!identifier.startsWith("nostr:")) {
identifier = 'nostr:' + identifier; identifier = "nostr:" + identifier;
} }
const metadata = await getUserMetadata(identifier); const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
@ -224,8 +289,8 @@ export async function processNostrIdentifiers(content: string): Promise<string>
continue; // skip if part of a URL continue; // skip if part of a URL
} }
let identifier = fullMatch; let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) { if (!identifier.startsWith("nostr:")) {
identifier = 'nostr:' + identifier; identifier = "nostr:" + identifier;
} }
const link = createNoteLink(identifier); const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link); processedContent = processedContent.replace(fullMatch, link);
@ -236,19 +301,77 @@ export async function processNostrIdentifiers(content: string): Promise<string>
export async function getNpubFromNip05(nip05: string): Promise<string | null> { export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try { try {
const ndk = get(ndkInstance); // Parse the NIP-05 address
if (!ndk) { const [name, domain] = nip05.split("@");
console.error('NDK not initialized'); if (!name || !domain) {
console.error("[getNpubFromNip05] Invalid NIP-05 format:", nip05);
return null; return null;
} }
const user = await ndk.getUser({ nip05 }); // Fetch the well-known.json file with timeout and CORS handling
if (!user || !user.npub) { const url = wellKnownUrl(domain, name);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
try {
const response = await fetch(url, {
signal: controller.signal,
mode: "cors",
headers: {
Accept: "application/json",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error(
"[getNpubFromNip05] HTTP error:",
response.status,
response.statusText,
);
return null;
}
const data = await response.json();
// 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(
`[getNpubFromNip05] Found case-insensitive match: ${name} -> ${matchingName}`,
);
}
}
if (!pubkey) {
console.error("[getNpubFromNip05] No pubkey found for name:", name);
return null;
}
// Convert pubkey to npub
const npub = nip19.npubEncode(pubkey);
return npub;
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === "AbortError") {
console.warn("[getNpubFromNip05] Request timeout for:", url);
} else {
console.warn("[getNpubFromNip05] CORS or network error for:", url);
}
return null; return null;
} }
return user.npub;
} catch (error) { } catch (error) {
console.error('Error getting npub from nip05:', error); console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null; return null;
} }
} }
@ -256,8 +379,8 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
/** /**
* Generic utility function to add a timeout to any promise * Generic utility function to add a timeout to any promise
* Can be used in two ways: * Can be used in two ways:
* 1. Method style: promise.withTimeout(5000) * 1. Method style: promise.withTimeout(TIMEOUTS.GENERAL)
* 2. Function style: withTimeout(promise, 5000) * 2. Function style: withTimeout(promise, TIMEOUTS.GENERAL)
* *
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style) * @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style) * @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
@ -266,17 +389,17 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
*/ */
export function withTimeout<T>( export function withTimeout<T>(
thisOrPromise: Promise<T> | number, thisOrPromise: Promise<T> | number,
timeoutMsOrPromise?: number | Promise<T> timeoutMsOrPromise?: number | Promise<T>,
): Promise<T> { ): Promise<T> {
// Handle method-style call (promise.withTimeout(5000)) // Handle method-style call (promise.withTimeout(5000))
if (typeof thisOrPromise === 'number') { if (typeof thisOrPromise === "number") {
const timeoutMs = thisOrPromise; const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise<T>; const promise = timeoutMsOrPromise as Promise<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),
) ),
]); ]);
} }
@ -286,8 +409,8 @@ 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),
) ),
]); ]);
} }
@ -298,7 +421,10 @@ declare global {
} }
} }
Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number): Promise<T> { Promise.prototype.withTimeout = function <T>(
this: Promise<T>,
timeoutMs: number,
): Promise<T> {
return withTimeout(timeoutMs, this); return withTimeout(timeoutMs, this);
}; };
@ -311,68 +437,77 @@ Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number)
export async function fetchEventWithFallback( export async function fetchEventWithFallback(
ndk: NDK, ndk: NDK,
filterOrId: string | NDKFilter<NDKKind>, filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000 timeoutMs: number = 3000,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// Get user relays if logged in // Use both inbox and outbox relays for better event discovery
const userRelays = ndk.activeUser ? const inboxRelays = get(activeInboxRelays);
Array.from(ndk.pool?.relays.values() || []) const outboxRelays = get(activeOutboxRelays);
.filter(r => r.status === 1) // Only use connected relays const allRelays = [...inboxRelays, ...outboxRelays];
.map(r => r.url) :
[];
// Create three relay sets in priority order
const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
];
try { console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
let found: NDKEvent | null = null; console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays);
const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { // Check if we have any relays available
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs); if (allRelays.length === 0) {
} else { console.warn("fetchEventWithFallback: No relays available for event fetch");
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; return null;
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
return results instanceof Set ? Array.from(results)[0] as NDKEvent : null;
} }
// Create relay set from all available relays
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
try {
if (relaySet.relays.size === 0) {
console.warn("fetchEventWithFallback: No relays in relay set for event fetch");
return null;
} }
// Try each relay set in order console.log("fetchEventWithFallback: Relay set size:", relaySet.relays.size);
for (const [index, relaySet] of relaySets.entries()) { console.log("fetchEventWithFallback: Filter:", filterOrId);
const setName = index === 0 ? 'standard relays' : console.log("fetchEventWithFallback: Relay URLs:", Array.from(relaySet.relays).map((r) => r.url));
index === 1 ? 'user relays' :
'fallback relays';
found = await tryFetchFromRelaySet(relaySet, setName); let found: NDKEvent | null = null;
if (found) break;
if (
typeof filterOrId === "string" &&
new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, "i").test(filterOrId)
) {
found = await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs);
} else {
const filter =
typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId;
const results = await ndk
.fetchEvents(filter, undefined, relaySet)
.withTimeout(timeoutMs);
found = results instanceof Set
? (Array.from(results)[0] as NDKEvent)
: null;
} }
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => { const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(", ");
const setName = i === 0 ? 'standard relays' : console.warn(
i === 1 ? 'user relays' : `fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
'fallback relays'; );
const urls = Array.from(set.relays).map(r => r.url);
return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null;
}).filter(Boolean).join(', then ');
console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`);
return null; return null;
} }
console.log("fetchEventWithFallback: Found event:", found.id);
// 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) {
console.error('Error in fetchEventWithFallback:', err); if (err instanceof Error && err.message === 'Timeout') {
const timeoutSeconds = timeoutMs / 1000;
const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(", ");
console.warn(
`fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
);
} else {
console.error("fetchEventWithFallback: Error in fetchEventWithFallback:", err);
}
return null; return null;
} }
} }
@ -383,10 +518,10 @@ export async function fetchEventWithFallback(
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 (/^[a-f0-9]{64}$/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 (pubkey.startsWith("npub1")) return pubkey;
return null; return null;
} catch { } catch {
return null; return null;
@ -401,7 +536,7 @@ export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk); return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk);
} }
export function createNDKEvent(ndk: NDK, rawEvent: any) { export function createNDKEvent(ndk: NDK, rawEvent: NDKEvent | NostrEvent | undefined) {
return new NDKEvent(ndk, rawEvent); return new NDKEvent(ndk, rawEvent);
} }
@ -428,7 +563,7 @@ export function getEventHash(event: {
event.created_at, event.created_at,
event.kind, event.kind,
event.tags, event.tags,
event.content event.content,
]); ]);
return bytesToHex(sha256(serialized)); return bytesToHex(sha256(serialized));
} }
@ -444,3 +579,92 @@ export async function signEvent(event: {
const sig = await schnorr.sign(id, event.pubkey); const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig); return bytesToHex(sig);
} }
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink
*/
export function prefixNostrAddresses(content: string): string {
// Regex to match Nostr addresses that are not already prefixed with "nostr:"
// and are not part of a markdown link or HTML link
// Must be followed by at least 20 alphanumeric characters to be considered an address
const nostrAddressPattern =
/\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
return content.replace(nostrAddressPattern, (match, offset) => {
// Check if this match is part of a markdown link [text](url)
const beforeMatch = content.substring(0, offset);
const afterMatch = content.substring(offset + match.length);
// Check if it's part of a markdown link
const beforeBrackets = beforeMatch.lastIndexOf("[");
const afterParens = afterMatch.indexOf(")");
if (beforeBrackets !== -1 && afterParens !== -1) {
const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets);
const lastOpenBracket = textBeforeBrackets.lastIndexOf("[");
const lastCloseBracket = textBeforeBrackets.lastIndexOf("]");
// If we have [text] before this, it might be a markdown link
if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) {
return match; // Don't prefix if it's part of a markdown link
}
}
// Check if it's part of an HTML link
const beforeHref = beforeMatch.lastIndexOf("href=");
if (beforeHref !== -1) {
const afterHref = afterMatch.indexOf('"');
if (afterHref !== -1) {
return match; // Don't prefix if it's part of an HTML link
}
}
// Check if it's already prefixed with "nostr:"
const beforeNostr = beforeMatch.lastIndexOf("nostr:");
if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(" ")) {
return match; // Already prefixed
}
}
// Additional check: ensure it's actually a valid Nostr address format
// The part after the prefix should be a valid bech32 string
const addressPart = match.substring(4); // Remove npub, nprofile, etc.
if (addressPart.length < 20) {
return match; // Too short to be a valid address
}
// Check if it looks like a valid bech32 string (alphanumeric, no special chars)
if (!/^[a-zA-Z0-9]+$/.test(addressPart)) {
return match; // Not a valid bech32 format
}
// Additional check: ensure the word before is not a common word that would indicate
// this is just a general reference, not an actual address
const wordBefore = beforeMatch.match(/\b(\w+)\s*$/);
if (wordBefore) {
const beforeWord = wordBefore[1].toLowerCase();
const commonWords = [
"the",
"a",
"an",
"this",
"that",
"my",
"your",
"his",
"her",
"their",
"our",
];
if (commonWords.includes(beforeWord)) {
return match; // Likely just a general reference, not an actual address
}
}
// Prefix with "nostr:"
return `nostr:${match}`;
});
}

2
src/lib/utils/npubCache.ts

@ -1,4 +1,4 @@
import type { NostrProfile } from './nostrUtils'; import type { NostrProfile } from "./nostrUtils";
export type NpubMetadata = NostrProfile; export type NpubMetadata = NostrProfile;

393
src/lib/utils/profile_search.ts

@ -0,0 +1,393 @@
import { ndkInstance } from "../ndk.ts";
import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts";
import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts";
import { communityRelays, secondaryRelays } from "../consts.ts";
import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import {
fieldMatches,
nip05Matches,
normalizeSearchTerm,
createProfileFromEvent,
} from "./search_utils.ts";
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
*/
export async function searchProfiles(
searchTerm: string,
): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log(
"searchProfiles called with:",
searchTerm,
"normalized:",
normalizedSearchTerm,
);
// Check cache first
const cachedResult = searchCache.get("profile", normalizedSearchTerm);
if (cachedResult) {
console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events
.map((event) => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
})
.filter(Boolean) as NostrProfile[];
console.log("Cached profiles found:", profiles.length);
return { profiles, Status: {} };
}
const ndk = get(ndkInstance);
if (!ndk) {
console.error("NDK not initialized");
throw new Error("NDK not initialized");
}
console.log("NDK initialized, starting search logic");
let foundProfiles: NostrProfile[] = [];
try {
// Check if it's a valid npub/nprofile first
if (
normalizedSearchTerm.startsWith("npub") ||
normalizedSearchTerm.startsWith("nprofile")
) {
try {
const metadata = await getUserMetadata(normalizedSearchTerm);
if (metadata) {
foundProfiles = [metadata];
}
} catch (error) {
console.error("Error fetching metadata for npub:", error);
}
} else if (normalizedSearchTerm.includes("@")) {
// Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try {
const npub = await getNpubFromNip05(normalizedNip05);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
foundProfiles = [profile];
}
} catch (e) {
console.error("[Search] NIP-05 lookup failed:", e);
}
} else {
// Try NIP-05 search first (faster than relay search)
console.log("Starting NIP-05 search for:", normalizedSearchTerm);
foundProfiles = await searchNip05Domains(normalizedSearchTerm);
console.log(
"NIP-05 search completed, found:",
foundProfiles.length,
"profiles",
);
// If no NIP-05 results, try quick relay search
if (foundProfiles.length === 0) {
console.log("No NIP-05 results, trying quick relay search");
foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk);
console.log(
"Quick relay search completed, found:",
foundProfiles.length,
"profiles",
);
}
}
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || "";
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "profile",
searchTerm: normalizedSearchTerm,
};
searchCache.set("profile", normalizedSearchTerm, result);
}
console.log("Search completed, found profiles:", foundProfiles.length);
return { profiles: foundProfiles, Status: {} };
} catch (error) {
console.error("Error searching profiles:", error);
return { profiles: [], Status: {} };
}
}
/**
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(
searchTerm: string,
): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups
// Prioritize gitcitadel.com since we know it has profiles
const commonDomains = [
"gitcitadel.com", // Prioritize this domain
"theforest.nostr1.com",
"nostr1.com",
"nostr.land",
"sovbit.host",
"damus.io",
"snort.social",
"iris.to",
"coracle.social",
"nostr.band",
"nostr.wine",
"purplepag.es",
"relay.noswhere.com",
"aggr.nostr.land",
"nostr.sovbit.host",
"freelay.sovbit.host",
"nostr21.com",
"greensoul.space",
"relay.damus.io",
"relay.nostr.band",
];
// Normalize the search term for NIP-05 lookup
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("NIP-05 search: normalized search term:", normalizedSearchTerm);
// Try gitcitadel.com first with extra debugging
const gitcitadelAddress = `${normalizedSearchTerm}@gitcitadel.com`;
console.log("NIP-05 search: trying gitcitadel.com first:", gitcitadelAddress);
try {
const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) {
console.log(
"NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub,
);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
console.log(
"NIP-05 search: created profile for gitcitadel.com:",
profile,
);
foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else {
console.log("NIP-05 search: no npub found for gitcitadel.com");
}
} catch (e) {
console.log("NIP-05 search: error for gitcitadel.com:", e);
}
// If gitcitadel.com didn't work, try other domains
console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter(
(domain) => domain !== "gitcitadel.com",
);
// Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
console.log("NIP-05 search: trying address:", nip05Address);
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
console.log(
"NIP-05 search: created profile for",
nip05Address,
":",
profile,
);
return profile;
} else {
console.log("NIP-05 search: no npub found for", nip05Address);
}
} catch (e) {
console.log("NIP-05 search: error for", nip05Address, ":", e);
// Continue to next domain
}
return null;
});
// Wait for all searches with timeout
const results = await Promise.allSettled(searchPromises);
for (const result of results) {
if (result.status === "fulfilled" && result.value) {
foundProfiles.push(result.value);
}
}
console.log("NIP-05 search: total profiles found:", foundProfiles.length);
return foundProfiles;
}
/**
* Quick relay search with short timeout
*/
async function quickRelaySearch(
searchTerm: string,
ndk: NDK,
): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm);
// Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use all profile relays for better coverage
const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays
console.log("Using all relays for search:", quickRelayUrls);
// Create relay sets for parallel search
const relaySets = quickRelayUrls
.map((url) => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
console.warn(`Failed to create relay set for ${url}:`, e);
return null;
}
})
.filter(Boolean);
// Search all relays in parallel with short timeout
const searchPromises = relaySets.map((relaySet, index) => {
if (!relaySet) return [];
return new Promise<NostrProfile[]>((resolve) => {
const foundInRelay: NostrProfile[] = [];
let eventCount = 0;
console.log(
`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`,
);
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true },
relaySet,
);
sub.on("event", (event: NDKEvent) => {
eventCount++;
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName =
profileData.displayName || profileData.display_name || "";
const display_name = profileData.display_name || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(
displayName,
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (
matchesDisplayName ||
matchesDisplay_name ||
matchesName ||
matchesNip05 ||
matchesAbout
) {
console.log(`Found matching profile on relay ${index + 1}:`, {
name: profileData.name,
display_name: profileData.display_name,
nip05: profileData.nip05,
pubkey: event.pubkey,
searchTerm: normalizedSearchTerm,
});
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile in this relay
const existingIndex = foundInRelay.findIndex(
(p) => p.pubkey === event.pubkey,
);
if (existingIndex === -1) {
foundInRelay.push(profile);
}
}
} catch {
// Invalid JSON or other error, skip
}
});
sub.on("eose", () => {
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
resolve(foundInRelay);
});
// Short timeout for quick search
setTimeout(() => {
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
sub.stop();
resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay
});
});
// Wait for all searches to complete
const results = await Promise.allSettled(searchPromises);
// Combine and deduplicate results
const allProfiles: Record<string, NostrProfile> = {};
for (const result of results) {
if (result.status === "fulfilled") {
for (const profile of result.value) {
if (profile.pubkey) {
allProfiles[profile.pubkey] = profile;
}
}
}
}
console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles);
}

142
src/lib/utils/relayDiagnostics.ts

@ -0,0 +1,142 @@
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { TIMEOUTS } from "./search_constants.ts";
import { get } from "svelte/store";
export interface RelayDiagnostic {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
responseTime?: number;
}
/**
* Tests connection to a single relay
*/
export function testRelay(url: string): Promise<RelayDiagnostic> {
const startTime = Date.now();
return new Promise((resolve) => {
const ws = new WebSocket(url);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
ws.close();
resolve({
url,
connected: false,
requiresAuth: false,
error: "Connection timeout",
responseTime: Date.now() - startTime,
});
}
}, TIMEOUTS.RELAY_DIAGNOSTICS);
ws.onopen = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
ws.close();
resolve({
url,
connected: true,
requiresAuth: false,
responseTime: Date.now() - startTime,
});
}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve({
url,
connected: false,
requiresAuth: false,
error: "WebSocket error",
responseTime: Date.now() - startTime,
});
}
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === "NOTICE" && data[1]?.includes("auth-required")) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
ws.close();
resolve({
url,
connected: true,
requiresAuth: true,
responseTime: Date.now() - startTime,
});
}
}
};
});
}
/**
* Tests all relays and returns diagnostic information
*/
export async function testAllRelays(): Promise<RelayDiagnostic[]> {
// Use the new relay management system
const allRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
console.log("[RelayDiagnostics] Testing", allRelays.length, "relays...");
const results = await Promise.allSettled(
allRelays.map((url) => testRelay(url)),
);
return results.map((result, index) => {
if (result.status === "fulfilled") {
return result.value;
} else {
return {
url: allRelays[index],
connected: false,
requiresAuth: false,
error: "Test failed",
};
}
});
}
/**
* Gets working relays from diagnostic results
*/
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] {
return diagnostics.filter((d) => d.connected).map((d) => d.url);
}
/**
* Logs relay diagnostic results to console
*/
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void {
console.group("[RelayDiagnostics] Results");
const working = diagnostics.filter((d) => d.connected);
const failed = diagnostics.filter((d) => !d.connected);
console.log(`✅ Working relays (${working.length}):`);
working.forEach((d) => {
console.log(
` - ${d.url}${d.requiresAuth ? " (requires auth)" : ""}${d.responseTime ? ` (${d.responseTime}ms)` : ""}`,
);
});
if (failed.length > 0) {
console.log(`❌ Failed relays (${failed.length}):`);
failed.forEach((d) => {
console.log(` - ${d.url}: ${d.error || "Unknown error"}`);
});
}
console.groupEnd();
}

531
src/lib/utils/relay_management.ts

@ -0,0 +1,531 @@
import NDK, { NDKKind, NDKRelay, NDKUser } from "@nostr-dev-kit/ndk";
import { searchRelays, secondaryRelays, anonymousRelays, lowbandwidthRelays, localRelays } from "../consts.ts";
import { getRelaySetForNetworkCondition } from "./network_detection.ts";
import { networkCondition } from "../stores/networkStore.ts";
import { get } from "svelte/store";
/**
* Normalizes a relay URL to a standard format
* @param url The relay URL to normalize
* @returns The normalized relay URL
*/
export function normalizeRelayUrl(url: string): string {
let normalized = url.toLowerCase().trim();
// Ensure protocol is present
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = 'wss://' + normalized;
}
// Remove trailing slash
normalized = normalized.replace(/\/$/, '');
return normalized;
}
/**
* Normalizes an array of relay URLs
* @param urls Array of relay URLs to normalize
* @returns Array of normalized relay URLs
*/
export function normalizeRelayUrls(urls: string[]): string[] {
return urls.map(normalizeRelayUrl);
}
/**
* Removes duplicates from an array of relay URLs
* @param urls Array of relay URLs
* @returns Array of unique relay URLs
*/
export function deduplicateRelayUrls(urls: string[]): string[] {
const normalized = normalizeRelayUrls(urls);
return [...new Set(normalized)];
}
/**
* Tests connection to a relay and returns connection status
* @param relayUrl The relay URL to test
* @param ndk The NDK instance
* @returns Promise that resolves to connection status
*/
export function testRelayConnection(
relayUrl: string,
ndk: NDK,
): Promise<{
connected: boolean;
requiresAuth: boolean;
error?: string;
actualUrl?: string;
}> {
return new Promise((resolve) => {
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl);
// Use the existing NDK instance instead of creating a new one
const relay = new NDKRelay(secureUrl, undefined, ndk);
let authRequired = false;
let connected = false;
let error: string | undefined;
let actualUrl: string | undefined;
const timeout = setTimeout(() => {
relay.disconnect();
resolve({
connected: false,
requiresAuth: authRequired,
error: "Connection timeout",
actualUrl,
});
}, 3000); // Increased timeout to 3 seconds to give relays more time
relay.on("connect", () => {
connected = true;
actualUrl = secureUrl;
clearTimeout(timeout);
relay.disconnect();
resolve({
connected: true,
requiresAuth: authRequired,
error,
actualUrl,
});
});
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
authRequired = true;
}
});
relay.on("disconnect", () => {
if (!connected) {
error = "Connection failed";
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl,
});
}
});
relay.connect();
});
}
/**
* Ensures a relay URL uses secure WebSocket protocol for remote relays
* @param url The relay URL to secure
* @returns The URL with wss:// protocol (except for localhost)
*/
function ensureSecureWebSocket(url: string): string {
// 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://");
if (secureUrl !== url) {
console.warn(
`[relay_management.ts] Protocol upgrade for rem ote relay: ${url} -> ${secureUrl}`,
);
}
return secureUrl;
}
/**
* Tests connection to local relays
* @param localRelayUrls Array of local relay URLs to test
* @param ndk NDK instance
* @returns Promise that resolves to array of working local relay URLs
*/
async function testLocalRelays(localRelayUrls: string[], ndk: NDK): Promise<string[]> {
const workingRelays: string[] = [];
if (localRelayUrls.length === 0) {
return workingRelays;
}
console.debug(`[relay_management.ts] Testing ${localRelayUrls.length} local relays...`);
await Promise.all(
localRelayUrls.map(async (url) => {
try {
const result = await testRelayConnection(url, ndk);
if (result.connected) {
workingRelays.push(url);
console.debug(`[relay_management.ts] Local relay connected: ${url}`);
} else {
console.debug(`[relay_management.ts] Local relay failed: ${url} - ${result.error}`);
}
} catch {
// Silently ignore local relay failures - they're optional
console.debug(`[relay_management.ts] Local relay error (ignored): ${url}`);
}
})
);
console.debug(`[relay_management.ts] Found ${workingRelays.length} working local relays`);
return workingRelays;
}
/**
* Discovers local relays by testing common localhost URLs
* @param ndk NDK instance
* @returns Promise that resolves to array of working local relay URLs
*/
export async function discoverLocalRelays(ndk: NDK): Promise<string[]> {
try {
// If no local relays are configured, return empty array
if (localRelays.length === 0) {
console.debug('[relay_management.ts] No local relays configured');
return [];
}
// Convert wss:// URLs from consts to ws:// for local testing
const localRelayUrls = localRelays.map((url: string) =>
url.replace(/^wss:\/\//, 'ws://')
);
const workingRelays = await testLocalRelays(localRelayUrls, ndk);
// If no local relays are working, return empty array
// The network detection logic will provide fallback relays
return workingRelays;
} catch {
// Silently fail and return empty array
return [];
}
}
/**
* Fetches user's local relays from kind 10432 event
* @param ndk NDK instance
* @param user User to fetch local relays for
* @returns Promise that resolves to array of local relay URLs
*/
export async function getUserLocalRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
const localRelayEvent = await ndk.fetchEvent(
{
kinds: [10432 as NDKKind],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
}
);
if (!localRelayEvent) {
return [];
}
const localRelays: string[] = [];
localRelayEvent.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) {
localRelays.push(tag[1]);
}
});
return localRelays;
} catch (error) {
console.info('[relay_management.ts] Error fetching user local relays:', error);
return [];
}
}
/**
* Fetches user's blocked relays from kind 10006 event
* @param ndk NDK instance
* @param user User to fetch blocked relays for
* @returns Promise that resolves to array of blocked relay URLs
*/
export async function getUserBlockedRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
const blockedRelayEvent = await ndk.fetchEvent(
{
kinds: [10006],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
}
);
if (!blockedRelayEvent) {
return [];
}
const blockedRelays: string[] = [];
blockedRelayEvent.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) {
blockedRelays.push(tag[1]);
}
});
return blockedRelays;
} catch (error) {
console.info('[relay_management.ts] Error fetching user blocked relays:', error);
return [];
}
}
/**
* Fetches user's outbox relays from NIP-65 relay list
* @param ndk NDK instance
* @param user User to fetch outbox relays for
* @returns Promise that resolves to array of outbox relay URLs
*/
export async function getUserOutboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
console.debug('[relay_management.ts] Fetching outbox relays for user:', user.pubkey);
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
}
);
if (!relayList) {
console.debug('[relay_management.ts] No relay list found for user');
return [];
}
console.debug('[relay_management.ts] Found relay list event:', relayList.id);
console.debug('[relay_management.ts] Relay list tags:', relayList.tags);
const outboxRelays: string[] = [];
relayList.tags.forEach((tag) => {
console.debug('[relay_management.ts] Processing tag:', tag);
if (tag[0] === 'w' && tag[1]) {
outboxRelays.push(tag[1]);
console.debug('[relay_management.ts] Added outbox relay:', tag[1]);
} else if (tag[0] === 'r' && tag[1]) {
// Some relay lists use 'r' for both inbox and outbox
outboxRelays.push(tag[1]);
console.debug('[relay_management.ts] Added relay (r tag):', tag[1]);
} else {
console.debug('[relay_management.ts] Skipping tag:', tag[0], 'value:', tag[1]);
}
});
console.debug('[relay_management.ts] Final outbox relays:', outboxRelays);
return outboxRelays;
} catch (error) {
console.info('[relay_management.ts] Error fetching user outbox relays:', error);
return [];
}
}
/**
* Gets browser extension's relay configuration by querying the extension directly
* @returns Promise that resolves to array of extension relay URLs
*/
export async function getExtensionRelays(): Promise<string[]> {
try {
// Check if we're in a browser environment with extension support
if (typeof window === 'undefined' || !globalThis.nostr) {
console.debug('[relay_management.ts] No globalThis.nostr available');
return [];
}
console.debug('[relay_management.ts] Extension available, checking for getRelays()');
const extensionRelays: string[] = [];
// Try to get relays from the extension's API
// Different extensions may expose their relay config differently
if (globalThis.nostr.getRelays) {
console.debug('[relay_management.ts] getRelays() method found, calling it...');
try {
const relays = await globalThis.nostr.getRelays();
console.debug('[relay_management.ts] getRelays() returned:', relays);
if (relays && typeof relays === 'object') {
// Convert relay object to array of URLs
const relayUrls = Object.keys(relays);
extensionRelays.push(...relayUrls);
console.debug('[relay_management.ts] Got relays from extension:', relayUrls);
}
} catch (error) {
console.debug('[relay_management.ts] Extension getRelays() failed:', error);
}
} else {
console.debug('[relay_management.ts] getRelays() method not found on globalThis.nostr');
}
// If getRelays() didn't work, try alternative methods
if (extensionRelays.length === 0) {
// Some extensions might expose relays through other methods
// This is a fallback for extensions that don't expose getRelays()
console.debug('[relay_management.ts] Extension does not expose relay configuration');
}
console.debug('[relay_management.ts] Final extension relays:', extensionRelays);
return extensionRelays;
} catch (error) {
console.debug('[relay_management.ts] Error getting extension relays:', error);
return [];
}
}
/**
* Tests a set of relays in batches to avoid overwhelming them
* @param relayUrls Array of relay URLs to test
* @param ndk NDK instance
* @returns Promise that resolves to array of working relay URLs
*/
async function testRelaySet(relayUrls: string[], ndk: NDK): Promise<string[]> {
const workingRelays: string[] = [];
const maxConcurrent = 2; // Reduce to 2 relays at a time to avoid overwhelming them
for (let i = 0; i < relayUrls.length; i += maxConcurrent) {
const batch = relayUrls.slice(i, i + maxConcurrent);
const batchPromises = batch.map(async (url) => {
try {
const result = await testRelayConnection(url, ndk);
return result.connected ? url : null;
} catch (error) {
console.debug(`[relay_management.ts] Failed to test relay ${url}:`, error);
return null;
}
});
const batchResults = await Promise.allSettled(batchPromises);
const batchWorkingRelays = batchResults
.filter((result): result is PromiseFulfilledResult<string | null> => result.status === 'fulfilled')
.map(result => result.value)
.filter((url): url is string => url !== null);
workingRelays.push(...batchWorkingRelays);
}
return workingRelays;
}
/**
* Builds a complete relay set for a user, including local, user-specific, and fallback relays
* @param ndk NDK instance
* @param user NDKUser or null for anonymous access
* @returns Promise that resolves to inbox and outbox relay arrays
*/
export async function buildCompleteRelaySet(
ndk: NDK,
user: NDKUser | null
): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
console.debug('[relay_management.ts] buildCompleteRelaySet: Starting with user:', user?.pubkey || 'null');
// Discover local relays first
const discoveredLocalRelays = await discoverLocalRelays(ndk);
console.debug('[relay_management.ts] buildCompleteRelaySet: Discovered local relays:', discoveredLocalRelays);
// Get user-specific relays if available
let userOutboxRelays: string[] = [];
let userLocalRelays: string[] = [];
let blockedRelays: string[] = [];
let extensionRelays: string[] = [];
if (user) {
console.debug('[relay_management.ts] buildCompleteRelaySet: Fetching user-specific relays for:', user.pubkey);
try {
userOutboxRelays = await getUserOutboxRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User outbox relays:', userOutboxRelays);
} catch (error) {
console.debug('[relay_management.ts] Error fetching user outbox relays:', error);
}
try {
userLocalRelays = await getUserLocalRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User local relays:', userLocalRelays);
} catch (error) {
console.debug('[relay_management.ts] Error fetching user local relays:', error);
}
try {
blockedRelays = await getUserBlockedRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User blocked relays:', blockedRelays);
} catch {
// Silently ignore blocked relay fetch errors
}
try {
extensionRelays = await getExtensionRelays();
console.debug('[relay_management.ts] Extension relays gathered:', extensionRelays);
} catch (error) {
console.debug('[relay_management.ts] Error fetching extension relays:', error);
}
} else {
console.debug('[relay_management.ts] buildCompleteRelaySet: No user provided, skipping user-specific relays');
}
// Build initial relay sets and deduplicate
const finalInboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...userLocalRelays]);
const finalOutboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...userOutboxRelays, ...extensionRelays]);
// Test relays and filter out non-working ones
let testedInboxRelays: string[] = [];
let testedOutboxRelays: string[] = [];
if (finalInboxRelays.length > 0) {
testedInboxRelays = await testRelaySet(finalInboxRelays, ndk);
}
if (finalOutboxRelays.length > 0) {
testedOutboxRelays = await testRelaySet(finalOutboxRelays, ndk);
}
// If no relays passed testing, use remote relays without testing
if (testedInboxRelays.length === 0 && testedOutboxRelays.length === 0) {
const remoteRelays = deduplicateRelayUrls([...secondaryRelays, ...searchRelays]);
return {
inboxRelays: remoteRelays,
outboxRelays: remoteRelays
};
}
// Use tested relays and deduplicate
const inboxRelays = testedInboxRelays.length > 0 ? deduplicateRelayUrls(testedInboxRelays) : deduplicateRelayUrls(secondaryRelays);
const outboxRelays = testedOutboxRelays.length > 0 ? deduplicateRelayUrls(testedOutboxRelays) : deduplicateRelayUrls(secondaryRelays);
// Apply network condition optimization
const currentNetworkCondition = get(networkCondition);
const networkOptimizedRelaySet = getRelaySetForNetworkCondition(
currentNetworkCondition,
discoveredLocalRelays,
lowbandwidthRelays,
{ inboxRelays, outboxRelays }
);
// Filter out blocked relays and deduplicate final sets
const finalRelaySet = {
inboxRelays: deduplicateRelayUrls(networkOptimizedRelaySet.inboxRelays.filter((r: string) => !blockedRelays.includes(r))),
outboxRelays: deduplicateRelayUrls(networkOptimizedRelaySet.outboxRelays.filter((r: string) => !blockedRelays.includes(r)))
};
// If no relays are working, use anonymous relays as fallback
if (finalRelaySet.inboxRelays.length === 0 && finalRelaySet.outboxRelays.length === 0) {
return {
inboxRelays: deduplicateRelayUrls(anonymousRelays),
outboxRelays: deduplicateRelayUrls(anonymousRelays)
};
}
console.debug('[relay_management.ts] buildCompleteRelaySet: Final relay sets - inbox:', finalRelaySet.inboxRelays.length, 'outbox:', finalRelaySet.outboxRelays.length);
console.debug('[relay_management.ts] buildCompleteRelaySet: Final inbox relays:', finalRelaySet.inboxRelays);
console.debug('[relay_management.ts] buildCompleteRelaySet: Final outbox relays:', finalRelaySet.outboxRelays);
return finalRelaySet;
}

108
src/lib/utils/searchCache.ts

@ -0,0 +1,108 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { CACHE_DURATIONS, TIMEOUTS } from "./search_constants.ts";
export interface SearchResult {
events: NDKEvent[];
secondOrder: NDKEvent[];
tTagEvents: NDKEvent[];
eventIds: Set<string>;
addresses: Set<string>;
searchType: string;
searchTerm: string;
timestamp: number;
}
class SearchCache {
private cache: Map<string, SearchResult> = new Map();
private readonly CACHE_DURATION = CACHE_DURATIONS.SEARCH_CACHE;
/**
* Generate a cache key for a search
*/
private generateKey(searchType: string, searchTerm: string): string {
if (!searchTerm) {
return `${searchType}:`;
}
return `${searchType}:${searchTerm.toLowerCase().trim()}`;
}
/**
* Check if a cached result is still valid
*/
private isExpired(result: SearchResult): boolean {
return Date.now() - result.timestamp > this.CACHE_DURATION;
}
/**
* Get cached search results
*/
get(searchType: string, searchTerm: string): SearchResult | null {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
if (!result || this.isExpired(result)) {
if (result) {
this.cache.delete(key);
}
return null;
}
return result;
}
/**
* Store search results in cache
*/
set(
searchType: string,
searchTerm: string,
result: Omit<SearchResult, "timestamp">,
): void {
const key = this.generateKey(searchType, searchTerm);
this.cache.set(key, {
...result,
timestamp: Date.now(),
});
}
/**
* Check if a search result is cached and valid
*/
has(searchType: string, searchTerm: string): boolean {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
return result !== undefined && !this.isExpired(result);
}
/**
* Clear expired entries from cache
*/
cleanup(): void {
for (const [key, result] of this.cache.entries()) {
if (this.isExpired(result)) {
this.cache.delete(key);
}
}
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
}
export const searchCache = new SearchCache();
// Clean up expired entries periodically
setInterval(() => {
searchCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

124
src/lib/utils/search_constants.ts

@ -0,0 +1,124 @@
/**
* Search and Event Utility Constants
*
* This file centralizes all magic numbers used throughout the search and event utilities
* to improve maintainability and reduce code duplication.
*/
// Timeout constants (in milliseconds)
export const TIMEOUTS = {
/** Default timeout for event fetching operations */
EVENT_FETCH: 10000,
/** Timeout for profile search operations */
PROFILE_SEARCH: 15000,
/** Timeout for subscription search operations */
SUBSCRIPTION_SEARCH: 10000,
/** Timeout for second-order search operations */
SECOND_ORDER_SEARCH: 5000,
/** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000,
/** Timeout for general operations */
GENERAL: 5000,
/** Cache cleanup interval */
CACHE_CLEANUP: 60000,
} as const;
// Cache duration constants (in milliseconds)
export const CACHE_DURATIONS = {
/** Default cache duration for search results */
SEARCH_CACHE: 5 * 60 * 1000, // 5 minutes
/** Cache duration for index events */
INDEX_EVENT_CACHE: 10 * 60 * 1000, // 10 minutes
} as const;
// Search limits
export const SEARCH_LIMITS = {
/** Limit for specific profile searches (npub, NIP-05) */
SPECIFIC_PROFILE: 10,
/** Limit for general profile searches */
GENERAL_PROFILE: 500,
/** Limit for community relay checks */
COMMUNITY_CHECK: 1,
/** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100,
} as const;
// Nostr event kind ranges
export const EVENT_KINDS = {
/** Replaceable event kinds (0, 3, 10000-19999) */
REPLACEABLE: {
MIN: 0,
MAX: 19999,
SPECIFIC: [0, 3],
},
/** Parameterized replaceable event kinds (20000-29999) */
PARAMETERIZED_REPLACEABLE: {
MIN: 20000,
MAX: 29999,
},
/** Addressable event kinds (30000-39999) */
ADDRESSABLE: {
MIN: 30000,
MAX: 39999,
},
/** Comment event kind */
COMMENT: 1111,
/** Text note event kind */
TEXT_NOTE: 1,
/** Profile metadata event kind */
PROFILE_METADATA: 0,
} as const;
// Relay-specific constants
export const RELAY_CONSTANTS = {
/** Request ID for community relay checks */
COMMUNITY_REQUEST_ID: "alexandria-forest",
/** Default relay request kinds for community checks */
COMMUNITY_REQUEST_KINDS: [1],
} as const;
// Time constants
export const TIME_CONSTANTS = {
/** Unix timestamp conversion factor (seconds to milliseconds) */
UNIX_TIMESTAMP_FACTOR: 1000,
/** Current timestamp in seconds */
CURRENT_TIMESTAMP: Math.floor(Date.now() / 1000),
} as const;
// Validation constants
export const VALIDATION = {
/** Hex string length for event IDs and pubkeys */
HEX_LENGTH: 64,
/** Minimum length for Nostr identifiers */
MIN_NOSTR_IDENTIFIER_LENGTH: 4,
} as const;
// HTTP status codes
export const HTTP_STATUS = {
/** OK status code */
OK: 200,
/** Not found status code */
NOT_FOUND: 404,
/** Internal server error status code */
INTERNAL_SERVER_ERROR: 500,
} as const;

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

Loading…
Cancel
Save